哈夫曼树与哈夫曼编码
较难6哈夫曼树与哈夫曼编码:帮文件瘦身的秘密武器
你有没有想过,电脑里的压缩包(比如 .zip、.rar)为什么能把文件变小?哈夫曼编码就是其中一种核心技术。它像一位聪明的“管家”,给经常出现的字符分配短编码,给不常出现的字符分配长编码,这样整个文件存储起来最省空间。而背后支撑它的数据结构,就是哈夫曼树(最优二叉树)。
带权路径长度:哈夫曼树的“斤斤计较”
在学习如何构造哈夫曼树之前,我们需要先理解一个核心指标——带权路径长度(WPL,Weighted Path Length)。
- 权值:可以理解为每个节点的“重量”,比如字符出现的次数、物体质量等。
- 路径长度:从根节点到某个节点的边数。
- 带权路径长度:所有叶子节点的权值 × 它的路径长度,再加起来。
哈夫曼树的目标就是让整个树的 WPL 最小。好比你要给几个朋友送礼物,他们离你家距离不同,你想让跑的总路程最短,那就要优先给近的朋友送重礼物,远的送轻礼物——这就是“带权路径”的思想。
举个例子:有3个叶子节点,权值分别是 {2, 3, 5},下面两棵树的 WPL 明显不同:
(10) (10)
/ \ / \
(5) (5) (2) (8)
/ \ / \
(2) (3) (3) (5)
- 左边树:叶子2的路径长度为2,叶子3为2,叶子5为1 → WPL = 2×2 + 3×2 + 5×1 = 15
- 右边树:叶子2的路径长度1,叶子3为2,叶子5为2 → WPL = 2×1 + 3×2 + 5×2 = 18
显然左边更优。哈夫曼树一定能构造出 WPL 最小的树。
步步为营:如何构造哈夫曼树
构造过程就像玩“合并积木”游戏。我们每次挑出最轻的两块积木,把它们粘在一起,新积木的重量是两者之和,继续重复,直到只剩一块。
看一个具体例子
假设我们有5个字符及其出现次数(权值):
| 字符 | A | B | C | D | E |
|---|---|---|---|---|---|
| 权值 | 5 | 9 | 12 | 13 | 16 |
第一步:把每个字符看作一个孤立的节点(叶子)。
第二步:挑出最小的两个权值:5 (A) 和 9 (B)。合并成新节点,权值 = 5+9=14,作为它们的父节点。现在集合变为:{12 (C), 13 (D), 16 (E), 14 (新节点)}。
第三步:再挑最小的两个:12 (C) 和 13 (D),合并成权值=25的新节点。集合:{16 (E), 14, 25}。
第四步:挑最小的两个:14 和 16,合并成权值=30的新节点。集合:{25, 30}。
第五步:最后合并25和30,得到根节点权值=55。构造完成。
最终树形(手动绘制):
(55)
/ \
(25) (30)
/ \ / \
(12) (13)(14) (16)
C D / \
(5) (9)
A B
注意:合并的顺序不唯一(比如先合C和D还是先合A和B?),但结果树的 WPL 唯一最小。
从树到编码:左0右1
哈夫曼树建好后,我们就可以给每个叶子节点(字符)分配一个唯一的二进制编码了。
规则:
- 从根节点出发,走到左子树记作
0,走到右子树记作1。 - 从根到叶子的所有 0/1 序列,就是这个叶子(字符)的哈夫曼编码。
以上面那棵树为例:
- 走到A的路径:根→(30)→(14)→左→(5?) 注意:A对应权值5,是从14的左子到达的?不对,检查树:14的左子是5(A),右子是9(B)。所以从根:右→左→左 = 110?不对,我们明确一下:根左是25,根右是30。30的左是14,14的左是5(A),14的右是9(B)。所以A的路径:根右(1) → 30左(0) → 14左(0) → 编码“100”?更准确:路径=根→右(1)→左(0)→左(0) => “100”。类似地,B:根右(1)→左(0)→右(1) => “101”。C:根左(0)→左(0) => “00”。D:根左(0)→右(1) => “01”。E:根右(1)→右(1) => “11”。
可见,全编码为:
A: 100
B: 101
C: 00
D: 01
E: 11
(这和原代码运行结果“A : 011”不同,是因为原代码中构造顺序不同导致编码不同,但都是合法的哈夫曼编码,WPL相同。)
重要性质:哈夫曼编码是前缀编码,任何一个字符的编码不会是另一个字符编码的前缀。这样解码时不会产生歧义(比如“00”是C,“000”就可能混淆,但哈夫曼编码避免这一点)。
生活中的哈夫曼思想
除了摩斯电码,还有不少例子:
- 学校成绩评定:如果90分以上(A)占比很多,85-89(B)稍少… 你给等级分配字母时,A用“A”一个字符,B用“B”一个字符,而E(不及格)用“E”一个字符,这没压缩。但如果用二进制传输成绩,给A分配“0”,B分配“10”,C分配“110”等,就是哈夫曼思想。
- 自助餐厅取餐盘:常吃的菜(米饭)放最近,不常吃的(蜗牛)放最远,节省来回走的路程——相当于用短路径给高频率食物。
- 微信表情包:常用的表情(笑、哭)用很少的字节存储,冷门表情用较多字节,整体压缩包更小。
完整可运行的C++代码示例
下面是一个完整的程序,包含:
- 从用户输入读取字符及其频率
- 构建哈夫曼树
- 输出每个字符的编码
- 计算并输出压缩前的总长度和压缩后的总长度(WPL)
- 释放所有动态分配的内存(避免内存泄漏)
#include <iostream>
#include <queue>
#include <vector>
#include <string>
#include <iomanip>
using namespace std;
// 哈夫曼树节点
struct HuffmanNode {
char ch; // 字符(内部节点设为 '\0')
int freq; // 频率(权值)
HuffmanNode *left, *right; // 左右子节点指针
HuffmanNode(char c, int f) : ch(c), freq(f), left(nullptr), right(nullptr) {}
};
// 用于最小堆的比较器:频率小的优先级高
struct Compare {
bool operator()(HuffmanNode* a, HuffmanNode* b) {
return a->freq > b->freq; // 注意:最小堆需要 greater,即a.freq > b.freq时a优先级低
}
};
// 递归生成编码,存入vector
// root: 当前节点,code: 当前路径编码,codes: 存放结果的数组(下标为字符ASCII码)
void getCodes(HuffmanNode* root, string code, vector<string>& codes) {
if (!root) return;
// 如果是叶子节点(有实际字符),记录编码
if (!root->left && !root->right) {
codes[root->ch] = code;
return;
}
getCodes(root->left, code + "0", codes);
getCodes(root->right, code + "1", codes);
}
// 递归释放整棵树的内存
void deleteTree(HuffmanNode* root) {
if (!root) return;
deleteTree(root->left);
deleteTree(root->right);
delete root;
}
int main() {
int n; // 字符个数
cout << "请输入字符个数: ";
cin >> n;
vector<char> chars(n); // 存储字符
vector<int> freqs(n); // 存储对应频率
cout << "请输入每个字符及其频率(例如 A 5):" << endl;
for (int i = 0; i < n; i++) {
cin >> chars[i] >> freqs[i];
}
// 使用优先队列(最小堆)存储节点指针
priority_queue<HuffmanNode*, vector<HuffmanNode*>, Compare> pq;
for (int i = 0; i < n; i++) {
pq.push(new HuffmanNode(chars[i], freqs[i]));
}
// 构建哈夫曼树:不断取出两个最小节点合并
while (pq.size() > 1) {
HuffmanNode* left = pq.top(); pq.pop(); // 最小权值的节点
HuffmanNode* right = pq.top(); pq.pop(); // 次小权值的节点
// 创建父节点,权值为两者之和,字符设为'\0'表示内部节点
HuffmanNode* parent = new HuffmanNode('\0', left->freq + right->freq);
parent->left = left;
parent->right = right;
pq.push(parent);
}
HuffmanNode* root = pq.top(); // 最终根节点
// 生成编码(ASCII可打印字符范围通常0~127,这里分配128就足够)
vector<string> codes(128, "");
getCodes(root, "", codes);
// 输出每个字符的编码
cout << "\n字符和对应的哈夫曼编码:" << endl;
for (int i = 0; i < n; i++) {
cout << chars[i] << " : " << codes[chars[i]] << endl;
}
// 计算压缩前后的总比特数(假设原来每个字符用8位固定编码)
int originalTotal = 0, compressedTotal = 0;
for (int i = 0; i < n; i++) {
originalTotal += freqs[i] * 8; // 原编码8位
compressedTotal += freqs[i] * codes[chars[i]].size(); // 哈夫曼编码长度
}
cout << "\n原始总比特数: " << originalTotal << endl;
cout << "哈夫曼压缩后总比特数: " << compressedTotal << endl;
cout << "压缩率: " << fixed << setprecision(2)
<< (double)compressedTotal / originalTotal * 100 << "%" << endl;
// 释放所有动态内存
deleteTree(root);
return 0;
}
运行示例(输入上面5个字符):
请输入字符个数: 5
请输入每个字符及其频率(例如 A 5):
A 5
B 9
C 12
D 13
E 16
字符和对应的哈夫曼编码:
A : 100
B : 101
C : 00
D : 01
E : 11
原始总比特数: 440
哈夫曼压缩后总比特数: 131
压缩率: 29.77%
(注意:编码结果和之前手动推算的可能不同,因为合并顺序导致树形不同,但WPL都是131,压缩效果相同。)
新手容易犯的错误
-
优先队列的比较器写反
很多同学把Compare写成return a->freq < b->freq;这样就成了最大堆(根节点是最大元素),构建出来的树是“最差霍夫曼树”,编码长度反而更长。正确写法:return a->freq > b->freq;。 -
混淆内部节点和叶子节点
内部节点(合并后创建的父节点)没有字符,需要设为特殊值(如'\0'),不能误当作叶子节点。在getCodes中要正确判断是否叶子:!root->left && !root->right。 -
编码长度过长导致溢出
理论上哈夫曼编码长度不会超过字符总数-1,但若输入频率分布极不均匀(比如一个字符占99%),编码长度可能很长。在竞赛中一般不会超出int范围,但注意 vector 下标如果是char类型,可能会遇到负数(若字符是扩展ASCII),建议用unsigned char或直接映射到0~255。 -
忘记释放内存
竞赛中通常不在乎内存泄漏,但实际开发或项目要求中必须释放。上面的代码已经用deleteTree递归释放了。 -
对相同频率的节点合并顺序不敏感
当两个节点频率相同时,合并顺序不影响WPL,但会影响编码结果。这是允许的,最终得到的编码都是前缀编码,且总长度最小。
相关指引(学完哈夫曼树之后)
- 贪心算法:哈夫曼树是经典的贪心策略(每次选最小的两个合并),可以学习其他贪心问题(活动选择、背包等)。
- 优先队列:掌握
priority_queue的用法,特别是自定义比较器。 - 前缀编码:哈夫曼编码是前缀码的一种,其他还有固定长度编码、算术编码等。
- 数据压缩应用:ZIP、GZIP、PNG、JPEG 中都使用了哈夫曼编码或其变种(如动态哈夫曼编码)。
- 信息论:香农提出信源编码理论,哈夫曼编码接近理论上的最优——熵(信息量的期望值)。
尝试自行实现:给定一段文本,统计字符频率,用哈夫曼编码压缩并还原(解压)。你会发现压缩的原理其实并不神秘!
例题精讲
在哈夫曼树中,权值较大的叶子节点相对于权值较小的叶子节点,其深度(即编码长度)通常如何?
哈夫曼编码是一种前缀编码,即任何一个字符的编码都不是另一个字符编码的前缀。
以下函数用于计算一组权值的哈夫曼树带权路径长度(WPL),请在横线处填入正确的表达式。
int huffmanWPL(vector<int>& weights) {
priority_queue<int, vector<int>, greater<int>> pq;
for (int w : weights) pq.push(w);
int wpl = 0;
while (pq.size() > 1) {
int a = pq.top(); pq.pop();
int b = pq.top(); pq.pop();
int sum = a + b;
wpl += ___; // 填空
pq.push(sum);
}
return wpl;
}给定字符频率:A:1, B:2, C:3, D:4, E:5,则权值最大的字符E的哈夫曼编码长度是多少?(假设根节点深度为0)
在哈夫曼树中,叶子节点的个数等于内部节点个数加1。