📖 引入故事:电报里的“省字诀”
早年发电报按字收费。英文里 e 出现得比 z 频繁得多——如果让常见的字母用“短码”、罕见的字母用“长码”,整段报文就能变短、省钱。问题来了:怎样给每个字母分配长短不一的 0/1 串,才能既最短、又能无歧义还原?
这正是最优二叉树(哈夫曼树)要解决的问题:它给频率高的字母“走近路”(短编码),频率低的字母“走远路”(长编码),且保证任何编码都不是别的编码的前缀。
掌握带权路径长度 WPL、哈夫曼树的“合并最小两个”构造法,以及左0右1得到的前缀编码与无歧义解码。
从“如何把一段文字压到最小”出发,理解哈夫曼树为什么是最优二叉树,以及哈夫曼编码怎样做到既短又不冲突。
早年发电报按字收费。英文里 e 出现得比 z 频繁得多——如果让常见的字母用“短码”、罕见的字母用“长码”,整段报文就能变短、省钱。问题来了:怎样给每个字母分配长短不一的 0/1 串,才能既最短、又能无歧义还原?
这正是最优二叉树(哈夫曼树)要解决的问题:它给频率高的字母“走近路”(短编码),频率低的字母“走远路”(长编码),且保证任何编码都不是别的编码的前缀。
小区里有一栋住 100 户的大楼和几栋只住 1 户的小楼。若给大楼编“短门牌”(如 1 号),给小楼编“长门牌”(如 1001 号),邮递员平均每天走的“编号长度”最短。哈夫曼树就是把“频率”当作“户数”,给高频字符编短码,从而让平均编码长度最小。
关键约束:短码不能是长码的开头(否则“11”到底指 1+1 还是 11?),专业说法叫前缀码。
带权路径长度(WPL):设叶子结点 i 的权值为 wi、深度为 di,则 WPL = Σ wi·di。权值常表示“出现频率”,深度就是“编码长度”。
哈夫曼树(Huffman Tree):在给定 n 个权值的二叉树中,WPL 最小的那棵,也叫“最优二叉树”。
哈夫曼编码:对哈夫曼树从根到叶子,左分支记 0、右分支记 1,叶子上的字符得到的 0/1 串就是它的哈夫曼编码。它一定是前缀码(不会有编码是另一编码的前缀)。
“最小两个先合并,层层往上建树棵;左0右1到叶子,高频短码最省得;前缀无歧义,解码不认错。”
| 知识点 | 你要掌握 | 难度 |
|---|---|---|
| 带权路径长度 WPL | WPL = Σ wᵢ·dᵢ | ★ |
| 哈夫曼树(最优二叉树) | 使 WPL 最小的二叉树 | ★★ |
| 构造算法(贪心) | 每次合并权值最小的两棵子树 | ★★ |
| 哈夫曼编码 | 左0右1,得到前缀码 | ★★ |
| 前缀码与无歧义解码 | 编码互不为前缀 → 沿树唯一还原 | ★★ |
| 应用(拓展) | 文件压缩、合并果子(优先队列) | ★★★ |
先搞懂 WPL,再看哈夫曼树如何“合并最小两个”造出来,最后由树得到前缀编码并验证能无歧义解码。
给一棵树上的叶子标上“权值”(频率),每个叶子到根要经过的边数叫“深度”。带权路径长度 WPL = 所有叶子的“权值 × 深度”之和。
直觉:权值大的叶子如果离根近(深度小),乘积就小,WPL 就小。哈夫曼树的目标,就是让 WPL 达到最小。
构造思想非常“贪心”:把 n 个权值看成 n 棵只有根(叶子)的树,每次选出权值最小的两棵合并成一棵新树(新根权值 = 两子树权值之和),直到只剩一棵树。这样反复合并,得到的就是 WPL 最小的哈夫曼树。
上图中:先合并 5 和 9 得 14,再合 12、13 得 25……最终根权值 100。每个内部结点都等于其两棵子树权值之和,而 所有内部结点权值加起来正好等于 WPL。
把权值放进小根堆(优先队列),每次取两个最小的合并,把新权值放回堆,直到堆里只剩一个。下面用 6 个字符频率 A5 B9 C12 D13 E16 F45 演示,并累加内部结点权值得到 WPL。
#include <iostream>
#include <queue>
#include <vector>
using namespace std;
int main() {
// 6 个字符的出现频率(权重)
long long w[] = {5, 9, 12, 13, 16, 45};
int n = 6;
priority_queue<long long, vector<long long>, greater<long long>> pq; // 小根堆
for (int i = 0; i < n; i++) pq.push(w[i]);
long long wpl = 0;
cout << "合并过程(每次取两个最小权重的结点合并):" << endl;
while (pq.size() > 1) {
long long a = pq.top(); pq.pop();
long long b = pq.top(); pq.pop();
long long s = a + b;
cout << a << " + " << b << " = " << s << endl;
wpl += s; // 每次合并产生的新结点权重,累加起来就是 WPL
pq.push(s);
}
cout << "带权路径长度 WPL = " << wpl << endl;
return 0;
}
合并过程(每次取两个最小权重的结点合并):
5 + 9 = 14
12 + 13 = 25
14 + 16 = 30
25 + 30 = 55
45 + 55 = 100
带权路径长度 WPL = 224每次合并产生的新结点权重累加起来 = 14 + 25 + 30 + 55 + 100 = 224,这就是 WPL。合并顺序不唯一,但 WPL 唯一。
从根出发,向左走记 0、向右走记 1,走到叶子就得到该字符的编码。因为编码都在“叶子”结束,没有任何编码是别的编码的前缀——这正是“前缀码”,保证解码不二义。
#include <iostream>
#include <vector>
#include <algorithm>
#include <string>
#include <utility>
using namespace std;
struct Node {
char ch; // 叶子存字符;内部结点存 '\0'
int w;
Node *l, *r;
Node(char c, int ww) : ch(c), w(ww), l(nullptr), r(nullptr) {}
};
// 按权重升序(stable_sort 保证权重相同时顺序稳定 → 输出确定)
bool cmp(Node* a, Node* b) { return a->w < b->w; }
void dfs(Node* u, const string& code, vector<pair<char,string>>& res) {
if (!u) return;
if (u->ch != '\0') { // 叶子:记录 字符 → 哈夫曼编码
res.push_back({u->ch, code});
return;
}
dfs(u->l, code + "0", res); // 左分支记 0
dfs(u->r, code + "1", res); // 右分支记 1
}
int main() {
// 字符与频率
Node* init[] = { new Node('A',5), new Node('B',9), new Node('C',12),
new Node('D',13), new Node('E',16), new Node('F',45) };
vector<Node*> v(init, init + 6);
long long wpl = 0;
while (v.size() > 1) {
stable_sort(v.begin(), v.end(), cmp);
Node* a = v[0]; // 最小
Node* b = v[1]; // 次小
Node* par = new Node('\0', a->w + b->w);
par->l = a; par->r = b;
wpl += par->w; // 内部结点权重累加 = WPL
v.erase(v.begin()); v.erase(v.begin());
v.push_back(par);
}
vector<pair<char,string>> res;
dfs(v[0], "", res);
sort(res.begin(), res.end()); // 按字符排序,输出更整齐
cout << "字符 频率 哈夫曼编码" << endl;
int freq[] = {5,9,12,13,16,45}; // 与 A..F 对应
for (auto& p : res) {
int f = freq[p.first - 'A'];
cout << p.first << " " << f << " " << p.second << endl;
}
cout << "带权路径长度 WPL = " << wpl << endl;
return 0;
}
字符 频率 哈夫曼编码
A 5 1100
B 9 1101
C 12 100
D 13 101
E 16 111
F 45 0
带权路径长度 WPL = 224把结果整理成表,一眼可见:频率最高的 F(45)只用了 1 位 0,频率最低的 A(5)用了 4 位 1100。
| 字符 | 频率 | 哈夫曼编码 | 编码长度 |
|---|---|---|---|
| A | 5 | 1100 | 4 |
| B | 9 | 1101 | 4 |
| C | 12 | 100 | 3 |
| D | 13 | 101 | 3 |
| E | 16 | 111 | 3 |
| F | 45 | 0 | 1 |
收到一串 0/1 后,从根开始:遇到 0 走左、1 走右,走到叶子就译出一个字符并回到根,继续译下一个。因为编码是前缀码,每次都能在唯一位置“停”下来,绝不会译错。
#include <iostream>
#include <vector>
#include <algorithm>
#include <string>
#include <utility>
using namespace std;
struct Node {
char ch;
int w;
Node *l, *r;
Node(char c, int ww) : ch(c), w(ww), l(nullptr), r(nullptr) {}
};
bool cmp(Node* a, Node* b) { return a->w < b->w; }
// 由哈夫曼树生成 字符→编码 映射
void build_code(Node* u, const string& code, vector<pair<char,string>>& mp) {
if (!u) return;
if (u->ch != '\0') { mp.push_back({u->ch, code}); return; }
build_code(u->l, code + "0", mp);
build_code(u->r, code + "1", mp);
}
// 由编码串沿树走回字符
string decode(Node* root, const string& bits) {
string out;
Node* cur = root;
for (char c : bits) {
cur = (c == '0') ? cur->l : cur->r;
if (cur->ch != '\0') { out += cur->ch; cur = root; }
}
return out;
}
int main() {
Node* init[] = { new Node('A',5), new Node('B',9), new Node('C',12),
new Node('D',13), new Node('E',16), new Node('F',45) };
vector<Node*> v(init, init + 6);
while (v.size() > 1) {
stable_sort(v.begin(), v.end(), cmp);
Node* a = v[0], *b = v[1];
Node* par = new Node('\0', a->w + b->w);
par->l = a; par->r = b;
v.erase(v.begin()); v.erase(v.begin()); v.push_back(par);
}
vector<pair<char,string>> mp;
build_code(v[0], "", mp);
sort(mp.begin(), mp.end());
string msg = "ABFCADE"; // 待压缩的报文
string bits;
for (char c : msg)
for (auto& p : mp) if (p.first == c) { bits += p.second; break; }
cout << "原报文: " << msg << endl;
cout << "哈夫曼编码: " << bits << endl;
cout << "解码结果: " << decode(v[0], bits) << endl;
return 0;
}
原报文: ABFCADE
哈夫曼编码: 1100110101001100101111
解码结果: ABFCADE报文 ABFCADE 被压缩成 1100110101001100101111(22 位);若用定长 3 位编码需要 6×3=18 位、用 ASCII 更要 48 位。哈夫曼编码在此更省,且解码后完全还原。
三道分析编程题:换一组权值求 WPL、构造并输出编码、编码解码验证可逆。均给出可编译运行的参考实现。
直接套用“小根堆合并”思路,把每次合并的新权值累加即可。
#include <iostream>
#include <queue>
#include <vector>
using namespace std;
int main() {
long long w[] = {3, 5, 7, 9, 11, 13}; // 新的权值集合
int n = 6;
priority_queue<long long, vector<long long>, greater<long long>> pq;
for (int i = 0; i < n; i++) pq.push(w[i]);
long long wpl = 0;
while (pq.size() > 1) {
long long a = pq.top(); pq.pop();
long long b = pq.top(); pq.pop();
wpl += a + b; // 每次合并的新结点权重累加
pq.push(a + b);
}
cout << "权值 {3,5,7,9,11,13} 的 WPL = " << wpl << endl;
return 0;
}
权值 {3,5,7,9,11,13} 的 WPL = 119合并顺序:3+5=8,7+8=15,9+11=20,13+15=28,20+28=48 → WPL = 119。
用同样的建树方法,输出每个字符的编码与 WPL,体会“高频短码”。
#include <iostream>
#include <vector>
#include <algorithm>
#include <string>
#include <utility>
using namespace std;
struct Node { char ch; int w; Node *l, *r; Node(char c, int ww) : ch(c), w(ww), l(nullptr), r(nullptr) {} };
bool cmp(Node* a, Node* b) { return a->w < b->w; }
void dfs(Node* u, const string& code, vector<pair<char,string>>& r) {
if (!u) return;
if (u->ch != '\0') { r.push_back({u->ch, code}); return; }
dfs(u->l, code + "0", r); dfs(u->r, code + "1", r);
}
int main() {
Node* init[] = { new Node('A',2), new Node('B',4), new Node('C',5), new Node('D',7) };
vector<Node*> v(init, init + 4);
long long wpl = 0;
while (v.size() > 1) {
stable_sort(v.begin(), v.end(), cmp);
Node* a = v[0]; Node* b = v[1];
Node* par = new Node('\0', a->w + b->w);
par->l = a; par->r = b; wpl += par->w;
v.erase(v.begin()); v.erase(v.begin()); v.push_back(par);
}
vector<pair<char,string>> res; dfs(v[0], "", res); sort(res.begin(), res.end());
cout << "字符 频率 哈夫曼编码" << endl;
int freq[] = {2,4,5,7};
for (auto& p : res) cout << p.first << " " << freq[p.first-'A'] << " " << p.second << endl;
cout << "WPL = " << wpl << endl;
return 0;
}
字符 频率 哈夫曼编码
A 2 110
B 4 111
C 5 10
D 7 0
WPL = 35频率最高的 D(7)得到最短编码 0;WPL = 35。
验证哈夫曼编码的“可逆性”:编码后的 0/1 串能沿树唯一还原成原报文。
#include <iostream>
#include <vector>
#include <algorithm>
#include <string>
#include <utility>
using namespace std;
struct Node { char ch; int w; Node *l, *r; Node(char c, int ww) : ch(c), w(ww), l(nullptr), r(nullptr) {} };
bool cmp(Node* a, Node* b) { return a->w < b->w; }
void build_code(Node* u, const string& code, vector<pair<char,string>>& mp) {
if (!u) return;
if (u->ch != '\0') { mp.push_back({u->ch, code}); return; }
build_code(u->l, code + "0", mp); build_code(u->r, code + "1", mp);
}
string decode(Node* root, const string& bits) {
string out; Node* cur = root;
for (char c : bits) { cur = (c == '0') ? cur->l : cur->r; if (cur->ch != '\0') { out += cur->ch; cur = root; } }
return out;
}
int main() {
Node* init[] = { new Node('A',1), new Node('B',2), new Node('C',3), new Node('D',4) };
vector<Node*> v(init, init + 4);
while (v.size() > 1) {
stable_sort(v.begin(), v.end(), cmp);
Node* a = v[0]; Node* b = v[1];
Node* par = new Node('\0', a->w + b->w);
par->l = a; par->r = b; v.erase(v.begin()); v.erase(v.begin()); v.push_back(par);
}
vector<pair<char,string>> mp; build_code(v[0], "", mp); sort(mp.begin(), mp.end());
string msg = "ABCD"; string bits;
for (char c : msg) for (auto& p : mp) if (p.first == c) { bits += p.second; break; }
cout << "原报文: " << msg << endl;
cout << "哈夫曼编码: " << bits << endl;
cout << "解码结果: " << decode(v[0], bits) << endl;
return 0;
}
原报文: ABCD
哈夫曼编码: 110111100
解码结果: ABCD原报文 ABCD 编码为 110111100,解码后仍然是 ABCD,证明编码无歧义、可逆。
把 WPL、构造步骤、前缀码性质与拓展方向一次性理清。
| 结论 | 说明 |
|---|---|
| WPL | WPL = Σ wᵢ·dᵢ,权值×深度之和 |
| 哈夫曼树 | 使 WPL 最小的二叉树(最优二叉树) |
| 构造 | 每次合并权值最小的两棵子树,新权值 = 两子树之和 |
| WPL 计算 | = 所有“内部结点权值”之和 |
| 编码 | 左0右1;叶子得到编码 |
| 前缀码 | 编码互不为前缀 → 解码唯一、无歧义 |
priority_queue<int, vector<int>, greater<int>> 实现“每次取最小”,是合并果子类题目的标准工具。⚠️ 哈夫曼树的“构造与编码”属 CSP-J 应知;完整文件压缩实现属提高,初赛与复赛一般不要求手写。
5 大易错点 + 速查表 + 笔记卡,考前一眼回顾。
把 WPL 算成“所有结点深度之和”。正确是 只算叶子:WPL = Σ 权值ᵢ × 深度ᵢ。
合并时“取最小两个”写错成“取最大两个”或只取一个。必须每次取权值最小的两棵合并。
算 WPL 时把内部结点权值也当叶子累加,或漏加。简便记法:WPL = 所有内部结点权值之和。
编码左右分支记反(把右记 0)。约定“左0右1”后前后一致即可,但必须统一。
以为哈夫曼编码能“随便译”。解码必须沿同一棵树走;且因是前缀码,每个字符在唯一位置停下,别多读或少读一位。
| 概念 | 要点 |
|---|---|
| WPL | Σ wᵢ·dᵢ(只叶子) |
| 构造 | 最小两棵合并 → 新权值 = 二者之和 |
| WPL 速算 | = 所有内部结点权值之和 |
| 编码 | 左0右1;叶子的 0/1 串 |
| 前缀码 | 编码互不为前缀 → 解码唯一 |
哈夫曼树 = 最优二叉树
① WPL = Σ wᵢ·dᵢ(只算叶子)。
② 构造:每次合并权值最小的两棵子树。
③ WPL = 所有内部结点权值之和。
④ 编码:左0右1,得到前缀码。
⑤ 前缀码 → 沿树唯一解码、无歧义。
A基础 / B提高 / C挑战,难度递增,点击展开答案与解析。
回忆定义即可。
WPL = Σ wᵢ·dᵢ,其中 wᵢ 是叶子权值(常表示频率),dᵢ 是该叶子到根的深度(经过的边数)。WPL 越小,平均编码越短。
思路:WPL = Σ wᵢ·dᵢ:wᵢ 是叶子权值(通常代表出现频率),dᵢ 是该叶子到根的深度(经过的边数)。WPL 越小,平均编码长度越短。
易错:把深度 dᵢ 按“层号从 1 开始”来数,结果整体比“边数”多 1。
每次取最小两个。
2+3=5;4+5=9;5+9=14。内部结点权值 5、9、14。
思路:每轮从当前所有结点中取出权值最小的两个合并成一个新结点(权值为两者之和),再把新结点放回集合,重复直到只剩一个结点。
易错:合并后忘记把新结点放回集合参与后续选取,或只按最初的顺序两两配对而没有每轮重新找最小。
把内部结点权值相加。
WPL = 5+9+14 = 28。每个内部结点权值 = 其下所有叶子权值之和,累加内部结点即把所有叶子的“权值×深度”算了一遍。
思路:WPL = 所有内部结点权值之和 = 5 + 9 + 14 = 28。每个内部结点的权值等于其下所有叶子权值之和,把它们累加,相当于每个叶子都被算了“深度”次。
易错:直接把根的权值 14 当成 WPL,或只把叶子权值相加得 14。
从“编码都在叶子结束”想。
编码只在叶子处结束,任何叶子的编码都不可能是另一叶子编码的前缀。前缀码保证解码时每个字符在唯一位置停下,无二义。
思路:哈夫曼编码只从叶子结束,任一叶子的路径都不可能是另一叶子路径的前缀,因此是前缀码;解码时每个字符都在唯一位置停下,不会产生二义。
易错:以为前缀码的作用是“压缩”,其实它解决的是“解码唯一、无歧义”。
小根堆合并,累加内部结点。
1+2=3;3+3=6;4+5=9;6+9=15。WPL = 3+6+9+15 = 33。
思路:反复取最小的两个权值合并:1+2=3、3+3=6、4+5=9、6+9=15,WPL 等于全部内部结点权值之和 3+6+9+15 = 33。
易错:算出 1+2=3 后忘了它要与原来的 3 一起重新比较排序(两个 3 先配对),树形和 WPL 就会算错。
合并最小两个,建树后左右分支赋值。
3+5=8;7+8=15;9+15=24。一种编码:9→0,7→10,3→110,5→111(具体左右可互换,长度不变)。
思路:合并过程为 3+5=8、7+8=15、9+15=24。编码时从根往下走,走左枝记 0、走右枝记 1,根到该叶子的 0/1 串就是它的编码。
易错:编码方向写反,从叶子往根拼(应是从根到叶子),或把左右约定的 0/1 搞混。
沿前缀码逐位切分。
110→A,10→B,0→C,111→D,原报文 = ABCD。
思路:前缀码解码时从左往右逐位读,一边读一边与编码表匹配,每匹配到一个完整编码就输出对应字符,然后从头重新开始匹配。
易错:贪心地按固定字符宽度切分编码串,而不是按“是否构成某个编码”来判断切分位置。
堆里放权值,循环取最小两个。
把所有权值入小根堆;while(堆大小>1){ a=pop最小;b=pop最小;push(a+b);WPL+=a+b } 最后堆中仅剩根权值,WPL 即所求。
思路:把所有结点权值放进小根堆;每轮弹出最小的两个权值 a、b,把 a+b 压回堆中,同时把 a+b 累加进 WPL;堆里只剩一个结点时结束,累加值即为 WPL。
易错:忘记累加 WPL(或只累加了叶子权值),或弹出两个最小值后没有把新权值压回堆。
从每个叶子被累加的次数入手。
叶子权值 wᵢ 会在它到根路径上每一层合并时被加进一个祖先内部结点,共 dᵢ 次。故所有内部结点权值之和 = Σ wᵢ·dᵢ = WPL。
思路:叶子权值 wᵢ 在它到根的路径上,每经过一个内部结点就会被“合并”加进去一次,一共经历 dᵢ 次;所以这个叶子对“内部结点权值之和”的贡献恰好是 wᵢ·dᵢ。
易错:只陈述结论,没有做“贡献次数正好等于深度 dᵢ”这一步对应,证明就缺了关键环节。
举左右互换的反例。
交换某结点的左右子树,所有相关编码的 0/1 互换,仍合法前缀码且 WPL 不变;故最优编码不唯一,但 WPL 唯一。
思路:交换任意内部结点的左右子树,相关编码的 0/1 位全部互换,仍是合法前缀码且每个叶子的深度不变,故 WPL 相同;因此最优编码不唯一,但 WPL 唯一。
易错:认为“哈夫曼编码是唯一的最优解”,忽略了权值相同时合并顺序不同也能得到不同但等价的树。
复用本讲 CODE_CODE 的结构。
用结点结构存权值与左右指针;每次取两最小合并成新结点,DFS 从根到叶记 0/1,叶子输出编码;累加内部结点得 WPL。
思路:用优先队列每次取两个最小权值合并成新结点(记录好左右孩子);建完树后从根开始 DFS 记录路径上的 0/1,到叶子就输出该字符的编码,同时把每个内部结点权值累加得到 WPL。
易错:建树时只保存权值不保存左右孩子指针,导致最后无法从根遍历生成编码。
先统计频率,再套构造。
用数组/map 统计每个字符出现次数作为权值;建哈夫曼树;由编码表把原串逐字符翻译成 0/1 串输出。
思路:先统计字符串中每个字符出现的次数作为权值,建哈夫曼树并生成“字符 → 编码”表,再按原串顺序逐字符替换拼接,输出整条 0/1 串。
易错:编码时没有保持原字符顺序,或把权值统计成了字符的种类数而不是出现次数。