哈夫曼(huffman)压缩算法,非常易懂

 本文将介绍哈夫曼压缩算法(Huffman compression)

1. 前文回顾

  在字符串算法—字符串排序(上篇)字符串算法—字符串排序(下篇)中,我们讲述了字符串的排序方法;

  在字符串算法—字典树中,我们讲述了如何在一堆字符串中寻找某个字符串的方法;

  在字符串算法—字符串搜索字符串算法—正则表达式中,我们讲述了如何在一堆字符(如文章)中寻找某个特定的或符合某个规律的字符串的方法。

  著名的压缩算法有很多,这里将介绍两个:哈夫曼压缩算法(Huffman compression)LZW压缩算法(LZW compression)

  而本文将先讲述哈夫曼压缩算法(Huffman compression)

2. 为什么要进行数据压缩

  在这个每天都会诞生大量数据的时代,数据压缩扮演着重要的角色,如数据传输,传输压缩过的数据肯定会比原数据快。

  数据压缩的重要性大家都懂,这里不多说,直接介绍如何进行数据压缩。

  本文介绍的压缩算法是无损压缩,保证压缩解压后,数据无丢失。

 

3. 哈夫曼压缩算法(Huffman compression)

  众所周知,计算机存储数据时,实际上存储的是一堆0和1(二进制)。

  如果我们存储一段字符:ABRACADABRA!

  那么计算机会把它们逐一翻译成二进制,如A:01000001;B: 01000010; !: 00001010.

  每个字符占8个bits, 这一整段字符则至少占12*8=96 bits。

  但如果我们用一些特殊的值来代表这些字符,如:

  

  图中,0代表A; 1111代表B;等等。此时,存储这段字符只需30bits,比96bits小多了,达到了压缩的目的。

  我们需要这么一个表格来把原数据翻译成特别的、占空间较少的数据。同时,我们也可以用这个表格,把特别的数据还原成原数据。

  首先,为了避免翻译歧义,这个表格需满足一个条件:任何一个字符用的值都不能是其它字符的前缀

  我们举个反例:A: 0; B: 01;这里,A的值是B的值的前缀。如果压缩后的数据为01xxxxxx,x为0或者1,那么这个数据应该翻译成A1xxxxxx, 还是Bxxxxxxx?这样就会造成歧义。

  然后,不同的表格会有不同的压缩效果,如:

  

  这个表格的压缩效果更好。

  那么我们如何找到最好的表格呢?这个我们稍后再讲。

  为了方便阅读,这个表格是可以写成一棵树的:

  

  这棵树的节点左边是0,右边是1。任何含有字符的节点都没有非空子节点。(即上文提及的前缀问题。)

  这棵树是在压缩的过程中建成的,这个表格是在树形成后建成的。用这个表格,我们可以很简单地把一段字符变成压缩后的数据,如:

  原数据:ABRACADABRA!

  表格如上图。

  令压缩后的数据为S;

  第一个字符是A,根据表格,A:11,故S=11;

  第二个字符是B,根据表格,B:00,故S=1100;

  第三个字符是R,根据表格,R:011,故S=1100011;

  如此类推,读完所有字符为止。

  压缩搞定了,那解压呢?很简单,跟着这棵树读就行了:

  

  压缩后的数据S=11000111101011100110001111101

  记住,读到1时,往右走,读到0时,往左走。

  令解压后的字符串为D;

  从根节点出发,第一个数是1,往右走:

  

  第二个数是1,往右走:

  

  读到有字符的节点,返回此字符,加到字符串D里。D:A;

  返回根节点,继续读。

  

  第三个数是0,往左走:

   

  第四个数是0,往左走:

  

  读到有字符的节点,返回此字符,加到字符串D里。D:AB;

  返回根节点,继续读。

  第五个数是0,往左走:

  

  第六个数是1,往右走:

   

  第七个数是1,往右走:

  

  读到有字符的节点,返回此字符,加到字符串D里。D:ABR;

  返回根节点,继续读。

  如此类推,直到读完所有压缩后的数据S为止。

  压缩与解压都搞定了,现在看如何构建这个表格:

  我们需要先把原数据读一遍,并把每个字符出现的次数记录下来。如:

  ABRACADABRA!中,A出现了5次;B出现了2次;C出现了1次;D出现了1次;R出现了2次;!出现了1次。

  理论上,出现频率越高的字符,我们给它一个占用空间越小的值,这样,我们就可以有最佳的压缩率。

  我们把这些字符,按次数的多少排成递增的顺序:(括弧中的数字为出现次数)

  

  然后,我们把最小的两个字符找出来,并新建一个节点作为它们的父节点(谁左谁右不重要,随意):

  

  父节点的出现次数为子节点之和,新节点加入数组;(PS:这里提到了找出最小值,我们自然而然会想起了最小堆,不了解的,建议去补一下。用最小堆,我们可以高效地找出最小值,加入新元素也不需对数组重新排序)

  然后,我们把最小的两个字符找出来,并从数组中移除,并新建一个节点作为它们的父节点(谁左谁右不重要,随意),新节点加入数组:

  

  把最小的两个字符找出来,并从数组中移除,并新建一个节点作为它们的父节点(谁左谁右不重要,随意),新节点加入数组: 

  

  把最小的两个字符找出来,并从数组中移除,并新建一个节点作为它们的父节点(谁左谁右不重要,随意),新节点加入数组:

  

  把最小的两个字符找出来,并从数组中移除,并新建一个节点作为它们的父节点(谁左谁右不重要,随意),新节点加入数组:

  

  数组中只剩下一个元素了,构建表格结束。

  把这棵树转成表格:

  从左到右读过去(先序遍历)。给每个节点新建一个整数变量int C; 用C来记住每个节点对应的值。新建节点数组T来把含字符的节点记录下来。

  从根节点出发,先看左子节点(左边是0,右边是1),发现含有字符A,故A对应的值为0。

  

  然后返回上一个父节点,再看右子节点,发现右子节点不含字符,此节点的值为1:

  

  去看此节点的左子节点,发现此子节点不含字符,此节点的值为父节点的值+0,即10:(为了方便观看,我把代表频率的括弧里的值隐藏,把节点对应的值写在节点上)

  

  去看此节点的左子节点,发现此子节点含符号 !,此节点的值为父节点的值+0,即100:

  

  然后返回上一个父节点,再看右子节点,发现右子节点不含字符,此节点的值为父节点的值+1,即101:

  

  去看此节点的左子节点,发现此子节点含符号 C,此节点的值为父节点的值+0,即1010:

  

  然后返回上一个父节点,再看右子节点,发现右子节点含字符D,此节点的值为父节点的值+1,即1011:

  

  然后返回上一个父节点,左右子节点都看过了,再返回上一个父节点:

  

  然后返回上一个父节点,再看右子节点,发现右子节点不含字符,此节点的值为父节点的值+1,即11:

  

  去看此节点的左子节点,发现此子节点含符号 R,此节点的值为父节点的值+0,即110:

  

  然后返回上一个父节点,再看右子节点,发现右子节点含字符B,此节点的值为父节点的值+1,即111:

  

  然后一路返回父节点,去寻找有没还没看的子节点,结果没有,建表完成。

  这个表格跟上述的例子用的表格不相同,如果用这个表格进行压缩,会发现压缩后的数据只有28bits。这个表格是最佳压缩表。

  这个建表就是一个递归的过程。

  到目前为止,我们已经讲了如何压缩、解压、建表。完事了吗?不,我们还需要把表格用二进制存储起来,并且能从二进制中读取表格。

  存储表格跟上面的建表过程差不多,也是先序遍历(从左读到右)。

  令表格存储的数据为Y;

  首先从根节点开始:

  

  此节点不含字符,故加一个0给Y,Y:0;

  然后读此节点的左子节点:(为了避免歧义,我们把不含字符的点的值隐藏)

  

  此节点含字符A,A的二进制为01000001,由于此节点含有字符,加一个1给Y,(这个1是标志着此节点含有字符),再把A的二进制加给Y,Y:0101000001;

  然后返回上一个父节点,再看右子节点,发现右子节点不含字符,加一个0给Y,Y:01010000010;

  

  读此节点的左子节点,发现左子节点不含字符,加一个0给Y,Y:010100000100;

  

  读此节点的左子节点:

  

  此节点含符号 !,!的二进制为00001010,由于此节点含有字符,加一个1给Y,再把 ! 的二进制加给Y,Y:010100000100100001010;

  然后返回上一个父节点,再看右子节点,发现右子节点不含字符,加一个0给Y,Y:0101000001001000010100;

  

  读此节点的左子节点:

  

  此节点含符号 C,C的二进制为01000011,由于此节点含有字符,加一个1给Y,再把C的二进制加给Y,Y:0101000001001000010100101000011;

  然后返回上一个父节点,再看右子节点,发现右子节点含字符D,D的二进制为01000100,由于此节点含有字符,加一个1给Y,再把D的二进制加给Y,Y:0101000001001000010100101000011101000100;

  

  然后返回上一个父节点,左右子节点都看过了,再返回上一个父节点:

  

  然后返回上一个父节点,再看右子节点,发现右子节点不含字符,加一个0给Y,Y:01010000010010000101001010000111010001000:

  

  读此节点的左子节点:

  

  此节点含符号 R,R的二进制为01010010,由于此节点含有字符,加一个1给Y,再把R的二进制加给Y,Y: 01010000010010000101001010000111010001000101010010:

  然后返回上一个父节点,再看右子节点,发现右子节点含字符B,B的二进制为01000010,由于此节点含有字符,加一个1给Y,再把B的二进制加给Y,Y: 01010000010010000101001010000111010001000101010010101000010;

  

  然后一路返回父节点,去寻找有没还没看的子节点,结果没有,存储表格完成。

  从二进制中读取表格也是用先序遍历(先左再右);

  我们存储的表格数据为:Y: 01010000010010000101001010000111010001000101010010101000010;

  第一个数字为0,说明这是不含字符节点,新建节点:

  

  Y: 01010000010010000101001010000111010001000101010010101000010;

  接下来新建的两个节点依次为此节点的左右节点;

  第二个数字为1,说明这是含字符节点,读接下来的8个数字,得到对应的字符A,新建含A的节点;

  Y: 01010000010010000101001010000111010001000101010010101000010;

  下一个数字为0,说明这是不含字符节点,新建节点:

  

  含A的节点没子节点;新建的不含字符的节点将把接下来新建的两个节点依次为此节点的左右节点;

  Y: 01010000010010000101001010000111010001000101010010101000010;

  下一个数字为0,说明这是不含字符节点,新建节点:(未知节点的存在只是为了强调另一个节点为左节点)

  

  Y: 01010000010010000101001010000111010001000101010010101000010;

  新建的不含字符的节点将把接下来新建的两个节点依次为此节点的左右节点;

  下一个数字为1,说明这是含字符节点,读接下来的8个数字,得到对应的字符 ! ,新建含 ! 的节点;

  

  Y: 01010000010010000101001010000111010001000101010010101000010;

  下一个数字为0,说明这是不含字符节点,新建节点:

  

  Y: 01010000010010000101001010000111010001000101010010101000010;

  新建的不含字符的节点将把接下来新建的两个节点依次为此节点的左右节点;

  下一个数字为1,说明这是含字符节点,读接下来的8个数字,得到对应的字符 C,新建含 C的节点;

  

  Y: 01010000010010000101001010000111010001000101010010101000010;

  下一个数字为1,说明这是含字符节点,读接下来的8个数字,得到对应的字符 D,新建含 D的节点;

  

  Y: 01010000010010000101001010000111010001000101010010101000010;

  下一个数字为0,说明这是不含字符节点,新建节点,把节点放在未知节点处,(由于这个过程里,代码是使用递归,故自动把节点放在未知节点处)

  

  Y: 01010000010010000101001010000111010001000101010010101000010;

  新建的不含字符的节点将把接下来新建的两个节点依次为此节点的左右节点;

  下一个数字为1,说明这是含字符节点,读接下来的8个数字,得到对应的字符 R,新建含 R的节点;

  

  Y: 01010000010010000101001010000111010001000101010010101000010;

  下一个数字为1,说明这是含字符节点,读接下来的8个数字,得到对应的字符 B,新建含B的节点;

  

  Y: 01010000010010000101001010000111010001000101010010101000010;

  读完,读表结束。

  综上所述,哈夫曼压缩算法结束。

代码实现:

  实现节点:

  

  建立表格:

  

  存储表格:

  

  读取表格:

  

  解压:

  

  

4. 算法缺点

  哈夫曼压缩算法效率不错,美中不足之处为它建立表格时,需要先把原数据读一遍,从而来记录字符出现次数。这难免会对效率有所影响,我们是否会有更好的压缩算法呢?

  是的,LZW压缩算法(LZW compression)将在下一篇随笔中介绍。

<think>好的,我现在需要帮用户了解哈夫曼编码算法的原理及实现。首先,我应该回忆一下哈夫曼编码的基本概念。哈夫曼编码是一种用于数据压缩的算法,通过给频率高的字符分配较短的编码,频率低的分配较长的编码,从而减少整体数据的大小。 接下来,用户可能想知道原理的具体步骤。我记得哈夫曼编码的构建过程包括统计字符频率、构建优先队列、生成哈夫曼树、分配编码这几个步骤。需要详细说明每一步,比如如何构建优先队列,如何合并节点形成树结构,然后如何从树根到叶子生成二进制编码。 然后是关于实现的部分。用户可能需要一个Python的示例代码。我需要确保代码结构清晰,包括节点类、构建优先队列、合并节点、生成编码表等部分。同时,要检查代码的正确性,比如优先队列的使用是否正确,递归生成编码的逻辑是否合理。 还要考虑用户可能的疑问,比如哈夫曼编码的优点和应用场景。根据提供的引用,可以引用[^3]提到的优点,如压缩效率高、编码唯一等。应用场景可能包括文件压缩、通信数据传输等。 另外,用户提供的引用中有提到哈夫曼编码的程序流程图和实现细节,比如引用[^4]中的函数定义。可能需要在解释实现步骤时提到这些,但不需要深入代码细节,保持回答的易懂性。 需要确保回答符合用户给的格式要求,比如行内公式用$...$,独立公式用$$...$$,并且正确使用中文。同时,生成相关问题时要围绕哈夫曼编码的相关知识点,比如原理、实现步骤、优化、应用比较等。 最后,检查引用标识是否正确添加,比如在提到统计频率时引用[^1],算法实现引用[^2],优点引用,流程图引用。要自然地将引用标识放在相关内容的末尾,避免干扰阅读。 可能还需要验证示例代码是否能够运行,比如测试字符串的哈夫曼编码是否正确生成。如果用户提供的引用中有具体例子,比如字符串"hhhhhhhhgggggggffffffeeeeeddddcccbba"的频率统计,可以引用[^1]作为例子,说明如何统计字符出现次数。 总结下来,回答的结构应该是:原理分步骤说明,每个步骤详细解释;实现部分给出代码示例并解释关键部分;优点和应用引用提供的资料;最后生成相关问题,覆盖原理、实现、优化、应用等方面。</think>### 哈夫曼编码算法原理及实现 #### 一、算法原理 哈夫曼编码是一种基于字符频率的最优前缀编码算法,核心思想是**为高频字符分配短编码,低频字符分配长编码**,从而实现数据压缩。具体步骤如下: 1. **统计字符频率** 统计待编码字符串中每个字符的出现次数,例如字符串 `hhhhhhhhgggggggffffffeeeeeddddcccbba` 中字符频率为:`h:8, g:7, f:6, e:5, d:4, c:3, b:2, a:1`。 2. **构建优先队列** 将每个字符及其频率作为叶子节点,按频率升序排列成优先队列。 3. **生成哈夫曼树** - 每次取出频率最小的两个节点,合并为一个新节点,新节点的频率为两者之和。 - 将新节点重新加入队列,重复此过程直至队列中只剩一个节点(根节点)。 此过程形成的二叉树称为哈夫曼树,合并规则保证了高频字符靠近根节点[^2]。 4. **分配编码** 从根节点出发,向左子树路径标记为$0$,向右子树路径标记为$1$,叶子节点的路径即为其编码。例如,高频字符`h`的编码可能为$0$,低频字符`a`的编码可能为$11110$。 #### 二、Python实现示例 ```python import heapq from collections import defaultdict class Node: def __init__(self, char, freq): self.char = char self.freq = freq self.left = None self.right = None def __lt__(self, other): return self.freq < other.freq def build_huffman_tree(text): # 统计字符频率 freq = defaultdict(int) for char in text: freq[char] += 1 # 构建优先队列 heap = [Node(char, f) for char, f in freq.items()] heapq.heapify(heap) # 合并节点生成哈夫曼树 while len(heap) > 1: left = heapq.heappop(heap) right = heapq.heappop(heap) merged = Node(None, left.freq + right.freq) merged.left = left merged.right = right heapq.heappush(heap, merged) return heap[0] if heap else None def generate_codes(root, current_code="", codes={}): if root is None: return if root.char is not None: codes[root.char] = current_code generate_codes(root.left, current_code + "0", codes) generate_codes(root.right, current_code + "1", codes) return codes # 示例使用 text = "hhhhhhhhgggggggffffffeeeeeddddcccbba" huffman_tree = build_huffman_tree(text) huffman_codes = generate_codes(huffman_tree) print("Huffman Codes:", huffman_codes) ``` #### 三、算法优点 1. **压缩效率高**:通过动态分配编码减少冗余数据。 2. **无歧义解码**:前缀编码特性保证编码唯一性。 3. **时间复杂度低**:构建哈夫曼树的时间复杂度为$O(n \log n)$,适合大规模数据。 #### 四、应用场景 - 文件压缩(如ZIP、GZIP) - 图像/视频编码(如JPEG、MPEG) - 通信数据传输优化 ---
评论 6
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值