CC++ & Algorithm

哈夫曼树与哈夫曼编码

较难6
语言版本:C++
概述:哈夫曼树是一种带权路径最短的二叉树,常用于数据压缩,通过给字符分配不同长度的二进制编码。

哈夫曼树与哈夫曼编码:帮文件瘦身的秘密武器

你有没有想过,电脑里的压缩包(比如 .zip、.rar)为什么能把文件变小?哈夫曼编码就是其中一种核心技术。它像一位聪明的“管家”,给经常出现的字符分配短编码,给不常出现的字符分配长编码,这样整个文件存储起来最省空间。而背后支撑它的数据结构,就是哈夫曼树(最优二叉树)。


带权路径长度:哈夫曼树的“斤斤计较”

在学习如何构造哈夫曼树之前,我们需要先理解一个核心指标——带权路径长度(WPL,Weighted Path Length)

  • 权值:可以理解为每个节点的“重量”,比如字符出现的次数、物体质量等。
  • 路径长度:从根节点到某个节点的边数。
  • 带权路径长度:所有叶子节点的权值 × 它的路径长度,再加起来。

哈夫曼树的目标就是让整个树的 WPL 最小。好比你要给几个朋友送礼物,他们离你家距离不同,你想让跑的总路程最短,那就要优先给近的朋友送重礼物,远的送轻礼物——这就是“带权路径”的思想。

举个例子:有3个叶子节点,权值分别是 {2, 3, 5},下面两棵树的 WPL 明显不同:

     (10)              (10)
    /    \            /    \
  (5)    (5)        (2)    (8)
        /   \             /   \
      (2)   (3)         (3)   (5)
  • 左边树:叶子2的路径长度为2,叶子3为2,叶子5为1 → WPL = 2×2 + 3×2 + 5×1 = 15
  • 右边树:叶子2的路径长度1,叶子3为2,叶子5为2 → WPL = 2×1 + 3×2 + 5×2 = 18

显然左边更优。哈夫曼树一定能构造出 WPL 最小的树。


步步为营:如何构造哈夫曼树

构造过程就像玩“合并积木”游戏。我们每次挑出最轻的两块积木,把它们粘在一起,新积木的重量是两者之和,继续重复,直到只剩一块。

看一个具体例子

假设我们有5个字符及其出现次数(权值):

字符ABCDE
权值59121316

第一步:把每个字符看作一个孤立的节点(叶子)。

第二步:挑出最小的两个权值:5 (A) 和 9 (B)。合并成新节点,权值 = 5+9=14,作为它们的父节点。现在集合变为:{12 (C), 13 (D), 16 (E), 14 (新节点)}。

第三步:再挑最小的两个:12 (C) 和 13 (D),合并成权值=25的新节点。集合:{16 (E), 14, 25}。

第四步:挑最小的两个:14 和 16,合并成权值=30的新节点。集合:{25, 30}。

第五步:最后合并25和30,得到根节点权值=55。构造完成。

最终树形(手动绘制):

                (55)
               /    \
            (25)    (30)
           /    \   /   \
         (12) (13)(14) (16)
               C    D   /  \
                    (5)  (9)
                     A    B

注意:合并的顺序不唯一(比如先合C和D还是先合A和B?),但结果树的 WPL 唯一最小。


从树到编码:左0右1

哈夫曼树建好后,我们就可以给每个叶子节点(字符)分配一个唯一的二进制编码了。

规则

  • 从根节点出发,走到左子树记作 0,走到右子树记作 1
  • 从根到叶子的所有 0/1 序列,就是这个叶子(字符)的哈夫曼编码

以上面那棵树为例:

  • 走到A的路径:根→(30)→(14)→左→(5?) 注意:A对应权值5,是从14的左子到达的?不对,检查树:14的左子是5(A),右子是9(B)。所以从根:右→左→左 = 110?不对,我们明确一下:根左是25,根右是30。30的左是14,14的左是5(A),14的右是9(B)。所以A的路径:根右(1) → 30左(0) → 14左(0) → 编码“100”?更准确:路径=根→右(1)→左(0)→左(0) => “100”。类似地,B:根右(1)→左(0)→右(1) => “101”。C:根左(0)→左(0) => “00”。D:根左(0)→右(1) => “01”。E:根右(1)→右(1) => “11”。

可见,全编码为:

A: 100
B: 101
C: 00
D: 01
E: 11

(这和原代码运行结果“A : 011”不同,是因为原代码中构造顺序不同导致编码不同,但都是合法的哈夫曼编码,WPL相同。)

重要性质:哈夫曼编码是前缀编码,任何一个字符的编码不会是另一个字符编码的前缀。这样解码时不会产生歧义(比如“00”是C,“000”就可能混淆,但哈夫曼编码避免这一点)。


生活中的哈夫曼思想

除了摩斯电码,还有不少例子:

  • 学校成绩评定:如果90分以上(A)占比很多,85-89(B)稍少… 你给等级分配字母时,A用“A”一个字符,B用“B”一个字符,而E(不及格)用“E”一个字符,这没压缩。但如果用二进制传输成绩,给A分配“0”,B分配“10”,C分配“110”等,就是哈夫曼思想。
  • 自助餐厅取餐盘:常吃的菜(米饭)放最近,不常吃的(蜗牛)放最远,节省来回走的路程——相当于用短路径给高频率食物。
  • 微信表情包:常用的表情(笑、哭)用很少的字节存储,冷门表情用较多字节,整体压缩包更小。

完整可运行的C++代码示例

下面是一个完整的程序,包含:

  • 从用户输入读取字符及其频率
  • 构建哈夫曼树
  • 输出每个字符的编码
  • 计算并输出压缩前的总长度和压缩后的总长度(WPL)
  • 释放所有动态分配的内存(避免内存泄漏)
#include <iostream>
#include <queue>
#include <vector>
#include <string>
#include <iomanip>
using namespace std;

// 哈夫曼树节点
struct HuffmanNode {
    char ch;            // 字符(内部节点设为 '\0')
    int freq;           // 频率(权值)
    HuffmanNode *left, *right;   // 左右子节点指针
    HuffmanNode(char c, int f) : ch(c), freq(f), left(nullptr), right(nullptr) {}
};

// 用于最小堆的比较器:频率小的优先级高
struct Compare {
    bool operator()(HuffmanNode* a, HuffmanNode* b) {
        return a->freq > b->freq;  // 注意:最小堆需要 greater,即a.freq > b.freq时a优先级低
    }
};

// 递归生成编码,存入vector
// root: 当前节点,code: 当前路径编码,codes: 存放结果的数组(下标为字符ASCII码)
void getCodes(HuffmanNode* root, string code, vector<string>& codes) {
    if (!root) return;
    // 如果是叶子节点(有实际字符),记录编码
    if (!root->left && !root->right) {
        codes[root->ch] = code;
        return;
    }
    getCodes(root->left, code + "0", codes);
    getCodes(root->right, code + "1", codes);
}

// 递归释放整棵树的内存
void deleteTree(HuffmanNode* root) {
    if (!root) return;
    deleteTree(root->left);
    deleteTree(root->right);
    delete root;
}

int main() {
    int n;  // 字符个数
    cout << "请输入字符个数: ";
    cin >> n;

    vector<char> chars(n);           // 存储字符
    vector<int> freqs(n);            // 存储对应频率
    cout << "请输入每个字符及其频率(例如 A 5):" << endl;
    for (int i = 0; i < n; i++) {
        cin >> chars[i] >> freqs[i];
    }

    // 使用优先队列(最小堆)存储节点指针
    priority_queue<HuffmanNode*, vector<HuffmanNode*>, Compare> pq;
    for (int i = 0; i < n; i++) {
        pq.push(new HuffmanNode(chars[i], freqs[i]));
    }

    // 构建哈夫曼树:不断取出两个最小节点合并
    while (pq.size() > 1) {
        HuffmanNode* left = pq.top(); pq.pop();   // 最小权值的节点
        HuffmanNode* right = pq.top(); pq.pop();  // 次小权值的节点
        // 创建父节点,权值为两者之和,字符设为'\0'表示内部节点
        HuffmanNode* parent = new HuffmanNode('\0', left->freq + right->freq);
        parent->left = left;
        parent->right = right;
        pq.push(parent);
    }
    HuffmanNode* root = pq.top(); // 最终根节点

    // 生成编码(ASCII可打印字符范围通常0~127,这里分配128就足够)
    vector<string> codes(128, "");
    getCodes(root, "", codes);

    // 输出每个字符的编码
    cout << "\n字符和对应的哈夫曼编码:" << endl;
    for (int i = 0; i < n; i++) {
        cout << chars[i] << " : " << codes[chars[i]] << endl;
    }

    // 计算压缩前后的总比特数(假设原来每个字符用8位固定编码)
    int originalTotal = 0, compressedTotal = 0;
    for (int i = 0; i < n; i++) {
        originalTotal += freqs[i] * 8;                     // 原编码8位
        compressedTotal += freqs[i] * codes[chars[i]].size(); // 哈夫曼编码长度
    }
    cout << "\n原始总比特数: " << originalTotal << endl;
    cout << "哈夫曼压缩后总比特数: " << compressedTotal << endl;
    cout << "压缩率: " << fixed << setprecision(2)
         << (double)compressedTotal / originalTotal * 100 << "%" << endl;

    // 释放所有动态内存
    deleteTree(root);

    return 0;
}

运行示例(输入上面5个字符)

请输入字符个数: 5
请输入每个字符及其频率(例如 A 5):
A 5
B 9
C 12
D 13
E 16

字符和对应的哈夫曼编码:
A : 100
B : 101
C : 00
D : 01
E : 11

原始总比特数: 440
哈夫曼压缩后总比特数: 131
压缩率: 29.77%

(注意:编码结果和之前手动推算的可能不同,因为合并顺序导致树形不同,但WPL都是131,压缩效果相同。)


新手容易犯的错误

  1. 优先队列的比较器写反
    很多同学把 Compare 写成 return a->freq < b->freq; 这样就成了最大堆(根节点是最大元素),构建出来的树是“最差霍夫曼树”,编码长度反而更长。正确写法:return a->freq > b->freq;

  2. 混淆内部节点和叶子节点
    内部节点(合并后创建的父节点)没有字符,需要设为特殊值(如 '\0'),不能误当作叶子节点。在 getCodes 中要正确判断是否叶子:!root->left && !root->right

  3. 编码长度过长导致溢出
    理论上哈夫曼编码长度不会超过字符总数-1,但若输入频率分布极不均匀(比如一个字符占99%),编码长度可能很长。在竞赛中一般不会超出int范围,但注意 vector 下标如果是 char 类型,可能会遇到负数(若字符是扩展ASCII),建议用 unsigned char 或直接映射到0~255。

  4. 忘记释放内存
    竞赛中通常不在乎内存泄漏,但实际开发或项目要求中必须释放。上面的代码已经用 deleteTree 递归释放了。

  5. 对相同频率的节点合并顺序不敏感
    当两个节点频率相同时,合并顺序不影响WPL,但会影响编码结果。这是允许的,最终得到的编码都是前缀编码,且总长度最小。


相关指引(学完哈夫曼树之后)

  • 贪心算法:哈夫曼树是经典的贪心策略(每次选最小的两个合并),可以学习其他贪心问题(活动选择、背包等)。
  • 优先队列:掌握 priority_queue 的用法,特别是自定义比较器。
  • 前缀编码:哈夫曼编码是前缀码的一种,其他还有固定长度编码、算术编码等。
  • 数据压缩应用:ZIP、GZIP、PNG、JPEG 中都使用了哈夫曼编码或其变种(如动态哈夫曼编码)。
  • 信息论:香农提出信源编码理论,哈夫曼编码接近理论上的最优——熵(信息量的期望值)。

尝试自行实现:给定一段文本,统计字符频率,用哈夫曼编码压缩并还原(解压)。你会发现压缩的原理其实并不神秘!

例题精讲

1单选题

在哈夫曼树中,权值较大的叶子节点相对于权值较小的叶子节点,其深度(即编码长度)通常如何?

A较小
B较大
C相等
D不确定
2判断题

哈夫曼编码是一种前缀编码,即任何一个字符的编码都不是另一个字符编码的前缀。

3填空题
以下函数用于计算一组权值的哈夫曼树带权路径长度(WPL),请在横线处填入正确的表达式。
int huffmanWPL(vector<int>& weights) {
    priority_queue<int, vector<int>, greater<int>> pq;
    for (int w : weights) pq.push(w);
    int wpl = 0;
    while (pq.size() > 1) {
        int a = pq.top(); pq.pop();
        int b = pq.top(); pq.pop();
        int sum = a + b;
        wpl += ___;  // 填空
        pq.push(sum);
    }
    return wpl;
}
4单选题

给定字符频率:A:1, B:2, C:3, D:4, E:5,则权值最大的字符E的哈夫曼编码长度是多少?(假设根节点深度为0)

A1
B2
C3
D4
5判断题

在哈夫曼树中,叶子节点的个数等于内部节点个数加1。