Код Хаффмана. Симаков Александр, СыктГУ, Кафедра Прикладной Математики. Коды Хаффмана: примеры, применение

Сегодня мало кого из пользователей интересует вопрос, связанный с механизмом сжатия файлов. Процесс работы с персональным компьютером по сравнению с прошлым стал осуществляться намного проще.


Сегодня практически любой пользователь, который работает с файловой системой, пользуется архивами. Однако, мало кто из пользователей задумывался, как осуществляется сжатие файлов.

Коды Хаффмана стали первым вариантом. Они по-прежнему используются в различных архиваторах. Большинство пользователей даже не задумывается о том, как просто осуществляется сжатие файла по такой схеме. В данном обзоре мы рассмотрим, как осуществляется сжатие, какие особенности помогают ускорить и упростить процесс кодирования. Также мы попробуем разобраться с основными принципами построения дерева кодирования.

Алгоритм: история

Первым алгоритмом, предназначенным для проведения эффективного кодирования электронной информации, стал код, предложенный Хаффманом в 1952 году. Именно этот код на сегодняшний день можно считать базовым элементом большинства программ, разработанных для сжатия информации. Одними из наиболее популярных источников, которые используют данный код, на сегодняшний день являются архивы RAR, ARJ, ZIP. Данный алгоритм также используется для сжатия изображений JPEG и графических объектов. Также во всех современных факсах используется алгоритм кодирования, который был изобретен еще в 1952 году. Несмотря на то, что со времени создания данного кода прошло достаточно много времени, его эффективно используют в оборудовании старого типа, а также в новом оборудовании и оболочках.

Принцип эффективного кодирования

В основе алгоритма Хаффмана используется схема, которая позволяет заменить самые вероятные и наиболее часто встречающиеся символы кодами двоичной системы. Те символы, которые встречаются реже, заменяют длинными кодами. Переход к длинным кодам Хаффмана осуществляется только после того, как система использует все минимальные значения. Данная методика дает возможность минимизировать длину кода на символ исходного сообщения. В данном случае особенность заключается в том, что вероятности появления букв в начале кодирования должны быть уже известны. Конечное сообщение будет составляться именно из них. Исходя из этой информации, осуществляется построение кодового дерева Хаффмана. На основе него и будет осуществляться процесс кодирования букв в архиве.

Код Хаффмана: пример

Для того чтобы проиллюстрировать алгоритм Хаффмана, рассмотрим графический вариант построения кодового дерева. Чтобы использование данного способа было более эффективным, необходимо уточнить определение некоторых значений, которые необходимы для понятия данного способа. Всю совокупность множества узлов и дуг, которые направлены от узла к узлу, называют графом. Дерево само по себе является графом с набором определенных свойств. В каждый узел должно входить не больше одной из всех дуг. Один из узлов должен являться корнем дерева. Это значит, что в него не должны вообще входить дуги. Если начать от корня перемещения по дугам, то данный процесс должен позволять попасть в любой узел.

В коды Хаффмана также входит такое понятие, как лист дерева. Он представляет собой узел, из которого не должна выходить ни одна дуга. Если два узла между собой соединены дугой, то один из них является родителем, а другой ребенком. Если два узла имеют общий родительский узел, то их называют братскими узлами. Если кроме листьев у узлов выходит по несколько дуг, то такое дерево называют двоичным. Именно таким и является дерево Хаффмана. Особенностью узлов данного строения является то, что вес каждого родителя равняется сумме веса узловых детей.

Дерево Хаффмана: алгоритм построения

Построение кода Хаффмана выполняется из букв входного алфавита. Образуется список узлов, свободных в будущем кодовом дереве. В этом списке вес каждого узла должен быть таким же, что и вероятность возникновения буквы сообщения, которая соответствует данному узлу. Среди нескольких свободных узлов при этом выбирается тот, который меньше всего весит. Если при этом в нескольких узлах наблюдаются минимальные показатели, то можно свободно выбрать любую пару. После этого осуществляется создание родительского узла. Он должен весить столько же, сколько весит сумма данной пары узлов. Родителя после этого отправляют в список со свободными узлами. Детей удаляют. Дуги при этом получают соответствующие показатели, нули и единицы. Данный процесс повторяется столько раз, сколько требуется для того, чтобы остался только один узел. После этого по направлению сверху вниз выписываются двоичные цифры.

Как повысить эффективность сжатия

Для повышения эффективности сжатия, во время построения дерева кода необходимо использовать все данные, касающиеся вероятности появления букв в конкретном файле, который прикреплен к дереву. Нельзя допускать того, чтобы они были раскиданы по большому числу текстовых документов. Если пройтись предварительно по данному файлу, то можно получить статистику того, как часто встречаются буквы из объекта, который подлежит сжиманию.

Как ускорить процесс сжатия

Для ускорения работы алгоритма, определение букв нужно осуществлять не по показателям появления тех или иных букв, а по частоте их встречаемости. Алгоритм благодаря этому становится проще, а работа с ним значительно ускоряется. Это также дает возможность избежать операций, связанных с делением и плавающими запятыми. Также при работе в таком режиме, алгоритм не подлежит изменению. В основном это связано с тем, что вероятности прямо пропорциональны частотам. Стоит также обратить внимание на тот факт, что конечный вес корневого узла будет равняться сумме количества букв в объекте, который подлежит обработке.

Вывод

Коды Хаффмана представляют собой простой и давно разработанный алгоритм, который по сей день используется во многих популярных программах. Простота и понятность данного кода позволяет добиться эффективного сжатия файлов любых объемов.

Относительно простой метод сжатия данных может быть выполнен путём создания так называемых деревьев Хаффмана для файла и используется для его сжатия и распаковки данных в нём. Для большинства приложений используются бинарные деревья Хаффмана (например, каждый узел является либо листом, либо имеет ровно два подузла). Можно, однако, построить деревья Хаффмана с произвольным числом поддеревьев (например, троичных или, в общем случае, N -ичных деревьев).

Дерево Хаффмана для файла, содержащего Z разных символов имеет Z листьев. Путь от корня к листу, который представляет определенный символ, определяет кодировку, и каждый шаг на пути к листу определяет кодировку (которая может быть 0 , 1 , ..., (N-1) ). Путём размещения часто встречающихся символы ближе к корню, и менее часто встречающихся символов дальше от корня, и достигается желаемое сжатие. Строго говоря, такое дерево будет деревом Хаффмана, только если в результате кодирования будет использовано минимальное число N -ичных символов для кодирования заданного файла.

В этой задаче мы будем рассматривать только деревья, где каждый узел является либо внутренним узлом либо листом кодирования символов и нет изолированных листьев, которые не кодируют символ.

На рисунке ниже показан пример троичного дерева Хаффмана, символы "a " и "е " кодируются с помощью одной троичного символа; менее часто встречающиеся символы "s " и "p " кодируются с помощью двух троичных символов и наиболее редко встречающиеся символы "x ", "q " и "y " кодируются с помощью трех троичных символов каждый.

Конечно, если мы хотим, чтобы можно развернуть список N -ичных символов потом обратно, важно знать, какое дерево используется для сжатия данных. Это можно сделать несколькими способами. В этой задаче мы будем использовать следующий метод: потоку входных данных будет предшествовать заголовок, состоящий из закодированных значений символов Z , находящихся в исходном файле в лексикографическом порядке.

Зная количество входных символов Z , значение N , обозначающее "N -арность" дерева Хаффмана и сам заголовок, необходимо найти первичное значение закодированных символов.

Входные данные

Входные данные начинаются с целого числа T , расположенного в отдельной строке и обозначающего количество последующих тестовых случаев. Далее задано каждый из T тестовых случаев, каждый из которых расположен в 3 -х строках следующим образом:

  • Количество различных символов в тестовом случае Z (2 Z 20 );
  • Число N , обозначающее " N -арность" дерева Хаффмана (2 N 10 );
  • Строка, представляющая заголовок полученного сообщения, каждый символ будет цифрой в диапазоне . Эта строка будет содержать меньше 200 символов.

Примечание : Хотя и редко, но это возможно для заголовка, чтобы иметь несколько толкований при расшифровке (например, для заголовка "010011101100 ", и значениях Z = 5 и N = 2 ). Гарантируется, что во всех предлагаемых во входных данных тестовых случаях, имеется единственное решение.

Выходные данные

Для каждого из T тестовых случаев вывести Z строк, дающих декодированную версию каждого из Z символов в порядке возрастания. Используйте формат оригинал->кодировка , где оригинал - это десятичное число в диапазоне и соответствующая кодированная строка кодированных цифр для этих символов (каждая цифра ≥ 0 и < N ).

Книга "Фундаментальные алгоритмы и структуры данных в Delphi" представляет собой уникальное учебное и справочное пособие по наиболее распространенным алгоритмам манипулирования данными, которые зарекомендовали себя как надежные и проверенные многими поколениями программистов. По данным журнала "Delphi Informant" за 2002 год, эта книга была признана сообществом разработчиков прикладных приложений на Delphi как «самая лучшая книга по практическому применению всех версий Delphi».

В книге подробно рассматриваются базовые понятия алгоритмов и основополагающие структуры данных, алгоритмы сортировки, поиска, хеширования, синтаксического разбора, сжатия данных, а также многие другие темы, тесно связанные с прикладным программированием. Изобилие тщательно проверенных примеров кода существенно ускоряет не только освоение фундаментальных алгоритмов, но также и способствует более квалифицированному подходу к повседневному программированию.

Несмотря на то что книга рассчитана в первую очередь на профессиональных разработчиков приложений на Delphi, она окажет несомненную пользу и начинающим программистам, демонстрируя им приемы и трюки, которые столь популярны у истинных «профи». Все коды примеров, упомянутые в книге, доступны для выгрузки на Web-сайте издательства.

Книга:

Алгоритм кодирования Хаффмана очень похож на алгоритм сжатия Шеннона-Фано. Этот алгоритм был изобретен Девидом Хаффманом (David Huffman) в 1952 году ("A method for the Construction of Minimum-Redundancy Codes" ("Метод создания кодов с минимальной избыточностью")), и оказался еще более удачным, чем алгоритм Шеннона-Фано. Это обусловлено тем, что алгоритм Хаффмана математически гарантированно создает наименьший по размеру код для каждого из символов исходных данных.

Аналогично применению алгоритма Шеннона-Фано, нужно построить бинарное дерево, которое также будет префиксным деревом, где все данные хранятся в листьях. Но в отличие от алгоритма Шеннона-Фано, который является нисходящим, на этот раз построение будет выполняться снизу вверх. Вначале мы выполняем просмотр входных данных, подсчитывая количество появлений значений каждого байта, как это делалось и при использовании алгоритма Шеннона-Фано. Как только эта таблица частоты появления символов будет создана, можно приступить к построению дерева.

Будем считать эти пары символ-количество "пулом" узлов будущего дерева Хаффмана. Удалим из этого пула два узла с наименьшими значениями количества появлений. Присоединим их к новому родительскому узлу и установим значение счетчика родительского узла равным сумме счетчиков его двух дочерних узлов. Поместим родительский узел обратно в пул. Продолжим этот процесс удаления двух узлов и добавления вместо них одного родительского узла до тех пор, пока в пуле не останется только один узел. На этом этапе можно удалить из пула один узел. Он является корневым узлом дерева Хаффмана.

Описанный процесс не очень нагляден, поэтому создадим дерево Хаффмана для предложения "How much wood could a woodchuck chuck?" Мы уже вычислили количество появлений символов этого предложения и представили их в виде таблицы 11.1, поэтому теперь к ней потребуется применить описанный алгоритм с целью построения полного дерева Хаффмана. Выберем два узла с наименьшими значениями. Существует несколько узлов, из которых можно выбрать, но мы выберем узлы "m" и Для обоих этих узлов число появлений символов равно 1. Создадим родительский узел, значение счетчика которого равно 2, и присоединим к нему два выбранных узла в качестве дочерних. Поместим родительский узел обратно в пул. Повторим цикл с самого начала. На этот раз мы выбираем узлы "а" и "Д.", объединяем их в мини-дерево и помещаем родительский узел (значение счетчика которого снова равно 2) обратно в пул. Снова повторим цикл. На этот раз в нашем распоряжении имеется единственный узел, значение счетчика которого равно 1 (узел "Н") и три узла со значениями счетчиков, равными 2 (узел "к" и два родительских узла, которые были добавлены перед этим). Выберем узел "к", присоединим его к узлу "H" и снова добавим в пул родительский узел, значение счетчика которого равно 3. Затем выберем два родительских узла со значениями счетчиков, равными 2, присоединим их к новому родительскому узлу со значением счетчика, равным 4, и добавим этот родительский узел в пул. Несколько первых шагов построения дерева Хаффмана и результирующее дерево показаны на рис. 11.2.


Рисунок 11.2. Построение дерева Хоффмана

Используя это дерево точно так же, как и дерево, созданное для кодирования Шеннона-Фано, можно вычислить код для каждого из символов в исходном предложении и построить таблицу 11.5.

Таблица 11.5. Коды Хаффмана для символов примера предложения

Символ - Количество появлений

Пробел - 00

Обратите внимание, что эта таблица кодов - не единственная возможная. Каждый раз, когда имеется три или больше узлов, из числа которых нужно выбрать два, существуют альтернативные варианты результирующего дерева и, следовательно, результирующих кодов. Но на практике все эти возможные варианты деревьев и кодов будут обеспечивать максимальное сжатие. Все они эквивалентны.

Теперь можно вычислить код для всего предложения. Он начинается с битов:

1111110111100001110010100...

и содержит всего 131 бит. Если бы исходное предложение было закодировано кодами ASCII, по одному байту на символ, оно содержало бы 286 битов. Таким образом, в данном случае коэффициент сжатия составляет приблизительно 54%.

Повторим снова, что, как и при применении алгоритма Шеннона-Фано, необходимо каким-то образом сжать дерево и включить его в состав сжатых данных.

Восстановление выполняется совершенно так же, как при использовании кодирования Шеннона-Фано: необходимо восстановить дерево из данных, хранящихся в сжатом потоке, и затем воспользоваться им для считывания сжатого потока битов.

Рассмотрим кодирование Хаффмана с высокоуровневой точки зрения. В ходе реализации каждого из методов сжатия, которые будут описаны в этой главе, мы создадим простую подпрограмму, которая принимает как входной, так и выходной поток, и сжимает все данные входного потока и помещает их в выходной поток.

Эта высокоуровневая подпрограмма TDHuffroanCompress, выполняющая кодирование Хаффмана, приведена в листинге 11.5.

Листинг 11.5. Высокоуровневая подпрограмма кодирования Хаффмана

procedure TDHuffmanCompress(aInStream, aOutStream: TStream);

HTree: THuffmanTree;

HCodes: PHuffmanCodes;

BitStrm: TtdOutputBitStream;

Signature: longint;

{вывести информацию заголовка (сигнатуру и размер несжатых данных)}

Signature:= TDHuffHeader;

aOutStream.WriteBuffer(Signature, sizeof(longint));

Size:= aInStream.Size;

aOutStream.WriteBuffer(Size, sizeof(longint));

{при отсутствии данных для сжатия необходимо выйти из подпрограммы}

if (Size = 0) then

{подготовка}

{создать сжатый поток битов}

BitStrm:= TtdOutputBitStream.Create(aOutStream);

{распределить память под дерево Хаффмана}

HTree:= THuffmanTree.Create;

{определить распределение символов во входном потоке и выполнить восходящее построение дерева Хаффмана}

HTree.CalcCharDistribution(aInStream);

{вывести дерево в поток битов для облегчения задачи программы восстановления данных}

HTree.SaveToBitStream (BitStrm);

{если корневой узел дерева Хаффмана является листом, входной поток состоит лишь из единственного повторяющегося символа, и следовательно, задача выполнена. В противном случае необходимо выполнить сжатие входного потока}

if not HTree.RootIsLeaf then begin

{распределить память под массив кодов}

{вычислить все коды}

HTree.CalcCodes(HCodes^);

{сжать символы входного потока в поток битов}

DoHuffmanCompression(aInStream, BitStrm, HCodes^);

if (HCodes <> nil) then

Dispose(HCodes);

Код содержит множество элементов, которые мы еще не рассматривали. Но мы вполне можем вначале рассмотреть работу программы в целом, а затем приступить к рассмотрению каждого отдельного этапа. Прежде всего, мы записываем в выходной поток небольшой заголовок, за которым следует значение длины входного потока. Впоследствии эта информация упростит задачу восстановления данных, гарантируя, что сжатый поток соответствует созданному нами. Затем мы создаем объект потока битов, содержащий выходной поток. Следующий шаг -создание экземпляра класса THuffmanTree. Этот класс, как вскоре будет показано, будет использоваться для создания дерева Хаффмана и содержит различные методы, помогающие в решении этой задачи. Один из методов этого нового объекта, вызываемых в первую очередь, метод CalcCharDistribution, определяет статистическую информацию распределения символов во входном потоке, а затем строит префиксное дерево Хаффмана.

После того, как дерево Хаффмана построено, можно вызвать метод SaveToBitStream, чтобы записать структуру дерева в выходной поток.

Затем мы выполняем обработку особого случая и небольшую оптимизацию. Если входной поток состоит всего лишь из нескольких повторений одного и того же символа, корневой узел дерева Хаффмана будет листом. Все префиксное дерево состоит всего из одного узла. В этом случае выходной поток битов будет содержать уже достаточно информации, чтобы программа восстановления могла восстановить исходный файл (мы уже записали в поток битов размер входного потока и единственный бит).

В противном случае входной поток должен содержать, по меньшей мере, два различных символа, и дерево Хаффмана имеет вид обычного дерева, а не единственного узла. В этом случае мы выполняем оптимизацию: вычисляем таблицу кодов для каждого символа, встречающегося во входном потоке. Это позволит сэкономить время на следующем этапе, когда будет выполняться реальное сжатие, поскольку нам не придется постоянно перемещаться по дереву для выполнения кодирования каждого символа. Массив HCodes - простой 256-элементный массив, содержащий коды всех символов и построенный посредством вызова метода CalcCodes объекта дерева Хаффмана.

И, наконец, когда все эти структуры данных определены, мы вызываем подпрограмму DoHuffmanCompression, выполняющую реальное сжатие данных. Код этой подпрограммы приведен в листинге 11.6.

Листинг 11.6. Цикл сжатия Хаффмана

procedure DoHuffmanCompression(aInStream: TStream;

aBitStream: TtdOutputBitStream;

var aCodes: THuffmanCodes);

Buffer: PByteArray;

BytesRead: longint;

{сбросить входной поток в начальное состояние}

while (BytesRead <> 0) do

{записать строку битов для каждого символа блока}

for i:= 0 to pred(BytesRead) do aBitStream.WriteBits(aCodes]);

BytesRead:= aInStream.Read(Buffer^, HuffmanBufferSize);

Подпрограмма DoHuffmanCompression распределяет большой буфер для хранения считываемых из входного потока блоков данных, и будет постоянно считывать блоки из входного потока, сжимая их, до тех пор, пока поток не будет исчерпан. Такая буферизация данных служит простым методом оптимизации с целью повышения эффективности всего процесса. Для каждого символа блока подпрограмма записывает соответствующий код, полученный из массива aCodes, в выходной поток битов.

После того, как мы ознакомились с выполнением сжатия Хаффмана на высоком уровне, следует рассмотреть класс, выполняющий большую часть вычислений. Это внутренний класс THuffmanTree. Объявление связных с ним типов показано в листинге 11.7.

Вначале мы объявляем узел дерева Хаффмана THaffxnanNode и массив этих узлов THaffmanNodeArray фиксированного размера. Этот массив будет использоваться для создания реальной структуры дерева и будет содержать ровно 511 элементов. Почему именно это количество?

Это число определяется небольшой теоремой (или леммой) о свойствах бинарного дерева, которая еще не упоминалась.

Листинг 11.7. Класс дерева Хаффмана

PHuffmanNode = ^THuffmanNode;

THuffmanNode = packed record

hnCount: longint;

hnLeftInx: longint;

hnRightInx: longint;

hnIndex: longint;

PHuffmanNodeArray = ^THuffmanNodeArray;

THuffmanNodeAr ray = array of THuffmanNode;

THuffmanCodeStr = string;

PHuffmanCodes = ^THuffmanCodes;

THuffmanCodes = array of TtdBitString;

THuffmanTree = class private

FTree: THuffmanNodeArray;

procedure htBuild;

procedure htCalcCodesPrim(aNodeInx: integer;

var aCodeStr: THuffmanCodeStr;

var aCodes: THuffmanCodes);

function htLoadNode(aBitStream: TtdInputBitStream): integer;

procedure htSaveNode(aBitStream: TtdOutputBitStream;

aNode: integer);

constructor Create;

procedure CalcCharDistribution(aStream: TStream);

procedure CalcCodes(var aCodes: THuffmanCodes);

function DecodeNextByte(aBit St ream: TtdInputBitStream): byte;

procedure LoadFromBitStream(aBitStream: TtdInputBitStream);

function RootIsLeaf: boolean;

procedure SaveToBitStream(aBitStream: TtdOutputBitStream);

property Root: integer read FRoot;

Предположим, что дерево содержит только два типа узлов: внутренние, имеющие ровно по два дочерних узла, и листья, не имеющие узлов (иначе говоря, не существует узлов, имеющих только один дочерний узел, - именно такой вид имеет префиксное дерево). Сколько внутренних узлов имеет это дерево, если оно содержит n листьев? Лемма утверждает, что такое дерево содержит ровно n - 1 внутренних узлов. Это утверждение можно доказать методом индукции. Когда n = 1, лемма явно выполняется, поскольку дерево содержит только корневой узел.

Теперь предположим, что лемма справедлива для всех i < n, где n < 1, и рассмотрим случай, когда i = n. В этом случае дерево должно содержать, по меньшей мере, один внутренний узел - корневой. Этот корневой узел имеет два дочерних дерева: левое и правое. Если левое дочернее дерево имеет x листьев, то, согласно сделанному нами допущению, оно должно содержать x - 1 внутренних узлов, поскольку x < n. Аналогично, согласно сделанному допущению, если правое дочернее дерево имеет y листьев, оно должно содержать y - 1 внутренних узлов. Все дерево содержит n листьев, причем это число должно быть равно X + Y (вспомните, что корневой узел является внутренним). Следовательно, количество внутренних узлов равно (x-1) + (y-1) + 1, что составляет в точности n-1.

Чем же эта лемма может нам помочь? В префиксном дереве все символы должны храниться в листьях. В противном случае было бы невозможно получить однозначные коды. Следовательно, независимо от его внешнего вида, префиксное дерево, подобное дереву Хаффмана, будет содержать не более 511 узлов: не более 256 листьев и не более 255 внутренних узлов. Следовательно, мы должны быть в состоянии реализовать дерево Хаффмана (по крайней мере, обеспечивающее кодирование значений байтов) в виде 511-элементного массива.

Структура узла включает в себя поле счетчика (содержащее значение общего количества появлений символов для самого узла и всех его дочерних узлов), индексы левого и правого дочерних узлов и, наконец, поле, содержащее индекс самого этого узла (эта информация облегчит построение дерева Хаффмана).

Причина выбора типов кода Хаффмана (THuffmanCodeStr и THuffmanCodes) станет понятной после рассмотрения генерации кодов для каждого из символов.

Конструктор Create класса дерева Хаффмана всего лишь выполняет инициализацию внутреннего массива дерева.

Листинг 11.8. Конструирование объекта дерева Хаффмана

constructor THuffmanTree.Create;

inherited Create;

FillChar(FTree, sizeof(FTree), 0);

for i:= 0 to 510 do

FTree[i].hnIndex:= i;

Поскольку конструктор не распределяет никакой памяти, и никакое распределение памяти не выполняется ни в каком другом объекте класса, явному деструктору нечего делать. Поэтому по умолчанию класс использует метод TObject.Destroy.

Первым методом, вызываемым для дерева Хаффмана в подпрограмме сжатия, был метод CalcCharDistribution. Это метод считывает входной поток, вычисляет количество появлений каждого символа, а затем строит дерево.

Листинг 11.9. Вычисление количеств появлений символов

procedure THuffmanTree.CalcCharDistribution(aStream: TStream);

Buffer: PByteArray;

BytesRead: integer;

{считывать все байты с поддержанием счетчиков появлений для каждого значения байта, начиная с начала потока}

aStream.Position:= 0;

GetMem(Buffer, HuffmanBufferSize);

while (BytesRead <> 0) do

for i:= pred(BytesRead) downto 0 do

inc(FTree].hnCount);

BytesRead:= aStream.Read(Buffer^, HuffmanBufferSize);

FreeMem(Buffer, HuffmanBufferSize);

{построить дерево}

Как видно из листинга 11.9, большая часть кода метода вычисляет количества появлений символов и сохраняет эти значения в первых 256 узлах массива. Для повышения эффективности метод обеспечивает поблочное считывание входного потока (прежде чем выполнить цикл вычисления, он распределяет в куче большой блок памяти, а после вычисления освобождает его). И в завершение, в конце подпрограммы вызывается внутренний метод htBuild, выполняющий построение дерева.

Прежде чем изучить реализацию этого важного внутреннего метода, рассмотрим возможную реализацию алгоритма построения дерева. Вспомним, что мы начинаем с создания "пула" узлов, по одному для каждого символа. Мы выбираем два наименьших узла (т.е. два узла с наименьшими значениями счетчиков) и присоединяем их к новому родительскому узлу (устанавливая значение его счетчика равным сумме значений счетчиков его дочерних узлов), а затем помещаем родительский узел обратно в пул. Мы продолжаем этот процесс до тех пор, пока в пуле не останется только один узел. Если вспомнить описанное в главе 9, станет очевидным, какую структуру можно использовать для реализации этого аморфного "пула": очередь по приоритету. Строго говоря, мы должны использовать сортирующее дерево с выбором наименьшего элемента (обычно очередь по приоритету реализуется так, чтобы возвращать наибольший элемент).

Листинг 11.10. Построение дерева Хаффмана

function CompareHuffmanNodes(aData1, aData2: pointer): integer; far;

Node1: PHuffmanNode absolute aData1;

Node2: PHuffmanNode absolute aData2;

{ПРИМЕЧАНИЕ: эта подпрограмма сравнения предназначена для реализации очереди по приоритету Хаффмана, которая является *сортирующим деревом с выбором наименьшего элемента*. Поэтому она должна возвращать элементы в порядке, противоположном ожидаемому}

if (Node1^.hnCount) > (Node2^.hnCount) then

if (Node1^.hnCount) = (Node2^.hnCount)

else Result:= 1;

procedure THuffmanTree.htBuild;

PQ: TtdPriorityQueue;

Node1: PHuffmanNode;

Node2: PHuffmanNode;

RootNode: PHuffmanNode;

{создать очередь по приоритету}

PQ:= TtdPriorityQueue.Create(CompareHuffmanNodes, nil);

PQ.Name:= "Huffman tree minheap";

{добавить в очередь все ненулевые узлы}

for i:= 0 to 255 do

if (FTree[i].hnCount <> 0) then

PQ.Enqueue(@FTree[i]);

{ОСОБЫЙ СЛУЧАЙ: существует только один ненулевой узел, т.е. входной поток состоит только из одного символа, повторяющегося один или более раз. В этом случае значение корневого узла устанавливается равным значению индекса узла единственного символа}

if (PQ.Count = 1) then begin

RootNode:= PQ.Dequeue;

FRoot:= RootNode^.hnIndex;

{в противном случае имеет место обычный случай наличия множества различных символов}

{до тех пор, пока в очереди присутствует более одного элемента, необходимо выполнять удаление двух наименьших элементов, присоединять их к новому родительскому узлу и добавлять его в очередь}

while (PQ.Count > 1) do

Node1:= PQ.Dequeue;

Node2:= PQ.Dequeue;

RootNode:= @FTree;

with RootNode^ do

hnLeftInx:= Node1^.hnIndex;

hnRightInx Node2^.hnIndex;

hnCount:= Node1^.hnCount + Node2^.hnCount;

PQ.Enqueue(RootNode);

Мы начинаем с создания экземпляра класса TtdPriorityQueue. Мы передаем ему подпрограмму CompareHuffmanNodes. Вспомним, что в созданной в главе 9 очереди по приоритету подпрограмма сравнения использовалась для возврата элементов в порядке убывания. Для создания сортирующего дерева с выбором наименьшего элемента, необходимой для создания дерева Хаффмана, мы изменяем цель подпрограммы сравнения, чтобы она возвращала положительное значение, если первый элемент меньше второго, и отрицательное, если он больше.

Как только очередь по приоритету создана, мы помещаем в нее все узлы с ненулевыми значениями счетчиков. В случае существования только одного такого узла, значение поля корневого узла дерева Хаффмана устанавливается равным индексу этого единственного узла. В противном случае мы применяем алгоритм Хаффмана, причем обращение к первому родительскому узлу осуществляется по индексу, равному 256. Удаляя из очереди два узла и помещая в нее новый родительский узел, мы поддерживаем значение переменной FRoot, чтобы она указывала на последний родительский узел. В результате по окончании процесса нам известен индекс элемента, представляющего корневой узел дерева.

И, наконец, мы освобождаем объект очереди по приоритету. Теперь дерево Хаффмана полностью построено.

Следующий метод, вызываемый в высокоуровневой подпрограмме сжатия - метод, который выполняет запись дерева Хаффмана в выходной поток битов. По существу, нам необходимо применить какой-либо алгоритм, выполняющий запись достаточного объема информации, чтобы можно было восстановить дерево. Одна из возможностей предусматривает запись символов и их значений счетчика появлений. При наличии этой информации программа восстановления может без труда восстановить дерево Хаффмана, просто вызывая метод htBuild. Это кажется здравой идеей, если не учитывать объем, занимаемый таблицей символов и количеств их появлений в сжатом выходном потоке. В этом случае каждый символ занимал бы в выходном потоке полный байт, а его значение счетчика занимало бы определенное фиксированное количество байтов (например, два байта на символ, чтобы можно было подсчитывать вплоть до 65535 появлений). При наличии во входном потоке 100 отдельных символов вся таблица занимала бы 300 байт. Если бы во входном потоке присутствовали все возможные символы, таблица занимала бы 768 байт.

Другой возможный способ - хранение значений счетчика для каждого символа. В этом случае для всех символов, в том числе для отсутствующих во входном потоке, требуется два фиксированных байта. В результате общий размер таблицы во всех ситуациях составил бы 512 байт. Честно говоря, этот результат не многим лучше предыдущего.

Конечно, если бы входной поток был достаточно большим, некоторые из значений счетчиков могли бы превысить размер 2-байтового слова, и для каждого символа пришлось бы использовать по три или даже четыре байта.

Более рациональный подход - игнорировать значения счетчиков символов и сохранять реальную структуру дерева. Префиксное дерево содержит два различных вида узлов: внутренние с двумя дочерними узлами и внешние, не имеющие дочерних узлов. Внешние узлы - это узлы, содержащие символы. Выполним обход дерева, применив один из обычных методов обхода (фактически, мы будем использовать метод обхода в ширину). Для каждого достигнутого узла будем записывать нулевой бит, если узел является внутренним, или единичный бит, если узел является внешним, за которым будет следовать представляемый узлом символ. Код реализации метода SaveToBitStream и вызываемого им рекурсивного метода htSaveNode, который выполняет реальный обход дерева и запись информации в поток битов, представлен в листинге 11.11.

Листинг 11.11. Запись дерева Хаффмана в поток битов

procedure THuffmanTree.htSaveNode(aBitStream: TtdOutputBitStream;

aNode: integer);

{если этот узел является внутренним, выполнить запись нулевого бита, затем левого дочернего дерева, а затем - правого дочернего дерева}

if (aNode >= 256) then begin

aBitStream.WriteBit(false);

htSaveNode(aBitStream, FTree.hnLeftInx);

htSaveNode(aBitStream, FTree.hnRightInx);

{в противном случае узел является листом и нужно записать единичный бит, а затем символ}

aBitStream.WriteBit(true);

aBitStream.WriteByte (aNode);

{aNode - символ}

procedure THuffmanTree.SaveToBitStream(aBitStream: TtdOutputBitStream);

htSaveNode(aBitStream, FRoot);

Если бы во входном потоке присутствовало 100 отдельных символов, он содержал бы 99 внутренних узлов, и требовалось бы всего 199 битов для хранения информации об узлах плюс 100 байтов для хранения самих символов - всего около 125 байтов. Если бы во входном потоке были представлены все символы, требовалось бы 511 битов для хранения информации об узлах плюс место для хранения 256 символов. Таким образом, всего для хранения дерева требовалось бы 320 байтов.

Полный код подпрограммы сжатия дерева Хаффмана можно найти на Web-сайте издательства, в разделе материалов. После выгрузки материалов отыщите среди них файл TDHuffmn.pas.

После того, как мы рассмотрели реализацию сжатия Хаффмана, приступим к вопросу решения задачи восстановления данных. Код подпрограммы TDHuffmanDeconpress, управляющей этим процессом, приведен в листинге 11.12.

Листинг 11.12. Подпрограмма TDHuffmanDecoropress

procedure TDHuffmanDecompress(aInStream, aOutStream: TStream);

Signature: longint;

HTree: THuffmanTree;

BitStrm: TtdInputBitStream;

{выполнить проверку на предмет того, что входной поток является потоком, правильно закодированным методом Хаффмана}

aInStream.Seek(0, soFromBeginning);

aInStream.ReadBuffer(Signature, sizeof(Signature));

if (Signature <> TDHuffHeader) then

raise EtdHuffmanException.Create(FmtLoadStr(tdeHuffBadEncodedStrm,));

aInStream.ReadBuffer(Size, sizeof(longint));

{если данные для восстановления отсутствуют, осуществить выход из подпрограммы}

if (Size = 0) then

{подготовиться к восстановлению}

{создать поток битов}

BitStrm:= TtdInputBitStream.Create(aInStream);

BitStrm.Name:= "Huffman compressed stream";

{создать дерево Хаффмана}

HTree.LoadFromBitStream(BitStrm);

{если корневой узел дерева Хаффмана является листом, исходный поток состоит только из повторений одного символа}

if HTree.RootIsLeaf then

WriteMultipleChars(aOutStream, AnsiChar(HTree.Root), Size) {в противном случае выполнить восстановление символов входного потока посредством использования дерева Хаффмана}

DoHuffmanDecompression(BitStrm, aOutStream, HTree, Size);

Прежде всего, мы проверяем, начинается ли поток с корректной сигнатуры. Если нет, не имеет смысла продолжать процесс, поскольку поток явно содержит ошибки.

Затем выполняется считывание длины несжатых данных, и если она равна нулю, задача выполнена. В противном случае необходимо проделать определенную работу. В этом случае мы создаем входной поток битов, содержащий входной поток. Затем мы создаем объект дерева Хаффмана, который будет выполнять большую часть работы, и вынуждаем его выполнить собственное считывание из входного потока битов (вызывая для этого метод LoadFromBitStream). Если дерево Хаффмана представляет единственный символ, исходный поток восстанавливается в виде повторений этого символа. В противном случае мы вызываем подпрограмму DoHuffmanDecoonpression для выполнения восстановления данных. Код этой подпрограммы приведен в листинге 11.13.

Листинг 11.13. Подпрограмма DoHuffmanDecompression

procedure DoHuffmanDecompression(aBitStream: TtdInputBitStream;

aOutStream: TStream; aHTree: THuffmanTree; aSize: longint);

CharCount: longint;

Buffer: PByteArray;

BufEnd: integer;

GetMem(Buffer, HuffmanBufferSize);

{предварительная установка переменных цикла}

{повторять процесс до тех пор, пока не будут восстановлены все символы}

Ch:= aHTree.DecodeNextByte (aBitStream);

Buffer^ :=Ch;

{если буфер заполнен, необходимо выполнить его запись}

if (BufEnd = HuffmanBufferSize) then begin

aOutStream.WriteBuffer(Buffer^, HuffmanBufferSize);

{если в буфере остались какие-либо данные, необходимо выполнить его запись}

if (BufEnd <> 0) then

aOutStream.WriteBuffer(Buffer^, BufEnd);

FreeMem(Buffer, HuffmanBufferSize);

По существу подпрограмма представляет собой цикл, внутри которого многократно выполняется декодирование байтов и заполнение буфера. Когда буфер заполняется, мы записываем его в выходной поток и начинаем заполнять его снова. Декодирование выполняется при помощи метода DecodeNextByte класса THuffmanTree.

Листинг 11.14. Метод DecodeNextByte

function THuffmanTree.DecodeNextByte(aBitStream: TtdInputBitStream): byte;

NodeInx: integer;

NodeInx:= FRoot;

while (NodeInx >= 256) do

if not aBitStream.ReadBit then

NodeInx:= FTree.hnLeftInx else

NodeInx:= FTree.hnRightInx;

Result:= NodeInx;

Этот метод крайне прост. Он просто начинает обработку с корневого узла дерева Хаффмана, а затем для каждого бита, считанного из входного потока битов, в зависимости от того, был ли он нулевым или единичным, выполняет переход по левой или правой связи. Как только подпрограмма достигает листа, она возвращает индекс достигнутого узла (его значение будет меньше или равно 255). Этот узел является декодированным байтом.

Полный код выполнения восстановления дерева Хаффмана можно найти на Web-сайте издательства, в разделе материалов. После выгрузки материалов отыщите среди них файл TDHuffmn.pas.

Кодирование Хаффмана является простым алгоритмом для построения кодов переменной длины, имеющих минимальную среднюю длину. Этот весьма популярный алгоритм служит основой многих компьютерных программ сжатия текстовой и графической информации. Некоторые из них используют непосредственно алгоритм Хаффмана, а другие берут его в качестве одной из ступеней многоуровневого процесса сжатия. Метод Хаффмана производит идеальное сжатие (то есть, сжимает данные до их энтропии), если вероятности символов точно равны отрицательным степеням числа 2. Алгоритм начинает строить кодовое дерево снизу вверх, затем скользит вниз по дереву, чтобы построить каждый индивидуальный код справа налево (от самого младшего бита к самому старшему). Начиная с работ Д.Хаффмана 1952 года, этот алгоритм являлся предметом многих исследований. (Последнее утверждение из § 3.8.1 показывает, что наилучший код переменной длины можно иногда получить без этого алгоритма.)

Алгоритм начинается составлением списка символов алфавита в порядке убывания их вероятностей. Затем от корня строится дерево, листьями которого служат эти символы. Это делается по шагам, причем на каждом шаге выбираются два символа с наименьшими вероятностями, добавляются наверх частичного дерева, удаляются из списка и заменяются вспомогательным символом, представляющим эти два символа. Вспомогательному символу приписывается вероятность, равная сумме вероятностей, выбранных на этом шаге символов. Когда список сокращается до одного вспомогательного символа, представляющего весь алфавит, дерево объявляется построенным. Завершается алгоритм спуском по дереву и построением кодов всех символов.

Лучше всего проиллюстрировать этот алгоритм на простом примере. Имеется пять символов с вероятностями, заданными на рис. 1.3а.

Рис. 1.3. Коды Хаффмана.

Символы объединяются в пары в следующем порядке:

1. объединяется с , и оба заменяются комбинированным символом с вероятностью 0.2;

2. Осталось четыре символа, с вероятностью 0.4, а также и с вероятностями по 0.2. Произвольно выбираем и , объединяем их и заменяем вспомогательным символом с вероятностью 0.4;

3. Теперь имеется три символа и с вероятностями 0.4, 0.2 и 0.4, соответственно. Выбираем и объединяем символы и во вспомогательный символ с вероятностью 0.6;

4. Наконец, объединяем два оставшихся символа и и заменяем на с вероятностью 1.

Дерево построено. Оно изображено на рис. 1.3а, «лежа на боку», с корнем справа и пятью листьями слева. Для назначения кодов мы произвольно приписываем бит 1 верхней ветке и бит 0 нижней ветке дерева для каждой пары. В результате получаем следующие коды: 0, 10, 111, 1101 и 1100. Распределение битов по краям - произвольное.

Средняя длина этого кода равна бит/символ. Очень важно то, что кодов Хаффмана бывает много. Некоторые шаги алгоритма выбирались произвольным образом, поскольку было больше символов с минимальной вероятностью. На рис. 1.3b показано, как можно объединить символы по-другому и получить иной код Хаффмана (11, 01, 00, 101 и 100). Средняя длина равна бит/символ как и у предыдущего кода.

Пример: Дано 8 символов А, В, С, D, Е, F, G и H с вероятностями 1/30, 1/30, 1/30, 2/30, 3/30, 5/30, 5/30 и 12/30. На рис. 1.4а,b,с изображены три дерева кодов Хаффмана высоты 5 и 6 для этого алфавита.

Рис. 1.4. Три дерева Хаффмана для восьми символов.

Средняя длина этих кодов (в битах на символ) равна

Пример : На рис. 1.4d показано другое дерево высоты 4 для восьми символов из предыдущего примера. Следующий анализ показывает, что соответствующий ему код переменной длины плохой, хотя его длина меньше 4.

(Анализ.) После объединения символов А, В, С, D, Е, F и G остаются символы ABEF (с вероятностью 10/30), CDG (с вероятностью 8/30) и H (с вероятностью 12/30). Символы ABEF и CDG имеют наименьшую вероятность, поэтому их необходимо было слить в один, но вместо этого были объединены символы CDG и H. Полученное дерево не является деревом Хаффмана.

Таким образом, некоторый произвол в построении дерева позволяет получать разные коды Хаффмана с одинаковой средней длиной. Напрашивается вопрос: «Какой код Хаффмана, построенный для данного алфавита, является наилучшим?» Ответ будет простым, хотя и неочевидным: лучшим будет код с наименьшей дисперсией.

Дисперсия показывает насколько сильно отклоняются длины индивидуальных кодов от их средней величины (это понятие разъясняется в любом учебнике по статистике). Дисперсия кода 1.3а равна , а для кода 1.3b .

Код 1.3b является более предпочтительным (это будет объяснено ниже). Внимательный взгляд на деревья показывает, как выбрать одно, нужное нам. На дереве рис. 1.3а символ сливается с символом , в то время как на рис. 1.3b он сливается с . Правило будет такое: когда на дереве имеется более двух узлов с наименьшей вероятностью, следует объединять символы с наибольшей и наименьшей вероятностью; это сокращает общую дисперсию кода.

Если кодер просто записывает сжатый файл на диск, то дисперсия кода не имеет значения. Коды Хаффмана с малой дисперсией более предпочтительны только в случае, если кодер будет передавать этот сжатый файл по линиям связи. В этом случае, код с большой дисперсией заставляет кодер генерировать биты с переменной скоростью. Обычно данные передаются по каналам связи с постоянной скоростью, поэтому кодер будет использовать буфер. Биты сжатого файла помещаются в буфер по мере их генерации и подаются в канал с постоянной скоростью для передачи. Легко видеть, что код с нулевой дисперсией будет подаваться в буфер с постоянной скоростью, поэтому понадобится короткий буфер, а большая дисперсия кода потребует использование длинного буфера.

Следующее утверждение можно иногда найти в литературе по сжатию информации: длина кода Хаффмана символа с вероятностью всегда не превосходит . На самом деле, не смотря на справедливость этого утверждения во многих примерах, в общем случае оно не верно. Я весьма признателен Гаю Блелоку, который указал мне на это обстоятельство и сообщил пример кода, приведенного в табл. 1.5. Во второй строке этой таблицы стоит символ с кодом длины 3 бита, в то время как .

Табл. 1.5. Пример кода Хаффмана.

Длина кода символа , конечно, зависит от его вероятности . Однако она также неявно зависит от размера алфавита. В большом алфавите вероятности символов малы, поэтому коды Хаффмана имеют большую длину. В маленьком алфавите наблюдается обратная картина. Интуитивно это понятно, поскольку для малого алфавита требуется всего несколько кодов, поэтому все они коротки, а большому алфавиту необходимо много кодов и некоторые из них должны быть длинными.

Рис. 1.6. Код Хаффмана для английского алфавита.

На рис. 1.6 показан код Хаффмана для всех 26 букв английского алфавита.

Случай алфавита, в котором символы равновероятны, особенно интересен. На рис. 1.7 приведены коды Хаффмана для алфавита с 5, 6, 7 и 8 равновероятными символами. Если размер алфавита является степенью 2, то получаются просто коды фиксированной длины. В других случаях коды весьма близки к кодам с фиксированной длиной. Это означает, что использование кодов переменной длины не дает никаких преимуществ. В табл. 1.8 приведены коды, их средние длины и дисперсии.

Рис. 1.7. Коды Хаффмана с равными вероятностями.

Тот факт, что данные с равновероятными символами не сжимаются методом Хаффмана может означать, что строки таких символов являются совершенно случайными. Однако, есть примеры строк, в которых все символы равновероятны, но не являются случайными, и их можно сжимать. Хорошим примером является последовательность , в которой каждый символ встречается длинными сериями. Такую строку можно сжать методом RLE, но не методом Хаффмана. (Буквосочетание RLE означает «run-length encoding», т.е. «кодирование длин серий». Этот простой метод сам по себе мало эффективен, но его можно использовать в алгоритмах сжатия со многими этапами, см. }

Понравилась статья? Поделиться с друзьями: