🎁 当前为 免费试看 内容 · 开通VIP 解锁 GESP 通关手册全部 1–8 级知识点 + 题库 + 详细解析
🌐 主站
📖 已学 0

🌳 哈夫曼树与编码

所属等级:GESP 6级 | 难度:★★★ | 关键词:哈夫曼树、WPL、贪心、前缀码、数据压缩

📝 考纲要求

  • 理解哈夫曼树的概念:带权路径长度(WPL)最小的二叉树;
  • 掌握贪心构建方法:每次从森林里取出权值最小的两棵树合并;
  • 理解哈夫曼编码:约定左分支为 0、右分支为 1,得到的是前缀码(无歧义、可唯一解码);
  • 了解哈夫曼编码在数据压缩中的应用:出现频率越高的字符,编码越短。

📖 通俗讲解

💡 一句话理解 哈夫曼树就是“最省总长度”的一棵树:把权值大的叶子放在离根更近(更短)的位置,权值小的放在更远(更长)的位置,让所有“叶子权值 × 到根距离”的总和最小。

比如有 5 个字符,出现的次数(权值)分别是 2、3、5、7、11。我们一开始把它们看成 5 棵孤零零的小树(一个森林)。贪心的意思是:每一步都挑“当前最轻的两棵”合并成一棵新树,新树的权值是两者之和。重复这个过程,最后只剩一棵树,就是哈夫曼树。

🖼️ 图解

建立哈夫曼树:权值 A:5 B:2 C:1 D:3

森林(初始):  [A5] [B2] [C1] [D3]

第1步:最小两个 C1 + B2 -> 新节点3
        [A5] [D3] [ 3 ]        (3 = C+B)
                      / \
                    C1  B2

第2步:最小两个 D3 + 3 -> 新节点6
        [A5] [ 6 ]            (6 = D + (C+B))
              / \
            D3   3
                / \
              C1  B2

第3步:最后 A5 + 6 -> 根11,得到哈夫曼树
          11
         /  \
       A5     6
            /   \
          D3     3
               /   \
             C1    B2

左0右1 编码: A=0  D=10  C=110  B=111
频率越高(A)编码越短,频率越低(B,C)编码越长。

🌲 森林合并过程(ASCII 图解)

合并过程
初始森林(每片叶子都是一棵单独的树):
  [2]   [3]   [5]   [7]   [11]

第1步:最小的两棵是 2 和 3,合并 -> 新树权值 5
  [5]        [5]   [7]   [11]
   ^2+3

第2步:最小的两棵是 5 和 5,合并 -> 新树权值 10
  [7]   [10]             [11]
          ^5+5

第3步:最小的两棵是 7 和 10,合并 -> 新树权值 17
  [11]  [17]
         ^7+10

第4步:最后合并 11 和 17 -> 根 28,得到哈夫曼树
  [28]
   ^11+17

🌳 最终哈夫曼树(左 0 右 1)

树结构
            28
          /    \
        11       17
               /    \
              7      10
                    /  \
                   5    5
                       / \
                      2   3

约定:从根往下的「左分支」记 0,「右分支」记 1

从根到每个叶子的路径拼起来就是它的编码:11 在左枝 → 0;7 在“右-左” → 10;5 在“右-右-左” → 110;2、3 在“右-右-右” → 1110 和 1111。频率最高(11)的字符编码最短,这正是压缩的关键。

📦 为什么能压缩 真实文本里,像字母 e、空格出现得很多,给它们短编码,给罕见字符长编码,整段文字的平均长度就变小了。哈夫曼编码是很多压缩格式(如 ZIP、图片)的核心思想之一。

🔵 核心必背重点

✅ 必背
  • WPL(带权路径长度):WPL = Σ(叶子权值 × 路径长度),即每个叶子权值乘它到根经过的边数,再加总;
  • 哈夫曼树:在所有可能的二叉树中,WPL 最小的那一棵;
  • 贪心合并:每次取权值最小的两棵树合并,新树权值 = 两树权值之和;
  • 哈夫曼编码:左分支 0、右分支 1;因为没有任何字符的编码是另一个字符编码的前缀,所以叫前缀码,解码时不会混淆;
  • WPL 速算:把所有“合并产生的新节点权值”加起来,就是 WPL(本例 5+10+17+28 = 60)。
🔑
记忆口诀:“轻的抱团往下沉,重的靠近根上挂;左 0 右 1 标路径,前缀编码不打架。”

🔴 考试拉分难点

⚠️ 难点
  • 合并时“找最小两个”:必须用最小堆(优先队列)或反复扫描找最小的两个,不能直接排序一次就完事,因为每次合并后森林会变化;
  • WPL 计算:容易把“路径长度”数错(根到叶子经过的边数,根本身不算长度);
  • 编码不唯一:左右孩子谁当 0 谁当 1 不影响 WPL,所以不同写法都对,但同一份答案内部要前后一致;
  • 相同权值的处理:出现相等权值时,任选两个合并即可,最终 WPL 一定相同。

🟡 高频易错陷阱

🚫 扣分坑点
  • 把“路径长度”当成“层数”算多 1(根到叶子若经过 3 条边,长度是 3,不是 4);
  • 合并时忘记把新树重新放回森林,导致森林数量没减少、死循环;
  • 误以为哈夫曼编码需要等长(其实它不等长,正是靠不等长来省空间);
  • 用普通数组排序代替最小堆,合并后没更新集合,结果算错 WPL;
  • 解码时把前缀码当成普通编码,没利用“前缀无歧义”的特性。

🧪 例题演练

例题 1(基础题):求哈夫曼树的 WPL

题目:给定叶子权值 2、3、5、7、11,用贪心法构建哈夫曼树,求带权路径长度 WPL。

C++
#include <iostream>
#include <queue>
#include <vector>
using namespace std;

struct Node {
    int w;                 // 权值
    Node *l, *r;           // 左右孩子
    Node(int w) : w(w), l(nullptr), r(nullptr) {}
};
// 最小堆:权值小的优先出队
struct Cmp {
    bool operator()(Node* a, Node* b) { return a->w > b->w; }
};
// 递归计算 WPL = Σ(叶子权值 × 路径长度)
int wpl(Node* p, int depth) {
    if (!p) return 0;
    if (!p->l && !p->r) return p->w * depth;  // 叶子
    return wpl(p->l, depth + 1) + wpl(p->r, depth + 1);
}
int main() {
    int a[] = {2, 3, 5, 7, 11};
    int n = 5;
    priority_queue<Node*, vector<Node*>, Cmp> pq;
    for (int i = 0; i < n; i++) pq.push(new Node(a[i]));

    // 反复合并权值最小的两棵树
    while (pq.size() > 1) {
        Node* x = pq.top(); pq.pop();
        Node* y = pq.top(); pq.pop();
        Node* root = new Node(x->w + y->w);  // 新树权值 = 两子树之和
        root->l = x; root->r = y;
        pq.push(root);
    }
    cout << "WPL = " << wpl(pq.top(), 0) << endl;
    return 0;
}
输出WPL = 60
✅ 手算核对 合并产生的内部节点权值依次为 5、10、17、28,相加 5+10+17+28 = 60,与程序一致。

例题 2(模拟真题):构造哈夫曼编码

题目:字符 a、b、c、d、e 的出现频率分别为 2、3、5、7、11。构造哈夫曼树并输出每个字符的编码(左 0 右 1),指出频率最高的字符编码是什么。

C++
#include <iostream>
#include <queue>
#include <vector>
#include <string>
using namespace std;

struct Node {
    int w;
    char ch;              // 原字符,非叶子记为 '\0'
    Node *l, *r;
    Node(int w, char c = '\0') : w(w), ch(c), l(nullptr), r(nullptr) {}
};
struct Cmp {
    bool operator()(Node* a, Node* b) { return a->w > b->w; }
};
// 递归输出编码:左分支记 '0',右分支记 '1'
void printCode(Node* p, string s) {
    if (!p) return;
    if (p->ch != '\0') {                        // 到达叶子 = 原字符
        cout << p->ch << " : " << (s.empty() ? "0" : s) << endl;
        return;
    }
    printCode(p->l, s + '0');
    printCode(p->r, s + '1');
}
int main() {
    char ch[]  = {'a','b','c','d','e'};
    int  freq[]= { 2, 3, 5, 7, 11};
    int n = 5;
    priority_queue<Node*, vector<Node*>, Cmp> pq;
    for (int i = 0; i < n; i++) pq.push(new Node(freq[i], ch[i]));

    while (pq.size() > 1) {                     // 同例题1的贪心合并
        Node* x = pq.top(); pq.pop();
        Node* y = pq.top(); pq.pop();
        Node* root = new Node(x->w + y->w);
        root->l = x; root->r = y;
        pq.push(root);
    }
    printCode(pq.top(), "");
    return 0;
}
输出e : 0 d : 10 c : 110 a : 1110 b : 1111
📌 结论 频率最高的 e(11 次)编码最短,只有 0;频率最低的 a、b 编码最长(1110、1111)。这些编码互为前缀码,解码时从根沿 0/1 走到底即可唯一确定字符。

🛡️ 避坑总结

  1. 哈夫曼树=WPL 最小的二叉树,用贪心每次合并最轻的两棵;
  2. 路径长度是根到叶子经过的边数,别多数一层;
  3. WPL 可速算:把所有合并出的内部节点权值相加;
  4. 编码约定左 0 右 1,得到的是无歧义的前缀码;
  5. 频率高的字符编码短,这正是哈夫曼编码能压缩数据的原因;
  6. 实现时优先用最小堆(优先队列),合并后记得把新树放回集合。

💡 提示:按 ← → 方向键可在上一 / 下一知识点间快速翻页

GESP 6级 · 学习进度 0/0

📑 本页目录