NOI C++ 课程体系 / 数据结构篇 / 第八讲
数据结构 · 哈夫曼树

哈夫曼树的定义和构造、哈夫曼编码
贪心造出最省空间的前缀编码

掌握带权路径长度 WPL、哈夫曼树的“合并最小两个”构造法,以及左0右1得到的前缀编码与无歧义解码。

核心 WPL 最小构造 合并最小两个编码 左0右1前缀码解码 沿树唯一还原拓展 文件压缩
本讲 · 第 48/56 讲哈夫曼树与哈夫曼编码
01

情景导航

从“如何把一段文字压到最小”出发,理解哈夫曼树为什么是最优二叉树,以及哈夫曼编码怎样做到既短又不冲突。

📖 引入故事:电报里的“省字诀”

早年发电报按字收费。英文里 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到叶子,高频短码最省得;前缀无歧义,解码不认错。”

🗺️ 本讲知识地图

知识点你要掌握难度
带权路径长度 WPLWPL = Σ wᵢ·dᵢ★
哈夫曼树(最优二叉树)使 WPL 最小的二叉树★★
构造算法(贪心)每次合并权值最小的两棵子树★★
哈夫曼编码左0右1,得到前缀码★★
前缀码与无歧义解码编码互不为前缀 → 沿树唯一还原★★
应用(拓展)文件压缩、合并果子(优先队列)★★★
02

知识探究

先搞懂 WPL,再看哈夫曼树如何“合并最小两个”造出来,最后由树得到前缀编码并验证能无歧义解码。

① 什么是带权路径长度 WPL

给一棵树上的叶子标上“权值”(频率),每个叶子到根要经过的边数叫“深度”。带权路径长度 WPL = 所有叶子的“权值 × 深度”之和。

直觉:权值大的叶子如果离根近(深度小),乘积就小,WPL 就小。哈夫曼树的目标,就是让 WPL 达到最小。

公式WPL = w₁d₁ + w₂d₂ + … + wₙdₙ

② 哈夫曼树:合并最小的两个

构造思想非常“贪心”:把 n 个权值看成 n 棵只有根(叶子)的树,每次选出权值最小的两棵合并成一棵新树(新根权值 = 两子树权值之和),直到只剩一棵树。这样反复合并,得到的就是 WPL 最小的哈夫曼树。

图· 例:字符频率 A5 B9 C12 D13 E16 F45 构造出的哈夫曼树(内部结点 = 合并后权重)
10045F55253012C13D1416E5A9B
根(总权重100) 叶子(字符) 内部结点

上图中:先合并 5 和 9 得 14,再合 12、13 得 25……最终根权值 100。每个内部结点都等于其两棵子树权值之和,而 所有内部结点权值加起来正好等于 WPL。

③ 构造算法(优先队列实现)

把权值放进小根堆(优先队列),每次取两个最小的合并,把新权值放回堆,直到堆里只剩一个。下面用 6 个字符频率 A5 B9 C12 D13 E16 F45 演示,并累加内部结点权值得到 WPL。

C++哈夫曼树·构造并求 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,走到叶子就得到该字符的编码。因为编码都在“叶子”结束,没有任何编码是别的编码的前缀——这正是“前缀码”,保证解码不二义。

C++哈夫曼树·输出每个字符的编码
#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。

字符频率哈夫曼编码编码长度
A511004
B911014
C121003
D131013
E161113
F4501

⑤ 无歧义解码:沿树走回去

收到一串 0/1 后,从根开始:遇到 0 走左、1 走右,走到叶子就译出一个字符并回到根,继续译下一个。因为编码是前缀码,每次都能在唯一位置“停”下来,绝不会译错。

C++哈夫曼编码·编码后再解码(可逆验证)
#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 位。哈夫曼编码在此更省,且解码后完全还原。

03

实践应用

三道分析编程题:换一组权值求 WPL、构造并输出编码、编码解码验证可逆。均给出可编译运行的参考实现。

🧩 题1:权值 {3,5,7,9,11,13},求 WPL

直接套用“小根堆合并”思路,把每次合并的新权值累加即可。

C++求一组新权值的 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。

🧩 题2:权值 {2,4,5,7},构造并输出编码

用同样的建树方法,输出每个字符的编码与 WPL,体会“高频短码”。

C++构造哈夫曼树并输出编码
#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。

🧩 题3:权值 {1,2,3,4},对报文 ABCD 编码再解码

验证哈夫曼编码的“可逆性”:编码后的 0/1 串能沿树唯一还原成原报文。

C++编码后再解码(可逆性验证)
#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,证明编码无歧义、可逆。

04

总结提升

把 WPL、构造步骤、前缀码性质与拓展方向一次性理清。

📌 核心要点总结

结论说明
WPLWPL = Σ wᵢ·dᵢ,权值×深度之和
哈夫曼树使 WPL 最小的二叉树(最优二叉树)
构造每次合并权值最小的两棵子树,新权值 = 两子树之和
WPL 计算= 所有“内部结点权值”之和
编码左0右1;叶子得到编码
前缀码编码互不为前缀 → 解码唯一、无歧义

🚀 拓展:提高内容(CSP-J 不要求,了解即可)

  • 优先队列:用 priority_queue<int, vector<int>, greater<int>> 实现“每次取最小”,是合并果子类题目的标准工具。
  • 文件压缩:先统计字符频率 → 建哈夫曼树 → 得到编码表 → 把原文逐字符译成 0/1 串;解压时凭同一棵树还原。完整压缩程序属提高内容。
  • 带权路径长度最值:哈夫曼树保证 WPL 最小,是“贪心算法”的经典范例(类似还有最优合并、石子合并)。

⚠️ 哈夫曼树的“构造与编码”属 CSP-J 应知;完整文件压缩实现属提高,初赛与复赛一般不要求手写。

💡 思考与启发

  • 为什么“每次合并最小的两个”就能让 WPL 最小?如果改成“合并最大的两个”会怎样?
  • 哈夫曼编码是前缀码,但前缀码一定是哈夫曼编码吗?举一个反例。
  • 若两个字符频率相同,合并时分左右不同,编码会不同——这会影响 WPL 吗?会影响什么?
05

学习检测

5 大易错点 + 速查表 + 笔记卡,考前一眼回顾。

⚠️ 5 大易错点

易错1

把 WPL 算成“所有结点深度之和”。正确是 只算叶子:WPL = Σ 权值ᵢ × 深度ᵢ。

易错2

合并时“取最小两个”写错成“取最大两个”或只取一个。必须每次取权值最小的两棵合并。

易错3

算 WPL 时把内部结点权值也当叶子累加,或漏加。简便记法:WPL = 所有内部结点权值之和。

易错4

编码左右分支记反(把右记 0)。约定“左0右1”后前后一致即可,但必须统一。

易错5

以为哈夫曼编码能“随便译”。解码必须沿同一棵树走;且因是前缀码,每个字符在唯一位置停下,别多读或少读一位。

📑 速查表

概念要点
WPLΣ wᵢ·dᵢ(只叶子)
构造最小两棵合并 → 新权值 = 二者之和
WPL 速算= 所有内部结点权值之和
编码左0右1;叶子的 0/1 串
前缀码编码互不为前缀 → 解码唯一

📌 笔记卡

哈夫曼树 = 最优二叉树

① WPL = Σ wᵢ·dᵢ(只算叶子)。

② 构造:每次合并权值最小的两棵子树。

③ WPL = 所有内部结点权值之和。

④ 编码:左0右1,得到前缀码。

⑤ 前缀码 → 沿树唯一解码、无歧义。

课后练习

分层练习作业

A基础 / B提高 / C挑战,难度递增,点击展开答案与解析。

🟢 A组 · 基础(4题)

A1基础·概念

什么是带权路径长度 WPL?写出公式并说明权值与深度的含义。

回忆定义即可。

查看参考答案

WPL = Σ wᵢ·dᵢ,其中 wᵢ 是叶子权值(常表示频率),dᵢ 是该叶子到根的深度(经过的边数)。WPL 越小,平均编码越短。

思路:WPL = Σ wᵢ·dᵢ:wᵢ 是叶子权值(通常代表出现频率),dᵢ 是该叶子到根的深度(经过的边数)。WPL 越小,平均编码长度越短。

易错:把深度 dᵢ 按“层号从 1 开始”来数,结果整体比“边数”多 1。

A2基础·构造

权值 {2,3,4,5},按哈夫曼方法合并,写出每一步合并的两个权值与产生的新权值。

每次取最小两个。

查看参考答案

2+3=5;4+5=9;5+9=14。内部结点权值 5、9、14。

思路:每轮从当前所有结点中取出权值最小的两个合并成一个新结点(权值为两者之和),再把新结点放回集合,重复直到只剩一个结点。

易错:合并后忘记把新结点放回集合参与后续选取,或只按最初的顺序两两配对而没有每轮重新找最小。

A3基础·WPL

由 A2 的合并结果,WPL 等于多少?为什么等于内部结点权值之和?

把内部结点权值相加。

查看参考答案

WPL = 5+9+14 = 28。每个内部结点权值 = 其下所有叶子权值之和,累加内部结点即把所有叶子的“权值×深度”算了一遍。

思路:WPL = 所有内部结点权值之和 = 5 + 9 + 14 = 28。每个内部结点的权值等于其下所有叶子权值之和,把它们累加,相当于每个叶子都被算了“深度”次。

易错:直接把根的权值 14 当成 WPL,或只把叶子权值相加得 14。

A4基础·编码

哈夫曼编码为什么一定是前缀码?前缀码解决了什么问题?

从“编码都在叶子结束”想。

查看参考答案

编码只在叶子处结束,任何叶子的编码都不可能是另一叶子编码的前缀。前缀码保证解码时每个字符在唯一位置停下,无二义。

思路:哈夫曼编码只从叶子结束,任一叶子的路径都不可能是另一叶子路径的前缀,因此是前缀码;解码时每个字符都在唯一位置停下,不会产生二义。

易错:以为前缀码的作用是“压缩”,其实它解决的是“解码唯一、无歧义”。

🟡 B组 · 提高(4题)

B1提高·WPL

权值 {1,2,3,4,5},求哈夫曼树的 WPL。

小根堆合并,累加内部结点。

查看参考答案

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 就会算错。

B2提高·编码

权值 {3,5,7,9},构造哈夫曼树并写出每个权值的编码(左0右1)。

合并最小两个,建树后左右分支赋值。

查看参考答案

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 搞混。

B3提高·解码

收到编码串 110100111,已知编码表 A:110 B:10 C:0 D:111,译出原报文。

沿前缀码逐位切分。

查看参考答案

110→A,10→B,0→C,111→D,原报文 = ABCD。

思路:前缀码解码时从左往右逐位读,一边读一边与编码表匹配,每匹配到一个完整编码就输出对应字符,然后从头重新开始匹配。

易错:贪心地按固定字符宽度切分编码串,而不是按“是否构成某个编码”来判断切分位置。

B4提高·思路

用文字 + 伪代码说明“小根堆合并”构造哈夫曼树的核心思路(不要求完整 C++)。

堆里放权值,循环取最小两个。

查看参考答案

把所有权值入小根堆;while(堆大小>1){ a=pop最小;b=pop最小;push(a+b);WPL+=a+b } 最后堆中仅剩根权值,WPL 即所求。

思路:把所有结点权值放进小根堆;每轮弹出最小的两个权值 a、b,把 a+b 压回堆中,同时把 a+b 累加进 WPL;堆里只剩一个结点时结束,累加值即为 WPL。

易错:忘记累加 WPL(或只累加了叶子权值),或弹出两个最小值后没有把新权值压回堆。

🔴 C组 · 挑战(4题)

C1挑战·证明

证明:哈夫曼树的 WPL 等于所有“内部结点权值”之和。

从每个叶子被累加的次数入手。

查看参考答案

叶子权值 wᵢ 会在它到根路径上每一层合并时被加进一个祖先内部结点,共 dᵢ 次。故所有内部结点权值之和 = Σ wᵢ·dᵢ = WPL。

思路:叶子权值 wᵢ 在它到根的路径上,每经过一个内部结点就会被“合并”加进去一次,一共经历 dᵢ 次;所以这个叶子对“内部结点权值之和”的贡献恰好是 wᵢ·dᵢ。

易错:只陈述结论,没有做“贡献次数正好等于深度 dᵢ”这一步对应,证明就缺了关键环节。

C2挑战·性质

说明:哈夫曼编码是前缀码,但不是“唯一”的最优编码(左右互换得到不同编码,WPL 相同)。

举左右互换的反例。

查看参考答案

交换某结点的左右子树,所有相关编码的 0/1 互换,仍合法前缀码且 WPL 不变;故最优编码不唯一,但 WPL 唯一。

思路:交换任意内部结点的左右子树,相关编码的 0/1 位全部互换,仍是合法前缀码且每个叶子的深度不变,故 WPL 相同;因此最优编码不唯一,但 WPL 唯一。

易错:认为“哈夫曼编码是唯一的最优解”,忽略了权值相同时合并顺序不同也能得到不同但等价的树。

C3挑战·编码

手写 C++:给定字符频率,建哈夫曼树并输出每个字符的编码与 WPL(可用优先队列或排序)。

复用本讲 CODE_CODE 的结构。

查看参考答案

用结点结构存权值与左右指针;每次取两最小合并成新结点,DFS 从根到叶记 0/1,叶子输出编码;累加内部结点得 WPL。

思路:用优先队列每次取两个最小权值合并成新结点(记录好左右孩子);建完树后从根开始 DFS 记录路径上的 0/1,到叶子就输出该字符的编码,同时把每个内部结点权值累加得到 WPL。

易错:建树时只保存权值不保存左右孩子指针,导致最后无法从根遍历生成编码。

C4挑战·应用

手写 C++:先统计字符串各字符频率,再建哈夫曼树把该串编码为 0/1 串并输出(不要求写解码)。

先统计频率,再套构造。

查看参考答案

用数组/map 统计每个字符出现次数作为权值;建哈夫曼树;由编码表把原串逐字符翻译成 0/1 串输出。

思路:先统计字符串中每个字符出现的次数作为权值,建哈夫曼树并生成“字符 → 编码”表,再按原串顺序逐字符替换拼接,输出整条 0/1 串。

易错:编码时没有保持原字符顺序,或把权值统计成了字符的种类数而不是出现次数。

🏆 洛谷原题(20道)

1P1090合并果子(经典哈夫曼)普及-
2P1334瑞瑞的木板(哈夫曼合并)普及-
3P5836[USACO19DEC] Milk Visits S普及-
4P4913二叉树深度入门
5P1305新二叉树普及-
6P1030求先序排列普及-
7P1827美国血统普及-
8P1229遍历问题提高-
9P2015二叉苹果树提高-
10P3884满二叉树的带权路径长度普及-
11P5018对称二叉树普及-
12P1364医院设置(带权重心)提高-
13P1395会议(树的重心)提高-
14P1040加分二叉树提高-
15P3379最近公共祖先 LCA(模板)提高-
16P2052道路铺设提高-
17P1268树的最大独立集提高-
18P2014选课(树形 DP)提高-
19P5536【XR-3】核心城市提高-
20P1198[JSOI2008] 最大数(线段树)提高-