4 Введение Наблюдаемое в течение последних 20 лет стремительное увеличение количества используемых неуклонным ростом их вычислительных возможностей) машин привело (сопровождаемое к значительному расширению, как круга пользователей ЭВМ, так и сферы применения компьютеров. Одновременное развитие телекоммуникационных систем, рост пропускной способности и общего количества линий связи, появление и развитие глобальных компьютерных сетей, в настоящее время доступных сотням миллионов пользователей, вызвали быстрое увеличение объемов хранимой и передаваемой в информации. В связи с продолжающимся ростом глобальных сетей и расширением спектра предоставляемых ими услуг при экспоненциальном росте числа пользователей сетей, а также успешно выполняемыми проектами по переводу в электронно-читаемый вид содержимого огромных библиотек (Королевской Библиотеки Великобритании, Библиотеки Конгресса США и др.), выставочных залов и художественных галерей, следует ожидать, что объемы хранимой и передаваемой по системам связи информации будут продолжать увеличиваться с такой же, если не большей, скоростью. Таким образом, задача хранения и передачи текстовой, графической, звуковой и другой информации в наиболее компактном виде достаточно актуальна. Целью данной работы является рассмотрение алгоритм кодирования методом Хаффмана и его реализация на языке Haskell. Достижение данной цели предполагает последовательное решение следующих задач: изучение основных понятий; изучение и анализ алгоритма построения кода Хаффмана; реализацию алгоритма программирования Haskell. на функциональном языке 5 1 Деревья Хаффмана Предположим, требуется закодировать текст, состоящий из символов некоторого n-символьного алфавита, назначая каждому из символов текста некоторую последовательность битов, именуемую кодом (codeword). Например, мы можем использовать кодирование фиксированной длины (fixed-length encoding), когда каждому символу назначается битовая строка одной и той же длины m (m >= log2n). Это именно тот способ, который используется в стандартном семибитовом кодировании ASCII. Один из способов получить схему кодирования, которая в среднем дает более короткую битовую строку, основан на старой идее назначать более короткие коды чаще встречающимся символам, а более длинные — тем, что встречаются реже. (Эта идея, в частности, использована в телеграфных кодах, разработанных в средине XIX века Сэмюэлем Морзе (Samuel Morse). В этом коде часто встречающимся буквам, таким как е (•) или а (• -), назначены короткие последовательности точек и тире, в то время как редкие буквы, такие как q (- - • -) и z (- - • •), имеют длинные последовательности.) Использование кодирования переменной длины (variable-length encoding), при котором различным символам назначаются коды разной длины, создает проблемы, которых нет при кодировании постоянной длины, а именно: как определить, сколько битов кодированного текста представляют первый (или, в общем случае, i-ый) символ? Для того чтобы избежать этой сложности, мы можем ограничиться префиксными кодами (prefix code). При использовании префиксного кода ни один код не является префиксом другого кода. Следовательно, при таком кодировании мы просто сканируем битовую строку, пока не получим первую группу битов, являющуюся кодом некоторого символа, заменяем эти биты соответствующим символом и повторяем операцию, пока не будет достигнут конец битовой строки. 6 Если мы хотим создать бинарный префиксный код для некоторого алфавита, естественным представляется связать его символы с листьями бинарного дерева, в котором все левые ребра помечены 0, а правые — 1 (или наоборот). Код символа можно получить путем записи меток ребер на простом пути от корня дерева к листу символа. Поскольку простого пути к листу, который бы продолжался к другому листу, не существует, нет и кода, который бы был префиксом другого кода; таким образом, все такие деревья приводят к префиксному кодированию. Как среди множества деревьев, которые могут быть построены таким образом для алфавита с известной частотой употребления символов, найти дерево, которое назначает короткие строки часто встречающимся символам и длинные — редко встречающимся? Это можно сделать при помощи следующего жадного алгоритма, открытого Дэвидом Хаффманом (David Huffman) во время его учебы в Массачуссетском технологическом институте. 7 2 Алгоритм Хаффмана Шаг 1. Инициализируем n одноузловых деревьев и помечаем их символами алфавита. Записываем частоту каждого символа в корне его дерева в качестве веса дерева. (В общем случае вес дерева равен сумме частот, указанных в листьях дерева.) Шаг 2. Повторяем следующую операцию до тех пор, пока не получим единое дерево. Находим два дерева с наименьшими весами и делаем их левым и правым поддеревьями нового дерева, в корне которого записываем сумму их весов в качестве веса образованного дерева. Дерево, построенное по такому алгоритму, называется деревом Хаффмана (Huffman tree), а код, который оно определяет, — кодом Хаффмана (Huffman code). Пример. Рассмотрим пяти символьный алфавит {А, В, С, D, _}, вероятность появления каждого символа указана в таблице 1. Таблица 1 – Вероятности появления символов в исходном тексте Символ Вероятность A B C D _ 0,35 0,1 0,2 0,2 0,15 Построение дерева Хаффмана для приведенных входных данных показано на рисунке 2.1. В результате мы получаем коды символов (таблица 2). Таблица 2 – Коды символов в соответствии с полученным деревом Символ Вероятность Код A B C D _ 0,35 0,1 0,2 0,2 0,15 11 100 00 01 101 8 Следовательно, DAD кодируется как 011101, а 10011011011101 декодируется как BAD AD. Для данных вероятностей символов и полученных кодов математическое ожидание количества битов для кодирования одного Рисунок 2.1 - Пример построения дерева кодирования Хаффмана символа составляет: 2 * 0.35 + 3 * 0.1 + 2 * 0.2 + 2 * 0.2 + 3 * 0.15 = 2.25. Если бы мы применили код фиксированной длины для того же алфавита, нам бы пришлось использовать как минимум три бита для каждого символа. Таким образом, в этом примере применение кода Хаффмана позволило достичь степени сжатия (compression ratio), 9 стандартной меры эффективности алгоритма сжатия информации, равной (3 - 2.25)/3 • 100% = 25%. Другими словами, мы должны ожидать, что кодирование Хаффмана, примененное к некоторому тексту на основе данного алфавита, использует на 25% меньше памяти, чем кодирование фиксированной длины (большое количество экспериментов, выполненных с кодами Хаффмана, показывает, что степень сжатия в данной схеме обычно оказывается в диапазоне от 20% до 80%, в зависимости от характеристик сжимаемого файла). Кодирование Хаффмана — один из наиболее важных методов сжатия файлов. Кроме простоты и универсальности он обладает тем достоинством, что приводит к оптимальному (т.е. минимальной длины) кодированию (при условии, что вероятности появления символов независимы и известны заранее). Простейшая версия сжатия Хаффмана в действительности использует предварительное сканирование текста для подсчета частот появления в нем различных символов. Затем на основании этих частот строится дерево Хаффмана, и с его помощью выполняется кодирование текста. Однако такая схема делает необходимым включение в закодированный текст информации о дереве кодирования для того, чтобы текст было можно декодировать. Преодолеть этот недостаток можно с помощью так называемого динамического кодирования Хаффмана (dynamic Huffman encoding), при котором дерево кодирования обновляется всякий раз, когда из исходного текста считывается очередной символ. Важно отметить, что алгоритм Хаффмана не ограничивается сжатием данных. Предположим, у нас есть n положительных чисел w 1 ,w 2 , …. ,w n , которые назначены n листьям бинарного дерева, по одному на узел. Если мы определим взвешенную длину пути как сумму ∑𝑛𝑖=1 𝑙𝑖 𝑤𝑖 , где li длина простого пути от корня к i-му листу, то как можно построить бинарное дерево с минимальной взвешенной длиной пути? Это — более общая задача, решаемая алгоритмом Хаффмана (в рассматриваемом случае кодирования 𝑙𝑖 и 𝑤𝑖 представляют собой, соответственно, длину кода и 10 частоту появления i-го символа). Эта задача возникает во многих ситуациях, включающих принятие решения. Рассмотрим, например, игру с угадыванием выбранного предмета из n возможных (например, целого числа от 1 до n) с помощью вопросов, ответы на которые должны быть "да" или "нет". Различные стратегии, применяемые во время игры, могут быть смоделированы при помощи деревьев принятия решений (decision trees)4. Пример такого дерева для n = 4 приведен на рисунок 2.2. Рисунок 2.2 - Два дерева принятия решения для угадывания целого числа от 1 до 4 Длина простого пути от корня к листу в таком дереве равна количеству вопросов, требующихся для выбора числа, представленного в этом листе. Если число i выбирается с вероятностью рi, то сумма ∑𝑛𝑖=1 𝑙𝑖 𝑝𝑖 ( где 𝑙𝑖 - длина пути от корня до i-го листа) определяет среднее количество вопросов, необходимых для "угадывания" выбранного числа при использовании стратегии игры, представленной этим деревом принятия решения. Если каждое из чисел выбирается с одной и той же вероятностью, равной 1/n, то наилучшая стратегия состоит в последовательном исключении половины (или почти половины) кандидатов, как это делается при бинарном поиске. Однако в случае произвольных pi это может быть неподходящей стратегией (например, при п = 4, р1 = 0.1, р2 = 0.2, р3= 0.3 и р4 = 0.4 дерево с минимальной взвешенной длиной пути выглядит так, как показано на рисунок 2.2 справа). Таким образом, для решения этой задачи в общем виде мы должны воспользоваться алгоритмом Хаффмана [2]. 11 3 Передача кодового дерева Для того чтобы закодированное сообщение удалось декодировать, декодеру необходимо иметь такое же кодовое дерево (в той или иной форме), какое использовалось при кодировании. Поэтому вместе с закодированными данными мы вынуждены сохранять соответствующее кодовое дерево. Ясно, что чем компактнее оно будет, тем лучше. Решить эту задачу можно несколькими способами. Самое очевидное решение - сохранить дерево в явном виде (т.е. как упорядоченное множество узлов и указателей того или иного вида). Это пожалуй самый расточительный и неэффективный способ, на практике он не используется. Можно сохранить список частот символов (т.е. частотный словарь). С его помощью декодер без труда сможет реконструировать кодовое дерево. Хотя этот способ и менее расточителен чем предыдущий, он не является наилучшим. Наконец, можно использовать одно из свойств канонических кодов. Как уже было отмечено ранее, канонические коды вполне определяются своими длинами. Другими словами, все что необходимо декодеру - это список длин кодов символов. Учитывая, что в среднем длину одного кода для N-символьного алфавита можно закодировать [(log2(log2N))] битами, получим очень эффективный алгоритм. На нем мы остановимся подробнее. Предположим, что размер алфавита N=256, и мы сжимаем обыкновенный текстовый файл (ASCII). Скорее всего мы не встретим все N символов нашего алфавита в таком файле. Положим тогда длину кода отсутвующих символов равной нулю. В этом случае сохраняемый список длин кодов будет содержать достаточно большое число нулей (длин кодов отсутствующих символов) сгруппированных вместе. Каждую такую группу можно сжать при помощи так называемого группового кодирования - RLE (Run - Length - Encoding). Этот алгоритм чрезвычайно прост. Вместо последовательности из M одинаковых элементов идущих подряд, будем 12 сохранять первый элемент этой последовательности и число его повторений, т.е. (M-1). Пример: RLE("AAAABBBCDDDDDDD")=A3 B2 C0 D6. Более того, этот метод можно несколько расширить. Мы можем применить алгоритм RLE не только к группам нулевых длин, но и ко всем остальным. Такой способ передачи кодового дерева является общепринятым и применяется в большинстве современных реализаций. 13 4 Реализация алгоритма на языке Haskell Входными данными является строка текста, её пользователь вводит в консоль вручную или считывает из файла. После получения входных данных формируется отсортированный по возрастанию веса список состоящий из кортежей, которые имеет структуру вида: WeightCharacterTuple {weight :: Int, character :: Char}, где weight – это вес символа (количество его повторений в тексте), character – сам символ. Этот список формирует функция toFrequencyCharacterTuple. Пример промежуточных данных: [WeightCharacterTuple {weight = 1, character = 'T'},WeightCharacterTuple {weight = 1, character = 'b'},WeightCharacterTuple {weight = 1, character = 'f'}…]. Следующим этапом является формирование списка, состоящего из листьев будущего деверева, который получается обертыванием элементов оператором Leaf. Далее функция haffman выполняет построение дерева исходя из рассмотренного выше алгоритма. Каждый узел дерева представляется в виде элемента списка с суммарным весом листьев и служебным символом '*'. Пример промежуточных данных: [Node (WeightCharacterTuple {weight = 7, character = '*'}) (Leaf (WeightCharacterTuple {weight = 3, character = 's'})) (Node (WeightCharacterTuple {weight = 4, character = '*'}) (Node (WeightCharacterTuple {weight = 2, character = '*'}) (Leaf (WeightCharacterTuple {weight = 1, character = 'd'})) (Leaf (WeightCharacterTuple {weight = 1, character = 'q'}))) (Leaf (WeightCharacterTuple {weight = 2, character = '3'})))] Данное дерево было построено по входной строке 'ds3ss3q', имеет 3 узла, листья, имеющие больший вес находятся ближе к вершине дерева. Для вывода соответствующих кодов реализована процедура encode. Она на вход получает дерево, и производит его обход. 14 5 Примеры работы программы Пример 1 Входные данные: Haskell is an advanced purely-functional programming language. An open-source product of more than twenty years of cutting-edge research, it allows rapid development of robust, concise, correct software. With strong support for integration with other languages, built-in concurrency and parallelism, debuggers, profilers, rich libraries and an active community, Haskell makes it easier to produce flexible, maintainable, high-quality software. Выходные данные представлены на рисунке 5.1. Рисунок 5.1 - Результат работы программы 15 Пример 2 Входные данные: Following the release of Miranda by Research Software Ltd, in 1985, interest in lazy functional languages grew: by 1987, more than a dozen non-strict, purely functional programming languages existed. Выходные данные представлены на рисунке 5.2. Рисунок 5.2 - Результат работы программы Пример 3 16 Входные данные: The language has an open, published specification, and multiple implementations exist. Выходные данные представлены на рисунке 5.3. Рисунок 5.3 - Результат работы программы 17 Заключение В ходе данной работы был изучен и реализован алгоритм кодирования методом Хаффмана. Рассмотрены основные понятия: дерево Хаффмана, степень сжатия, взвешенная длинная пути, алгоритм RLE и другие. Продумана структура входных и выходных данных для реализации приведенного алгоритма на языке Haskell, после чего этот алгоритм был реализован на данном языке. В работе приведены примеры входных строк, хода и результатов работы программ для данных примеров. Опыт составления подобных программ может пригодиться для решения множества задач в дальнейшей профессиональной деятельности, в частности архивирование информации. 18 Список используемой литературы 1. Лидовский, В.И. Теория информации. [Текст] / В.И. Лидовский - М., «Высшая школа», 2002г. – 120с. 2. Кадач, А.В. Диссертация. Эффективные алгоритмы сжатия текстовой информации. [Текст] / А.В. Кадач – Новосибирск, 1997г. 3. Левитин, А. В. Глава 9. Жадные методы: Алгоритм Хаффмана // Алгоритмы: введение в разработку и анализ [Текст] / А. В. Левитин — М.: Вильямс, 2006. — С. 392—398. 4. Сэломон, Д. Сжатие данных, изображения и звука [Текст] / Д. Сэломон — М.: Техносфера, 2004. — 368 с. — 3000 экз. 5. Душкин, Р. В. Функциональное программирование на языке Haskell [Текст] / Гл. ред. Д. А. Мовчан; — М.: ДМК Пресс,, 2008. — 544 с., ил. с. — 1500 экз. — ISBN 5-94074-335-8 6. Язык и библиотека Haskell 98 [Текст]: [пер. с англ.] перевод выполнен М.Ю. Ландиной, В.А. Рогановым – М.: Наука, 2005. – 315с. – 5000экз. – ISBN 5 – 09 – 107693 – 9 7. Олькина, Е.В. Методические указания по оформлению пояснительных записок к дипломным, курсовым проектам (работам) и отчетов по практике в соответствии с требованиями государственных стандартов [Текст]: Е.В. Олькина. – Орел ГТУ, 2007. – 54с. – 50экз. 19 Приложение А (обязательное) Листинг программы на языке Haskell #структура для хранения веса символа data WeightCharacterTuple = WeightCharacterTuple { weight :: Int, character :: Char }deriving (Show) # переопределение методов для класса WeightCharacterTuple (Eq и Ord производные этого класса) instance Eq WeightCharacterTuple where a == b = (weight a) == (weight b) instance Ord WeightCharacterTuple where a > b = (weight a) > (weight b) a >= b = (weight a) >= (weight b) a < b = (weight a) < (weight b) a <= b = (weight a) <= (weight b) # структура для хранения дерева (имеет рекурсивную природу) data Tree a = Node a (Tree a) (Tree a) | Leaf a 20 deriving (Show) # переопределение метода для класса Tree instance Eq a => Eq (Tree a) where (==) a b = (node a) == (node b) # выбор типа на основе конструктора, получение из узла корня, а из листа дерева сам элемент node x = case x of (Node n _ _) -> n (Leaf n) -> n # переопределение методов для класса Tree instance Ord a => Ord (Tree a) where left >= right = (node left) >= (node right) left < right = (node left) < (node right) left > right = (node left) > (node right) left <= right = (node left) <= (node right) # Функция получения отсортированого списка с количеством повторения каждого символа toFrequencyCharacterTuple :: String -> [WeightCharacterTuple] toFrequencyCharacterTuple string = quickSort $ zipWith WeightCharacterTuple counts uniqueLetters 21 where # подсчет повторений каждого символа counts = map (frequency string) uniqueLetters # подсчет количества повторений символа frequency :: String -> Char -> Int frequency (x:xs) c | c == x = 1 + frequency xs c | otherwise = frequency xs c frequency _ c = 0 uniqueLetters = unique string # получение последовательности, в которой содержаться только уникальные символы unique :: String -> String unique (x:xs) = [x] ++ unique [y | y <- xs, y /= x ] unique [] = [] # реализация алгортма Хаффмана huffman :: [Tree WeightCharacterTuple] -> [Tree WeightCharacterTuple] # Список делится на две части - 2 элемента с минимальными значениями веса, которые необходимо объеденить и остальные элементы huffman (min1:min2:rest) = huffman newList where 22 newList #Если длина оставшегося списка не равно нулю то произвести сортировку, и слияние двух крайних элементов | length rest /= 0 = quickSort ((merge min1 min2):rest) # в противном случае поизводим только слияние | otherwise = [merge min1 min2] where merge a b # функция слияния элементов списка | a <= b = Node (WeightCharacterTuple newWeight '*') a b | otherwise = Node (WeightCharacterTuple newWeight '*') b a # получение общего веса where newWeight = (weight (node a)) + (weight (node b)) huffman x = x x `endsWith` y = (take (length y) (reverse x)) == (reverse y) # кодирование дерева Хаффмана encode::WeightCharacterTuple -> Tree WeightCharacterTuple -> String # если элемент списка - узел то рекурсивно выполнить encode учитывая, что правая ветвь кодируется 1, а левая 0 encode w (Node n left right) = oneOf ('0':(encode w left)) ('1':(encode w right)) where oneOf x y | y `endsWith` "WRONG-LEAF" =x 23 | otherwise =y encode w (Leaf l) | (character l) == (character w) = "" | otherwise = "WRONG-LEAF" # сортировка элементов по алгоритму быстрой сортировки quickSort (x:xs) = l1 ++ [x] ++ l2 where l1 = quickSort [y | y <- xs, y < x] # сортирует элементы меньше x l2 = quickSort [y | y <- xs, y >= x] # сортирует элементы больше x quickSort [] = [] # вывод на экран результатов displayAllEncodings :: [WeightCharacterTuple] -> Tree WeightCharacterTuple -> String displayAllEncodings (x:xs) tree= (codeForX x) ++ "\n" ++ (displayAllEncodings xs tree) where codeForX (WeightCharacterTuple w c) = (show c) ++ " weight = " ++ (show w) ++ " code = " ++ (encode x tree) displayAllEncodings _ _ = [] # Начало основой части программы main=do # получаем текст 24 x <- getLine # x <- readFile "C:1.txt" - получение текста из файла # Получаем список с количеством повторения каждого символа (входными данными является строка текста) let tupleList=toFrequencyCharacterTuple x # Обертывание элемтов списка tupleList структурой листа дерева let inputTreeList=map Leaf tupleList # Построение дерева Хаффмана let tree=huffman inputTreeList # Вывод дерева putStrLn (show tree) # Вывод кода Хаффмана putStrLn (displayAllEncodings tupleList (tree!!0)) return ()