越常用的字母,路越短——让"总路程"最少的编码树
想象你要设计一座森林里的快递站:5 种包裹(A、B、C、D、E)每天要送进森林深处的仓库,送 A 包裹的次数最多、送 E 的次数最少。修路很贵,所以你想:送得勤的仓库,修短一点的路;送得少的仓库,路长一点也没关系。怎么修才能让"每天跑的总路程"最少?
答案就是哈夫曼树:把每个包裹的"使用次数"(频率)标在树叶上,然后每次把最轻的两捆并成一捆,一直并到只剩一捆。最后得到的树,保证"频率 × 路长"的总和最小!
5 个字母的频率:A=1、B=4、C=6、D=8、E=10。右边是"每次合并最轻两捆"的全过程,左边是最后得到的哈夫曼树。
给字母编码时,每个字母对应树上一个叶子,从根走到这个叶子的路(0 和 1 组成的串)就是它的编码。编码长度 = 路长(深度)。如果一个字母出现 100 次,它的编码就会用上 100 次——所以出现次数多的字母,编码越短越划算。
总代价怎么算?总代价 = 每个字母的(频率 × 编码长度)加起来,数学术语叫加权路径长度 WPL(Weighted Path Length)。
看上图这棵树(A=1、B=4、C=6、D=8、E=10):E 出现最多(10),只走了 2 步;A 出现最少(1),走了 3 步。直觉上很合理,对不对?
把"合并两捆"这件事写成固定五步,闭着眼也能做:
用 A=1、B=4、C=6、D=8、E=10 完整走一遍(和大图右侧一致):
每合并一次,树里就多一层"父子关系":5 是 A、B 的父亲;11 是 5 和 C 的父亲;18 是 D、E 的父亲;29 是 11 和 18 的父亲(根)。5 个字母,合并 4 次——n 个叶子要合并 n−1 次。
正算法:每个叶子的(频率 × 深度)加起来。上图这棵树:A 深度 3、B 深度 3、C 深度 2、D 深度 2、E 深度 2。
WPL = 1×3 + 4×3 + 6×2 + 8×2 + 10×2 = 3 + 12 + 12 + 16 + 20 = 63。
捷径:WPL 还等于所有内部节点(非叶子)的权重之和:5 + 11 + 18 + 29 = 63,一样!
考场上用捷径:建完树后,把内部节点(不含叶子)的数字直接相加就是 WPL,不用一个个数深度。
两段代码:第一段用最小堆直接算 WPL(捷径法);第二段真的把树建出来,再用 BFS 数深度验证。
// 代码 1:最小堆建哈夫曼树,顺手算 WPL(捷径法) #include <bits/stdc++.h> using namespace std; int main() { int freq[] = {1, 4, 6, 8, 10}; // A B C D E int n = 5; priority_queue<int, vector<int>, greater<int>> pq; // 最小堆 for (int i = 0; i < n; i++) pq.push(freq[i]); long long wpl = 0; while (pq.size() > 1) { int a = pq.top(); pq.pop(); // 最轻的两捆 int b = pq.top(); pq.pop(); int s = a + b; // 新节点权重 wpl += s; // 每个内部节点只加一次 = WPL pq.push(s); } cout << "WPL = " << wpl << endl; // 输出:WPL = 63 return 0; }
// 代码 2:真建一棵树,BFS 数每个字母的深度,验证 WPL #include <bits/stdc++.h> using namespace std; struct Node { int w; // 权重 int ch; // 字母编号;-1 表示内部节点 int l, r; // 左右孩子编号;0 表示没有 Node(int weight, int c) : w(weight), ch(c), l(0), r(0) {} }; int main() { int freq[] = {1, 4, 6, 8, 10}; // A B C D E int n = 5; vector<Node> pool; // 节点池,编号 = 下标 // 最小堆存 (权重, 编号),权重相同比编号 priority_queue<pair<int,int>, vector<pair<int,int>>, greater<pair<int,int>>> pq; for (int i = 0; i < n; i++) { pool.push_back(Node(freq[i], i)); pq.push({freq[i], i}); } while (pq.size() > 1) { int a = pq.top().second; pq.pop(); // 较轻的 int b = pq.top().second; pq.pop(); // 较重的 pool.push_back(Node(pool[a].w + pool[b].w, -1)); int id = (int)pool.size() - 1; pool[id].l = a; // 轻的放左、重的放右(约定) pool[id].r = b; pq.push({pool[id].w, id}); } int root = pq.top().second; // BFS 求每个字母深度,累加 频率 × 深度 long long wpl = 0; queue<pair<int,int>> q; // {节点编号, 深度} q.push({root, 0}); while (!q.empty()) { int id = q.front().first, d = q.front().second; q.pop(); if (pool[id].ch >= 0) wpl += 1LL * pool[id].w * d; if (pool[id].l) q.push({pool[id].l, d + 1}); if (pool[id].r) q.push({pool[id].r, d + 1}); } cout << "WPL = " << wpl << endl; // 输出:WPL = 63 return 0; }
greater<int>(默认堆是最大堆,容易忘)。② 代码 2 里 pool 会不断变大,孩子编号必须存编号而不是值。③ 平手时谁左谁右不影响 WPL,但会影响下一课编码的具体 01 串——题目若规定"轻左重右",要照做。