🎒

哈夫曼树

越常用的字母,路越短——让"总路程"最少的编码树

💡 打个比方

想象你要设计一座森林里的快递站:5 种包裹(A、B、C、D、E)每天要送进森林深处的仓库,送 A 包裹的次数最多、送 E 的次数最少。修路很贵,所以你想:送得勤的仓库,修短一点的路;送得少的仓库,路长一点也没关系。怎么修才能让"每天跑的总路程"最少?

答案就是哈夫曼树:把每个包裹的"使用次数"(频率)标在树叶上,然后每次把最轻的两捆并成一捆,一直并到只剩一捆。最后得到的树,保证"频率 × 路长"的总和最小!

哈夫曼树:已知每个叶子的权重(频率),每次合并两棵最轻的树,直到剩一棵——得到的最优二叉树(加权路径长度 WPL 最小)。它是"哈夫曼编码"的地基:常用字母码短、生僻字母码长。

🖼️ 大图看明白

5 个字母的频率:A=1、B=4、C=6、D=8、E=10。右边是"每次合并最轻两捆"的全过程,左边是最后得到的哈夫曼树。

结果:哈夫曼树 29 11 18 5 C(6) D(8) E(10) A(1) B(4) 橙色 = 叶子(字母);内部节点 = 子树频率之和 建树:每次合并最轻的两捆 第 1 步:队列 {1, 4, 6, 8, 10},挑最小的 1 和 4 合并成 5,放回 → 队列 {5, 6, 8, 10} 第 2 步:队列 {5, 6, 8, 10},挑最小的 5 和 6 合并成 11,放回 → 队列 {8, 10, 11} 第 3 步:队列 {8, 10, 11},挑最小的 8 和 10 合并成 18,放回 → 队列 {11, 18} 第 4 步:队列 {11, 18},挑最小的 11 和 18 合并成 29 → 队列只剩一个,完成! 每合并一次 = 树里多一个"父亲";最后 29 是根

🧩 子任务 1:为什么要"常用走近路"?

🎯 小目标:理解"总代价 = 频率 × 路长",明白哈夫曼树到底在优化什么

给字母编码时,每个字母对应树上一个叶子,从根走到这个叶子的路(0 和 1 组成的串)就是它的编码。编码长度 = 路长(深度)。如果一个字母出现 100 次,它的编码就会用上 100 次——所以出现次数多的字母,编码越短越划算。

总代价怎么算?总代价 = 每个字母的(频率 × 编码长度)加起来,数学术语叫加权路径长度 WPL(Weighted Path Length)。

哈夫曼树的任务:给定每个字母的频率,造一棵二叉树,让 WPL 最小。贪心策略:频率小的字母放得深(路长),频率大的字母放得浅(路短)——而且"每次合并当前最小的两棵"这个贪心,被证明能全局最优!

看上图这棵树(A=1、B=4、C=6、D=8、E=10):E 出现最多(10),只走了 2 步;A 出现最少(1),走了 3 步。直觉上很合理,对不对?

✅ 小检查:如果 B 和 C 的位置互换(B 放深、C 放浅),WPL 会变大还是变小?
看看答案
变大。B(4)从深变浅省 4×1,C(6)从浅变深亏 6×1,净亏 2。规律:频率大的放浅处才划算,哈夫曼树正是这样安排的。

🧩 子任务 2:建树五步曲

🎯 小目标:能独立走完"选最小两个 → 合并 → 放回 → 重复 → 成树"全过程

把"合并两捆"这件事写成固定五步,闭着眼也能做:

  1. 排队:把所有字母按频率从小到大站好队(这就是"最小堆/优先队列",GESP5 学过)。
  2. 挑两个:从队头挑出最轻的两捆。
  3. 捆一起:造一个新节点当它们的父亲,新节点重量 = 两者之和。
  4. 放回队:新节点按大小插回队列。
  5. 重复:回到第 2 步,直到队列只剩一捆——它就是根,树建成了。

用 A=1、B=4、C=6、D=8、E=10 完整走一遍(和大图右侧一致):

  1. 队 {1,4,6,8,10} → 挑 1、4 → 新节点 5 → 队 {5,6,8,10}
  2. 队 {5,6,8,10} → 挑 5、6 → 新节点 11 → 队 {8,10,11}
  3. 队 {8,10,11} → 挑 8、10 → 新节点 18 → 队 {11,18}
  4. 队 {11,18} → 挑 11、18 → 新节点 29 → 队 {29},完成

每合并一次,树里就多一层"父子关系":5 是 A、B 的父亲;11 是 5 和 C 的父亲;18 是 D、E 的父亲;29 是 11 和 18 的父亲(根)。5 个字母,合并 4 次——n 个叶子要合并 n−1 次。

易错点:① 每次挑的是当前队列里最小的两个,不是最初最小的两个(第 2 步挑 5、6 而不是 4、6!4 已经并进 5 了)。② 新节点也要放回队列继续参与竞争,不能合并完就"退休"。③ 平手时任选一个先走,不影响 WPL。
✅ 小检查:6 个字母要合并几次?合并过程中一共会出现几个"新节点"?
看看答案
合并 n−1 = 5 次;新节点也是 5 个(每个新节点 = 一次合并的产物,它们最后都成了树里的内部节点)。

🧩 子任务 3:WPL 怎么算?

🎯 小目标:会算 WPL,并会用"内部节点和"捷径快速验证

正算法:每个叶子的(频率 × 深度)加起来。上图这棵树: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,不用一个个数深度。

✅ 小检查:若某哈夫曼树内部节点权重为 3、7、12、20,WPL 是多少?
看看答案
WPL = 3 + 7 + 12 + 20 = 42。内部节点之和 = 加权路径长度,一步到位。

💻 完整代码

两段代码:第一段用最小堆直接算 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 串——题目若规定"轻左重右",要照做。

✋ 你来试试

第 1 题:4 个字母 X=2、Y=3、Z=5、W=9,合并几次?WPL 是多少?
合并 n−1 = 3 次:2+3=5 → {5,5,9};5+5=10 → {9,10};9+10=19。WPL = 5+10+19 = 34(捷径法直接加内部节点:5、10、19)。
第 2 题:哈夫曼树里,频率最高的字母编码长还是频率最低的字母编码长?为什么?
频率最高的编码短(放得浅),频率最低的编码长(放得深)。因为每次合并都把最轻的往深处推,让总代价 频率×路长 最小。
第 3 题:一棵有 7 个叶子(7 个字母)的哈夫曼树,内部节点有几个?一共合并几次?
内部节点 = 7−1 = 6 个,合并 6 次。每次合并消灭一个节点、产生一个节点,净减 1,从 7 个减到 1 个正好 6 次。
🎯 一句话记住:哈夫曼树 = 每次把最轻的两捆合并成一根,直到剩一捆;频率大的放浅处,WPL = 内部节点权重之和。