哈夫曼树与哈夫曼编码
二、知识总结
✨ 核心思想
哈夫曼树(Huffman Tree),又称最优二叉树,是一种特殊的二叉树结构,广泛用于数据压缩中的哈夫曼编码技术。这种编码方法由 David A. Huffman 在 1952 年提出,旨在最小化编码后的总比特数,实现有效的数据压缩。
哈夫曼树的特点:
- 权值:树中每个叶节点都赋予一个权值,这些权值通常代表了字符在数据文件中出现的频率。
- 最优二叉树:哈夫曼树的所有非叶节点都有两个子节点,使得树的加权路径长度(WPL)最小。加权路径长度是所有叶节点的路径长度与其权值的乘积之和。
✨ 构建哈夫曼树的步骤
构建哈夫曼树的过程是一个从叶节点到根节点的合并过程:
- 初始化:根据每个字符的出现频率创建一个叶节点,并将所有节点放入优先队列中。
- 构建树:
- 从队列中选出两个权值最小的节点作为左右子节点。
- 创建一个新的内部节点,其权值为两个子节点权值之和,这两个节点成为其子节点。
- 将新节点加回到队列中。
- 重复这个过程,直到队列中只剩下一个节点,这个节点就是哈夫曼树的根节点。
- 结果:得到的哈夫曼树的每个叶节点对应输入中的一个字符,路径从根到叶节点定义了该字符的唯一编码。
✨ 哈夫曼编码
哈夫曼编码是基于哈夫曼树的一种编码方式:
- 编码规则:从根到每个叶子的路径可以转换为一个二进制数,其中左子树代表 0,右子树代表 1。
- 前缀码:每个字符的编码都不是其他字符编码的前缀,这消除了解码时的歧义。
下面的代码演示了哈夫曼树的构建和编码生成过程:
1#include <iostream>
2#include <vector>
3using namespace std;
4
5// 定义一个霍夫曼树节点的结构
6struct HuffmanNode {
7 string huffmanCode; // 存储霍夫曼编码
8 char value; // 存储节点代表的字符(在本例中未使用)
9 int weight; // 节点的权重
10 HuffmanNode *left; // 指向左子节点的指针
11 HuffmanNode *right; // 指向右子节点的指针
12};
13
14// 创建霍夫曼树
15HuffmanNode *huffmanTreeCreate(vector<HuffmanNode *> huffmanTreeNode) {
16 int times = huffmanTreeNode.size() - 1;
17 for (int i = 0; i < times; i++) {
18 int min1Idx = -1;
19 int min2Idx = -1;
20 // 找到权重最小的两个节点
21 for (int j = 0; j < huffmanTreeNode.size(); j++) {
22 if (min1Idx == -1 || huffmanTreeNode[j]->weight < huffmanTreeNode[min1Idx]->weight) {
23 min2Idx = min1Idx;
24 min1Idx = j;
25 } else if (min2Idx == -1 || huffmanTreeNode[j]->weight < huffmanTreeNode[min2Idx]->weight) {
26 min2Idx = j;
27 }
28 }
29 // 创建一个新节点,其子节点为找到的两个最小权重节点
30 HuffmanNode *node = new HuffmanNode;
31 node->left = huffmanTreeNode[min1Idx];
32 node->right = huffmanTreeNode[min2Idx];
33 node->weight = node->left->weight + node->right->weight; // 新节点的权重为两个子节点权重之和
34 // 从列表中移除已经使用的两个节点
35 if (min1Idx > min2Idx) {
36 huffmanTreeNode.erase(huffmanTreeNode.begin() + min1Idx);
37 huffmanTreeNode.erase(huffmanTreeNode.begin() + min2Idx);
38 } else {
39 huffmanTreeNode.erase(huffmanTreeNode.begin() + min2Idx);
40 huffmanTreeNode.erase(huffmanTreeNode.begin() + min1Idx);
41 }
42 huffmanTreeNode.push_back(node); // 将新节点加入列表中
43 }
44 return huffmanTreeNode[0]; // 返回霍夫曼树的根节点
45}
46
47// 使用中序遍历打印霍夫曼树的节点权重
48void inOrder(HuffmanNode *node) {
49 if (!node) {
50 return;
51 }
52 inOrder(node->left);
53 cout << node->weight << endl;
54 inOrder(node->right);
55}
56
57// 为霍夫曼树中的每个叶子节点生成霍夫曼编码
58void huffmanCoding(HuffmanNode *node, string code) {
59 if (!node->left && !node->right) {
60 cout << node->weight << " " << code << endl; // 打印叶子节点的权重和编码
61 return;
62 }
63 // 递归为左子树生成编码,编码加'0'
64 huffmanCoding(node->left, code + '0');
65 // 递归为右子树生成编码,编码加'1'
66 huffmanCoding(node->right, code + '1');
67}
68
69int main() {
70 int n = 0;
71 cin >> n; // 读取节点数量
72 vector<HuffmanNode *> huffmanTreeNode; // 存储所有节点的向量
73 for (int i = 0; i < n; i++) {
74 HuffmanNode *node = new HuffmanNode;
75 cin >> node->weight; // 读取节点的权重
76 node->left = NULL;
77 node->right = NULL;
78 huffmanTreeNode.push_back(node); // 将节点加入向量
79 }
80 HuffmanNode *root = huffmanTreeCreate(huffmanTreeNode); // 创建霍夫曼树
81 // inOrder(root); // 使用中序遍历打印树的节点权重(如果需要的话)
82 huffmanCoding(root, ""); // 为霍夫曼树生成编码
83 return 0;
84}✨ 执行示例
下面用一个简单的例子,完整展示哈夫曼树的构建和编码生成过程。
字符频率: A:5, B:2, C:1, D:1, E:3
第 1 步:初始化 将所有字符作为叶节点放入优先队列(按频率排序):
优先队列:[C:1, D:1, B:2, E:3, A:5]第 2 步:合并 C(1) 和 D(1)
- 取出最小的两个:C(1) 和 D(1)
- 创建新节点,权值 = 1+1 = 2
- 放回队列
优先队列:[B:2, CD:2, E:3, A:5]
2
/ \
C:1 D:1第 3 步:合并 B(2) 和 CD(2)
- 取出 B(2) 和 CD(2)
- 创建新节点,权值 = 2+2 = 4
- 放回队列
1优先队列:[E:3, BCD:4, A:5]
2 4
3 / \
4 B:2 2
5 / \
6 C:1 D:1第 4 步:合并 E(3) 和 BCD(4)
- 取出 E(3) 和 BCD(4)
- 创建新节点,权值 = 3+4 = 7
- 放回队列
1优先队列:[A:5, EBCD:7]
2 7
3 / \
4 E:3 4
5 / \
6 B:2 2
7 / \
8 C:1 D:1第 5 步:合并 A(5) 和 EBCD(7)
- 取出 A(5) 和 EBCD(7)
- 创建根节点,权值 = 5+7 = 12
112
2 / \
3 A:5 7
4 / \
5 E:3 4
6 / \
7 B:2 2
8 / \
9 C:1 D:1第 6 步:生成编码(左 0 右 1)
| 字符 | 路径 | 编码 | 编码长度 |
|---|---|---|---|
| A | 左 | 0 | 1 |
| E | 右左 | 10 | 2 |
| B | 右右左 | 110 | 3 |
| C | 右右右左 | 1110 | 4 |
| D | 右右右右 | 1111 | 4 |
WPL(加权路径长度)计算: WPL = 51 + 32 + 23 + 14 + 1*4 = 5 + 6 + 6 + 4 + 4 = 25
频率高的字符(A:5)获得了最短的编码(1位),频率低的字符(C:1, D:1)获得了最长的编码(4位),这就是哈夫曼编码实现压缩的原理。
✨ 编码和解码
- 编码:根据哈夫曼树,将文本转换为一串二进制码。
- 解码:从二进制码开始,沿着哈夫曼树到达叶节点,找到对应的字符。
哈夫曼编码是一种有效的压缩方法,特别是对于字符分布不均的文本数据。通过这种方法,频繁出现的字符使用较短的码,而不常出现的字符使用较长的码,从而实现压缩。
✨ 应用场景
哈夫曼编码在多种文件压缩格式和数据传输协议中得到了广泛应用。其主要优点是能够根据实际字符出现的频率动态地调整编码长度,使得常用字符使用更短的编码,从而达到压缩数据的目的。
编码案例
假设我们有一个字符串 "this is an example of a huffman tree",我们将基于这个字符串中字符的频率来构建哈夫曼树,并生成哈夫曼编码。
步骤 1:统计字符频率
首先,统计字符串中每个字符的出现频率:
| 字符 | 频率 |
|---|---|
| ' ' | 7 |
| 'e' | 4 |
| 'a' | 3 |
| 'h' | 2 |
| 'i' | 2 |
| 'm' | 2 |
| 'n' | 2 |
| 's' | 2 |
| 't' | 2 |
| 'f' | 2 |
| 'x' | 1 |
| 'l' | 1 |
| 'o' | 1 |
| 'p' | 1 |
| 'r' | 1 |
| 'u' | 1 |
步骤 2:构建哈夫曼树
使用以上字符及其频率,按照哈夫曼算法构建哈夫曼树:
- 将所有字符作为叶节点,根据其频率进行排序。
- 选择两个频率最低的节点,创建一个新的内部节点作为它们的父节点,该父节点的频率是这两个节点的频率之和。
- 将这个新节点添加到列表中,重新排序。
- 重复步骤 2 和 3,直到只剩下一个节点,这个节点成为树的根节点。
假设合并顺序如下:
- 合并 ('x', 1) 和 ('l', 1) → ('xl', 2)
- 合并 ('o', 1) 和 ('p', 1) → ('op', 2)
- 合并 ('r', 1) 和 ('u', 1) → ('ru', 2)
- 合并 ('xl', 2) 和 ('op', 2) → ('xlop', 4)
- 合并 ('ru', 2) 和 ('h', 2) → ('ruh', 4)
- 合并 ('i', 2) 和 ('m', 2) → ('im', 4)
- 合并 ('n', 2) 和 ('s', 2) → ('ns', 4)
- 合并 ('t', 2) 和 ('f', 2) → ('tf', 4)
- 合并 ('xlop', 4) 和 ('ruh', 4) → ('xlopruh', 8)
- 合并 ('im', 4) 和 ('ns', 4) → ('imns', 8)
- 合并 ('tf', 4) 和 ('a', 4) → ('tfa', 8)
- 合并 ('e', 4) 和 (' ', 7) → ('e ', 11)
- 合并 ('xlopruh', 8) 和 ('imns', 8) → ('xlopruhimns', 16)
- 合并 ('tfa', 8) 和 ('e ', 11) → ('tfae ', 19)
- 合并 ('xlopruhimns', 16) 和 ('tfae ', 19) → ('root', 35)
步骤 3:生成哈夫曼编码
从根节点开始,向左走记录 "0",向右走记录 "1",为每个叶节点(字符)生成一串编码序列。这些编码具有前缀自由性,即没有任何编码是另一个编码的前缀。
根据构建的哈夫曼树生成了以下编码:
| 字符 | 编码 |
|---|---|
| ' ' | 111 |
| 'e' | 110 |
| 'a' | 101 |
| 'h' | 0011 |
| 'i' | 0100 |
| 'm' | 0101 |
| 'n' | 0110 |
| 's' | 0111 |
| 't' | 1000 |
| 'f' | 1001 |
| 'x' | 00000 |
| 'l' | 00001 |
| 'o' | 00010 |
| 'p' | 00011 |
| 'r' | 00100 |
| 'u' | 00101 |
使用这些编码,原始字符串 "this is an example of a huffman tree" 可以被编码为一串二进制数字,大大减少了存储空间的需求。
✨ 解题步骤详解
遇到哈夫曼编码相关问题时,按以下步骤操作:
步骤一:统计频率 遍历输入数据,统计每个字符出现的频率。可以用数组或 map 存储。
步骤二:构建哈夫曼树
- 将所有字符及其频率放入最小堆(priority_queue + greater)。
- 循环 n-1 次(n 为不同字符数):每次取出两个最小节点,合并,放回堆。
- 最后堆中剩余的唯一节点就是根。
步骤三:生成编码 从根节点出发,递归遍历整棵树:
- 往左走时在当前编码后追加 '0'
- 往右走时在当前编码后追加 '1'
- 到达叶子节点时,记录该字符的完整编码
步骤四:编码/解码
- 编码:将原文中每个字符替换为其哈夫曼编码
- 解码:从根开始,读一个 bit 走一步,到达叶子就输出对应字符,然后回到根继续
✨ 常见错误
- 每次合并后忘记将新节点放回队列:合并两个节点后,必须把新的父节点重新插入优先队列中参与后续比较。
- 使用最大堆而非最小堆:哈夫曼算法每次要取最小的两个节点。使用
priorityqueue默认是最大堆,必须改用priorityqueue。, greater > - WPL 计算错误:WPL = 各叶子节点的权值 * 其到根的路径长度之和,不是所有节点权值的简单求和。
- 只有一个字符时的特殊处理:如果只有一种字符,哈夫曼树只有一个叶子节点,其编码应设为 "0"(不能为空串)。
- 混淆"前缀码"的含义:哈夫曼编码是前缀码,即没有任何编码是另一个编码的前缀。这保证了解码的唯一性,不需要分隔符。