范式哈夫曼算法的分析与实现(一)

来源:互联网 发布:mcpe联机软件 编辑:程序博客网 时间:2024/06/06 03:25
范式哈夫曼算法的分析与实现(一)
2010-10-15 10:44

声明

  本论文题目:范式哈夫曼算法的分析与实现,作者:叶叶,于2010年10月14日在编程论坛上发表。页面地址:http://programbbs.com/bbs/view12-29332-1.htm 。本论文全文及相关配套程序可以在上述页面中下载。请尊重他人劳动成果,转载或引用时请注明出处。

目录

  1 前言 2
  2 理论基础 2
    2.1 哈夫曼算法 2
    2.2 范式哈夫曼算法 6
  3 计算编码位长 7
  4 编码 9
  5 解码 9
    5.1 正常解码 9
    5.2 优化解码 10
  6 限制编码位长 11
  7 码表二次压缩 15
    7.1 指数哥伦布编码 15
    7.2 范式哈夫曼二次压缩 17
  8 测试与分析 18
  9 结束语 20
  参考文献 20
  附录:项目说明 20
    1 程序项目 20
    2 MemoryBuffer.pas文件 21
    3 CanonicalHuffman.pas文件 21
    4 CanHuf_Debug.pas文件 22

范式哈夫曼算法的分析与实现

作者:叶叶(网名:yeye55)

  摘要:全面介绍了范式哈夫曼算法的理论基础和实现方式。详细讨论了编码位长计算、限制编码位长、解码优化、码表二次压缩等实现技术。并给出了一个切实可行的应用程序。

  关键词:哈夫曼;范式哈夫曼;指数哥伦布编码;Delphi

  中图分类号:TP301.6

  1 前言

  David A. Huffman于1952年第一次提出了哈夫曼(Huffman)算法[1],该算法一经提出就引起了研究人员的广泛兴趣。哈夫曼算法使用变长前缀编码来压缩数据。对于出现次数较多的符号使用较短的编码表示,对于出现次数较少的符号使用较长的编码表示。哈夫曼算法的性能十分优异,即使在很糟糕的情况下都可以获得不错的压缩率。但是,哈夫曼算法也有许多缺点。例如:二叉树大量占用内存、码表过大、编解码速度过慢等等。

  Eugene S. Schwartz于1964年提出了范式哈夫曼编码(Canonical Huffman Code)算法[2]。作为哈夫曼算法的一个重要的变体,范式哈夫曼算法解决了哈夫曼算法的许多不足之处。范式哈夫曼算法可以根据编码位长算出编码。这样输出的码表就大大的减少了,而且编解码过程不再需要二叉树结构。在提高编解码速度的同时,内存占用也大幅减少。

  目前范式哈夫曼算法已经在数据压缩领域大量的应用。本文将详细的分析范式哈夫曼算法的理论基础和实现方式,并给出一个在Delphi 7.0下开发,使用范式哈夫曼算法压缩数据的应用程序。

  2 理论基础

  2.1 哈夫曼算法

  范式哈夫曼算法是哈夫曼算法的一种变体。所以这里先对哈夫曼算法进行介绍,然后再推导出范式哈夫曼算法。

  哈夫曼算法需要扫描两遍数据。第一遍扫描计算符号在数据中出现的次数(以下简称“频度”),然后根据符号频度构建一棵哈夫曼二叉树(以下简称“哈夫曼树”)。最后再次扫描数据将符号按哈夫曼树进行编码。例如:一份长度为38个符号的数据,其中一共出现8种符号,其频度如表2.1所示:

  符号 A B C D E F G H
  频度 10 1 1 11 1 1 8 5

  表2.1 符号频度

  构建哈夫曼树的过程是这样的:首先建立一个由二叉树构成的森林,每个叶子节点保存一个符号和它们的频度。每个分支节点保存一个频度总计。刚开始的时候每个二叉树都只有叶子节点。然后对于森林中所有的二叉树,以根节点的频度作为关键字从小到大排序。排序完成后,将排名第1、2位的二叉树合并成一棵新的二叉树。具体做法是:新建一个根节点,将排名第1位的二叉树作为新节点的左子树,排名第2位的二叉树作为新节点的右子树,新节点的频度等于这两棵子树根节点的频度之和。合并完成后再进行排序,不断重复这个过程,直到所有节点都合并到一棵二叉树上。利用表2.1中的频度数据构建哈夫曼树的整个过程如图2.1所示。

图2.1 哈夫曼树的构建过程

  图2.1第8步得出的就是哈夫曼树。现在可以对这棵哈夫曼树分配编码。一般采用左0右1的分配方案,即:为左子树分配编码0,为右子树分配编码1。注意:如果只是单纯的使用哈夫曼算法,不管是采用左0右1还是采用右0左1都是可以正常编解码的。只要编码程序和解码程序都使用相同的编码方案就不会出现错误。但是,本文中要推导出范式哈夫曼算法,所以必需采用左0右1的分配方案。一棵分配了编码的哈夫曼树如图2.2所示。

图2.2 分配编码的哈夫曼树

  根据图2.2中的哈夫曼树可以得到表2.2中的哈夫曼编码。哈夫曼算法编解码的过程其实就是查树的过程。编码一个符号时,先查找该符号对应的叶子节点;从叶子节点开始沿着父节点向上直到根节点;记录下路过每个节点的编码;最终得到的就是符号的哈夫曼编码。解码过程与之相反,从根节点开始;判断输入的位,为0时向左走,为1时向右走;当遇到叶子节点时就可以解码出一个符号。

  符号 编码位长 哈夫曼编码 范式哈夫曼编码
  A  2    10     00
  B  5    01010   11100
  C  5    01011   11101
  D  2    11     01
  E  5    01000   11110
  F  5    01001   11111
  G  2    00     10
  H  3    011    110

  表2.2 哈夫曼编码和范式哈夫曼编码

  从上述的介绍中可以看出哈夫曼算法的一些缺点。首先,编解码过程都需要建立哈夫曼树。哈夫曼树是根据符号频度建立的。这就意味着编码输出的时候必需输出一份频度数据(以下简称“码表”)以便解码的时候可以重建哈夫曼树。这份码表将占用输出数据很大的一部分空间。其次,哈夫曼树将占用大量的内存。而且频繁查树的效率也不高。另外,哈夫曼编码具有不确定性。例如,前面讲到的左0右1的分配方案;再例如,在构建时采用稳定或不稳定的排序算法。这都会影响到最终输出的哈夫曼编码。范式哈夫曼算法就是为了解决上述缺点而提出的。

  2.2 范式哈夫曼算法

  范式哈夫曼算法采用了一个巧妙的方法对哈夫曼树进行了规范调整。首先,对于同一层的节点,所有的叶子节点都调整到左边。然后,对于同一层的叶子节点按照符号顺序从小到大调整。最后,按照左0右1的方案分配编码。图2.2中的哈夫曼树经过这样的调整可以得到一棵范式哈夫曼树(以下简称“范式树”)如图2.3所示。

图2.3 范式哈夫曼树

  根据图2.3中的范式树可以得到表2.2中的范式哈夫曼编码。将表2.2中的范式哈夫曼编码,按照以编码位长为第1关键字、符号为第2关键字进行排序,可以得到范式哈夫曼编码表。可以将相同位长的符号称之为同组符号。在同组符号之间的排序序号可以称之为同组序号。结果如表2.3所示。

  序号 同组序号 符号 位长 编码
  0  0    A  2  00
  1  1    D  2  01
  2  2    G  2  10
  3  0    H  3  110
  4  0    B  5  11100
  5  1    C  5  11101
  6  2    E  5  11110
  7  3    F  5  11111

  表2.3 范式哈夫曼编码表

  观察图2.3和表2.3可以得出一些结论。第一,只要知道一个符号的编码位长就可以知道它在范式树上的位置。这就意味着在码表中只要保存每个符号的编码位长即可。编码位长通常要比符号频度小很多,所以码表的体积就可以减小很多。第二,相同位长的编码之间都相差1。例如,同组符号“A”、“D”、“G”的编码分别为“00”、“01”、“10”。而且,相邻的不同位长的编码,对齐后的高位相差1低位补充0。例如“H”和“B”的编码分别为“110”和“11100”,高3位相差1,补充2位0。这就意味着编码可以根据位长计算出来。编解码的过程可以根据计算出来的编码表进行,从而加快了编解码的速度。第三,根据以上两点可以发现,在编码的过程中,不必再建立范式树。只要模拟哈夫曼树的构建,计算出符号的编码位长即可。这样可以加快编码速度,同时又能减少内存的占用。

  可以看出,范式哈夫曼算法解决了哈夫曼算法的许多不足之处。以下就来讨论范式哈夫曼算法的具体实现。

  3 计算编码位长

  先说排序算法。前面讲到哈夫曼树的构建过程,其实就是从一个有序表中删除两个最小的元素,再插入一个新的元素。在这种情况下使用堆排序算法(Heap Sort)可以获得不错的性能。另外,由于只要计算编码位长,可以使用一个数组模拟构建哈夫曼树的过程。Alistair Moffat在书中描述了一种计算哈夫曼编码位长的算法[3](以下简称“M算法”)。设n为符号的信息量(Content),M算法使用了一个2n大小的数组。利用数组的左边保存一个最小堆结构,利用数组的右边保存符号频度。在这个数组中进行数据整理,可以模拟出构建哈夫曼树的过程。并且计算出编码位长。M算法不仅节约内存,其运行效率也相当不错。下面就来介绍这种算法。

  在我们的例子里一共出现了8种符号,所以n = 8。可以使用下面的代码建立一个具有2n个元素的数组Buf。

  Buf : array [0 .. (2 * n) - 1] of Cardinal;

  假设符号“A”的序号为0、“B”的序号为1、……、“H”的序号为7。先将“A”到“H”的符号频度依次放入数组Buf的n到2n - 1位置。Buf的左边用来建立一个最小堆结构。左边的每个元素都保存一个指向右边元素的索引。对Buf的n到2n - 1元素遍历一遍就可以构建起一个最小堆,构建完成后的数据如图3.1 1)所示。

图3.1 M算法的数据整理

  根据最小堆的原理,Buf[0]就是最小的一个元素。将Buf[0]移动到堆的末尾,这里是Buf[7]。然后重新整理堆,结果如图3.1 2)所示。再执行一次上述操作,结果如图3.1 3)所示。这样,我们就有两个需要合并的节点Buf[7]和Buf[6]对应Buf[9]和Buf[12]。将Buf[9]和Buf[12]相加保存到Buf[7]中,Buf[9]和Buf[12]分别保存对于Buf[7]的索引7。此时Buf[7]中已经保存了频度之和,将Buf[7]重新放入堆进行整理。过程如图3.1 4) 5)所示。重复这个过程,直到堆中只剩下一个元素,如图3.1 6)所示。

  可以看出,整理完成后Buf的1到2n-1已经保存了一棵哈夫曼树。Buf[1]就是根节点,后续元素保存一个指向父节点的索引。由于子节点的编码位长肯定等于父节点编码位长加1。所以可以用一个简单的公式算出节点的编码位长:Buf[i] = Buf[Buf[i]] + 1。将Buf[1]设为0。对Buf的2到2n - 1元素按上述公式遍历计算一遍,就可以得到编码位长。如图3.1 7)所示。

  自此,符号“A”到“H”的编码位长就保存到Buf的n到2n - 1位置上。

  4 编码

  当符号的编码位长计算出来之后,就可以根据位长为符号分配编码。现在先对表2.3中的数据进行统计。计算出相同位长的符号数量。以及相同位长中第一个符号的编码(以下简称“首编码”)。结果如表4.1所示。

  编码位长 首编码 符号数量
  2    00   3
  3    110  1
  5    11100 4

  表4.1 位长表

  综合分析表2.3和表4.1中的数据可以发现一些规律。每个首编码都等于上个首编码加上对应的符号数量,再左移相差位。例如,“110”等于“00”加上3再左移1位。在同组符号之间,符号编码等于首编码加上该符号的同组序号。例如,在编码位长为5的符号中“E”的序号是2。“E”的编码“11110”等于“11100”加上2。

  在实现的时候,由于前面计算完编码位长后Buf的0到n - 1位置已经不再使用。这时可以用这部分的空间来保存编码。注意:这里有一个前提,编码的位长不会超过32位。因为数组Buf使用Cardinal类型保存数据。

  分配编码的过程是这样的。首先,对Buf的n到2n - 1位置的位长数据扫描一遍。计算相同位长的符号数量;以及符号的同组序号。符号数量可以新建一个数组保存,同组序号可以保存到Buf的0到n - 1位置。接着,根据符号数量计算出首编码。然后,对Buf的n到2n - 1位置的位长数据再扫描一遍。根据首编码和符号的同组序号计算出符号编码,保存到Buf的0到n - 1位置。

  这样当范式哈夫曼算法第2遍扫描数据,进行编码的时候。就可以从Buf中直接获得编码以及编码位长。例如,符号“A”的编码就保存在Buf[“A”]中,编码位长就保存在Buf[“A”+ n]中。

  5 解码

  5.1 正常解码

  编码过程中的符号编码是根据一定的规律计算出来的。解码过程就相当于编码过程的逆运算。在解码之前需要根据编码位长建立一份解码表。如表5.1所示。

  序号 编码位长 首编码 符号数量 符号索引
  0  2    00   3    0
  1  3    110  1    3
  2  5    11100 4    4

  表5.1 解码表

  表5.1与表4.1类似,只是多了一个符号索引。符号索引对应于表2.3中的序号。例如,编码位长为5的符号,在表2.3中从序号4开始。那么表5.1中对应符号索引就等于4。在解码的时候不仅需要表5.1中的数据,还需要表2.3中经过排序的符号列表。

  一般情况下,编码位长会和压缩数据一起保存。解码时先从压缩数据中提取符号的编码位长。然后,可以使用基数排序算法(Radix Sort)计算符号列表。同时计算出表5.1中的相关数据。具体过程同分配编码时类似,这里就不再重复。

  建立完解码表就可以开始解码了。首先,设i = 0,从解码表的第i行开始。根据编码位长从数据中读取相应长度的位。将读取数据与首编码相减,假设差值等于Num。如果Num大于等于符号数量,那么将i加1重新开始整个过程。如果Num小于符号数量,那么将符号索引加Num从符号列表上的对应位置解码出一个符号。

  例如,输入数据“11110”。令i = 0,此时编码位长为2。读取2位的数据“11”与首编码相减等于3。3大于等于符号数量,于是i = i + 1等于1。此时编码位长为3。读取3位的数据“111”与首编码相减等于1。1大于等于符号数量,于是i = i + 1等于2。此时编码位长为5。读取5位的数据“11110”与首编码相减等于2。2小于符号数量,2加符号索引4等于6。从表2.3中可以查到序号为6的符号是“E”。从而解码出符号“E”。跳过当前已经解码的5位数据,可以重新开始解码下一个符号。

  由于前面编码的时候假设了一个前提,即:编码位长不会超过32位。那么在实现的时候,可以使用32个元素的数组保存表5.1中的数据。其中编码位长隐含在数组中可以被省略。另外,解码的时候可以声明一个Cardinal类型的变量,一次性读取32位的数据。在相减的时候,可以根据当前的编码位长将变量右移再相减。这样就不用一点一点的读取位数据。

  5.2 优化解码

  从解码的过程可以看出,解码最重要的问题就是判断编码位长。上述解码算法需要从最小的编码位长开始向下查找可能的编码位长。这种算法并不高效。一种比较简单的改进方案是建立一张k位的快速查询表(以下简称“快表”),其中k称为快表位长。k位的快表一共有2k个表项,每个表项保存一个符号和对应的编码位长。快表的序号对应于Num输入的编码。

  快表的建立过程是这样的:对于编码位长小于快表位长的情况,快表序号高位与编码相同的表项都设置为相应的符号。对于编码位长等于快表位长的情况,快表序号与编码相同的表项设置为相应的符号。对于编码位长小于快表位长的情况,快表序号与编码高位相同的表项设置为相应的起始符号。在我们的例子中使用4位快表的情况,如表5.2所示。

  序号 二进制序号 符号 编码位长 编码
  0  0000    A  2    00
  1  0001    A  2    00
  2  0010    A  2    00
  3  0011    A  2    00
  4  0100    D  2    01
  5  0101    D  2    01
  6  0110    D  2    01
  7  0111    D  2    01
  8  1000    G  2    10
  9  1001    G  2    10
  10  1010    G  2    10
  11  1011    G  2    10
  12  1100    H  3    110
  13  1101    H  3    110
  14  1110    -  5    11100
  15  1111    -  5    11110

  表5.2 快表

  注意,快表中只保存符号和编码位长两项。使用快表解码时,先读取k位的数据。直接将数据作为索引在快表中查询。如果对应表项的编码位长小于等于快表位长,则直接解码出符号。如果对应表项的编码位长大于快表位长,则从对应的编码位长开始,然后按照正常解码的算法进行。

  例如,输入数据“01110”,先读取4位数据“0111”。查询快表,符号为“D”,编码位长为2。2小于4,此时可以直接解码出符号“D”。再例如,输入数据“11110”,先读取4位数据“1111”。查询快表,符号不确定,编码位长为5。5大于4,然后以编码位长为5开始,按照正常解码的算法进行。可以解码出“E”。

  可以看出,快表能够提高解码速度。对于编码位长小于等于k的符号一次查表就可以解码出来。这些符号通常都是频度比较高的符号。即使查表出现编码位长大于k的情况,即:没有命中符号。也可以根据查到的编码位长缩短正常解码时查询的范围。

  在实现的时候,如果对内存占用的要求很高,可以省略快表中的符号项。解码时先从快表中查到对应的编码位长,再根据编码位长进行正常的解码。这样速度会略慢一点。另外,在处理实际数据的时候,可以根据数据特点调整快表的位长。例如,将快表位长设为最大编码位长。在我们的例子里最大编码位长是5。这样解码每一个符号都只要一次查表。但是在最大编码位长较大的时候,这种方法会占用大量的内存。所以实现的时候还要根据实际情况调整。还有,在解码的时候同样可以一次性读取32位的数据。然后根据变量的高k位进行查表。

  6 限制编码位长

  在前面编解码的时候都假设了一个前提:编码位长不会超过32位。如果编码位长超过了32位,前面的编解码过程将会出现错误。那么哈夫曼编码位长最长可以达到多少?这是一个非常有趣的问题。学过算法的人都知道斐波那契数列,那么我们假设符号的频度恰好为一个斐波那契数列。符号对应的频度如表6.1所示。

  符号 A B C D E F G H
  频度 1 1 1 3 4 7 11 18

  表6.1 符号频度为斐波那契数列

  其中符号“A”、“B”、“C”的频度都为1,“D”的频度为前3个频度之和。从“E”开始频度值为一个斐波那契数列。按照这样的频度生成的范式树如图6.1所示。

图6.1 单边范式树

  图6.1中的范式树可以被称之为单边范式树。注意将它与单边二叉树相区别。根据上述规律,假设信息量为n的符号,其哈夫曼编码位长理论上最长可以达到n - 1位。对于8位符号,信息量为256,其哈夫曼编码位长理论上最长可以达到255位。这是一个相当惊人的数据。但是这仅仅只是理论上的结果,我们还要考虑现实一点的问题。

  当实际编码位长大于指定编码位长时称之为位长溢出。上面的例子中使用了8种符号以及一份46个符号的数据,让实际编码位长达到了7位。如果要让编码位长超过32位,就要使用34种符号。并构造一份长达12,752,042个符号的数据。这还只是一份人工数据。在现实的应用中,符号通常用来表示特定的信息。符号与符号之间也具有一定的关系。出现像表6.1那样的频度十分罕见。

  当然,出现位长溢出的概率很低不等于不会出现位长溢出。解决位长溢出的方法无外乎两种,要么对计算出来的位长进行限制,要么采用无限制位长的编解码算法。很明显采用无限制位长的编解码算法会严重拖慢编解码的速度。那么只有采用限制位长的方法。

  限制位长的方法也有两种,第一种方法是在计算编码位长之前对符号频度进行调整,使之不会出现位长溢出;另一种方法是在出现位长溢出后对位长进行调整,使之满足要求。限制位长肯定会影响到压缩率。第一种方法可以把压缩率的损失降到最低,第二种方法次之。但第二种方法的速度比第一种方法快很多,而且它只是在出现溢出的时候才进行调整。所以整体性能比第一种方法高。下面就来介绍第二种方法。

  首先我们观察图2.3中的范式树,假设现在要将编码位长限制到4位。那么调整需要从最右边最下层的分支(以下简称“右下分支”)开始。根据哈夫曼树和范式树的规律,右下分支肯定是如图6.2所示的样式。

图6.2 右下分支

  从图2.3中的范式树上查找小于第4层的最右边的叶子节点。可以看出这是符号“H”对应的叶子节点。将该叶子节点下放一层,形成一个分支节点。原叶子节点作为新分支节点的左叶子节点。将右下分支的左叶子节点作为新分支节点的右叶子节点。将右下分支的右叶子节点上提一层替换原右下分支节点。过程如图6.3 1) 2)所示。

图6.3 范式树的调整

  上述的调整可以看出一个问题:右下分支的叶子节点应该是最下层的节点,占用最长的位。而该叶子节点却被调整到了较上层,占用较少的位。这样对于压缩率的损失较大。所以还要进行下优化调整。优化调整时遵循这样一个原则:在调整的时候,尽量保持符号原先的左右顺序不变。在满足上一条件的情况下,再按照范式树的规范进行调整。优化调整后的范式树如图6.3 3)所示。注意,由于此时调整还未完成,所以符号“H”排在了“B”和“C”的左边。这是按照原先的左右顺序排列。调整后又会出现一个右下分支,可以继续进行调整。直到所有的节点都不低于4层。最后调整完成的范式树如图6.3 4)所示。

  当然,范式哈夫曼算法在实现的时候并没有生成一棵完整的树。那么调整就要在表2.3中的数据中进行。仔细观察表2.3中的数据可以发现,位于表末尾的两个符号就是要进行调整的右下分支。这就意味着可以在表中进行调整,以模拟出上述在树中调整的过程。注意,表2.3中的数据是经过排序的。

  在表中调整位长的过程是这样的:首先,从表的末尾向上查找位长小于限制位长的序号。这里是序号3。将该位长加1,并设置倒数第二位的位长为该位长。然后将倒数第一位的位长减1。最后再对位长数据按大小整理。整理时要注意保持符号的顺序不变。原先序号6的位长被整理到了序号4。原先序号7的位长被整理到了序号5。这时可以判断末尾的位长是否大于限制位长。如果不大于,则调整完成。如果大于,就从原先序号3的下一位序号4开始向上查找位长小于限制位长的序号。并重复刚才的调整过程,直到末尾的位长小于等于限制位长。整个过程如表6.2所示。

  序号 符号 位长  位长调整
          第一轮 第二轮
  0  A  2  2 2  2 2
  1  D  2  2 2  2 2
  2  G  2  2 2  3 3
  3  H  3  4 4  4 3
  4  B  5  5 4  4 4
  5  C  5  5 4  4 4
  6  E  5  4 5  3 4
  7  F  5  4 5  4 4

  表6.2 位长调整

  可以看出在表中调整可以完美的模拟出在树中的调整,而且速度还更快。观察调整完的位长后可以发现,“B”、“C”、“E”、“F”的位长减小了,“H”的位长没有改变,而“G”的位长增加了。这说明该算法在控制压缩率损失方面并不优秀。但是,由于每次调整都是从较下层的节点开始。如果下层节点能够腾出足够的位置,对于上层节点将不会有任何影响。这意味着,虽然该算法在压缩率上会有损失,但仍然是可以被接受的。另外,由于只是在出现位长溢出的时候进行调整。对于没有溢出的压缩来讲,将不会有任何影响。

原创粉丝点击