CC++ & Algorithm

哈夫曼编码:像整理书包一样压缩数据

较难12
语言版本:C++Python
概述:哈夫曼编码是一种用更短的代码表示常用信息、用更长代码表示不常用信息的数据压缩方法,就像把最常穿的衣服放在书包最外面。

哈夫曼编码:像整理书包一样压缩数据

你有没有遇到过这样的烦恼?手机里照片太多,存储空间总是不够用。或者在下载大文件时,等得心急如焚。其实,很多文件之所以“大”,是因为它们用“固定长度”的二进制代码来存储每个字符——不管这个字符出现多少次,都给它分配同样多的比特位,这就像把所有衣服叠得一样厚,不管常不常穿。

哈夫曼编码(Huffman Coding)是一种聪明的数据压缩方法。它给出现频率高的字符分配很短的二进制代码,给出现频率低的字符分配稍长的代码,这样整体存储的数据量就会大大减少。就像收拾旅行箱:最常穿的T恤放在最外面(容易取),偶尔穿的厚外套塞在角落(占地方但用得少)。通过这种方式,同样的箱子里能装下更多东西。

为什么要用哈夫曼编码?

平时我们用计算机存储字符时,通常使用固定长度的编码,比如 ASCII 码每个字符用 8 位二进制表示。如果一段文字中某个字符出现次数极多,用固定编码就显得很浪费。比如下面这封信:

AAAAAAABBBBBCCCD

共 16 个字符,用 ASCII 码需要 16×8 = 128 位。但仔细看,这封信中 'A' 出现了 7 次,'B' 出现了 5 次,'C' 出现了 3 次,'D' 出现了 1 次。如果让 'A' 只用 1 位,'B' 用 2 位,'C' 用 3 位,'D' 用 3 位,那么总位数可以降到 7×1 + 5×2 + 3×3 + 1×3 = 7+10+9+3 = 29 位,压缩率高达 77%!这就是哈夫曼编码的魔力。

哈夫曼编码的核心思想:用“树”来生成编码

哈夫曼编码的核心是构建一棵哈夫曼树(也叫最优二叉树)。这棵树的叶子节点是我们要编码的字符,每个叶子节点的“权重”就是该字符出现的次数。树的结构遵循一个简单规则:出现次数越多的字符,离根节点越近。然后从根节点出发,向左走一步记作 0,向右走一步记作 1,走到叶子节点所经过的路径就是该字符的编码。因为高频字符离根近,所以路径短,编码也短。

构建哈夫曼树的步骤(就像玩积木一样)

  1. 统计频率:先统计每个字符出现的次数(相当于每个字符的“分量”)。
  2. 创建节点:把每个字符看成一个单独的节点,节点上写着字符和它的频率。
  3. 不断合并:每次从所有节点中选出频率最小的两个节点,把它们合并成一个新节点,新节点的频率就是这两个节点频率之和。新节点作为它们的“父亲”,频率小的放在左边,频率大的放在右边(左右顺序无所谓,但通常约定左小右大)。
  4. 重复:把新节点放回节点集合中,继续选最小的两个合并,直到只剩一个节点,这就是哈夫曼树的根。

举个例子,还是刚才的信:字符及频率为 A:7, B:5, C:3, D:1。

  • 第一步:所有节点(A7, B5, C3, D1)。
  • 第二步:频率最小的两个是 C3 和 D1,合并得到新节点 CD(4)。现在节点有 A7, B5, CD4。
  • 第三步:最小两个是 B5 和 CD4,合并得到 BCD(9)。现在节点有 A7, BCD9。
  • 第四步:最后合并 A7 和 BCD9,得到根节点 ABCD(16)。

构建的树如下(你可以想象一下):

        ABCD(16)
       /        \
      A(7)     BCD(9)
              /      \
            B(5)    CD(4)
                   /     \
                 C(3)   D(1)

从根出发,向左(A)是 0,向右(BCD)是 1;从 BCD 往左(B)是 0,往右(CD)是 1;从 CD 往左(C)是 0,往右(D)是 1。所以编码为:

  • A: 0
  • B: 10
  • C: 110
  • D: 111

你看,出现最多的 A 只有 1 位,出现最少的 D 用了 3 位,完全符合“高频短码、低频长码”的原则。

C++ 实现:用优先队列(最小堆)来构建树

在代码中,我们经常用优先队列(priority_queue)来快速找到频率最小的两个节点。优先队列就像一个自动排序的队伍,频率最小的节点永远排在队首。我们用一个结构体 Node 来表示树节点,每个节点存储字符、频率以及左右孩子指针。

下面是详细的代码,每一行都有中文注释,方便你理解:

#include <iostream>
#include <queue>          // 优先队列
#include <unordered_map>  // 存储字符到编码的映射
#include <vector>         // 存储初始数据
using namespace std;

// 哈夫曼树节点
struct Node {
    char ch;           // 字符(仅叶子节点有效,内部节点设为 '\0')
    int freq;          // 频率(出现次数)
    Node *left, *right; // 左孩子和右孩子指针
    // 构造函数,初始化字符和频率,左右孩子设为空
    Node(char c, int f) : ch(c), freq(f), left(nullptr), right(nullptr) {}
};

// 用于优先队列的比较函数:频率小的优先(最小堆)
struct Compare {
    bool operator()(Node* a, Node* b) {
        return a->freq > b->freq; // 注意是大于,这样频率小的排在前面
    }
};

// 递归生成编码,code为当前已累积的二进制串
void buildCodes(Node* root, string code, unordered_map<char, string>& huffmanCodes) {
    if (!root) return; // 如果节点为空,直接返回
    // 到达叶子节点:左右孩子都为空
    if (!root->left && !root->right) {
        huffmanCodes[root->ch] = code; // 记录这个字符的编码
    }
    // 向左走,编码加 '0'
    buildCodes(root->left, code + "0", huffmanCodes);
    // 向右走,编码加 '1'
    buildCodes(root->right, code + "1", huffmanCodes);
}

int main() {
    // 假设我们有四个字符及其频率
    vector<pair<char, int>> data = {{'A', 5}, {'B', 9}, {'C', 12}, {'D', 13}};
    // 创建一个优先队列(最小堆),存储 Node*,使用 Compare 比较
    priority_queue<Node*, vector<Node*>, Compare> pq;

    // 将每个字符作为一个节点放入优先队列
    for (auto& p : data) {
        pq.push(new Node(p.first, p.second));
    }

    // 构建哈夫曼树:当队列中不止一个节点时,合并最小的两个
    while (pq.size() > 1) {
        // 取出频率最小的两个节点
        Node* left = pq.top(); pq.pop();
        Node* right = pq.top(); pq.pop();
        // 创建新节点作为父节点,字符设为 '\0'(表示内部节点),频率为两者之和
        Node* parent = new Node('\0', left->freq + right->freq);
        parent->left = left;   // 左孩子指向频率较小的(这里 left 已经是小的)
        parent->right = right; // 右孩子指向频率较大的
        // 将新节点放回队列
        pq.push(parent);
    }
    // 队列中剩下的唯一节点就是哈夫曼树的根
    Node* root = pq.top();

    // 创建一个哈希表,用来存储每个字符对应的编码
    unordered_map<char, string> huffmanCodes;
    // 递归生成编码,从根开始,当前编码为空字符串
    buildCodes(root, "", huffmanCodes);

    // 输出结果
    cout << "字符的哈夫曼编码:" << endl;
    for (auto& p : huffmanCodes) {
        cout << p.first << " : " << p.second << endl;
    }

    // 释放内存(实际项目中需要递归删除节点,这里省略以突出重点)
    return 0;
}

运行这个程序,你会得到类似下面的输出:

字符的哈夫曼编码:
A : 00
B : 01
C : 10
D : 11

咦?为什么这次所有编码都是两位?这是因为示例中四个字符的频率相差不大(5, 9, 12, 13),生成的树比较平衡,所以编码长度相同。但你可以试着修改频率,比如把 'A' 的频率改成 50,其他保持不变,再运行一次,就会看到 'A' 的编码变短了。

新手容易犯的四个错误

  1. 优先队列比较符号搞反Compare 中如果写成 a->freq < b->freq,那么就是最大堆(频率大的优先),这样构建出来的树就不是最优的。记住:最小堆才能取出最小频率的节点,比较函数应返回 a.freq > b.freq

  2. 忘记处理只剩一个字符的情况:如果原始数据中只有一个字符,那么 while 循环不会执行,根节点就是该字符本身。此时 buildCodes 需要能正确处理,上面代码中 if 判断叶子节点会正确记录编码,但编码是空字符串(因为根就是叶子,code 初始为 "")。所以最好单独处理单字符情况:直接把编码设为 "0" 或 "1" 都可以,只要保证编码非空。

  3. 编码字典构建错误:递归时每次调用传入 code + "0"code + "1",但注意 + 操作会创建新的字符串,不会影响原来的 code。这是正确的,因为每条路径的编码是独立的。

  4. 内存泄漏:代码中用 new 创建了大量节点,但没有 delete。在实际项目中,应该在程序结束前递归释放所有节点。不过学习阶段为了代码简洁,可以暂时忽略。

完整可运行的示例(包括读取字符串并压缩)

下面是一个更完整的示例,它接受一个字符串,统计频率,生成哈夫曼编码,然后输出压缩后的二进制串和解码后的原文:

#include <iostream>
#include <queue>
#include <unordered_map>
#include <string>
using namespace std;

struct Node {
    char ch;           // 字符
    int freq;          // 频率
    Node *left, *right;
    Node(char c, int f) : ch(c), freq(f), left(nullptr), right(nullptr) {}
};

struct Compare {
    bool operator()(Node* a, Node* b) {
        return a->freq > b->freq; // 最小堆
    }
};

// 生成编码并放入映射表
void buildCodes(Node* root, string code, unordered_map<char, string>& codes) {
    if (!root) return;
    if (!root->left && !root->right) {
        codes[root->ch] = code;
        return;
    }
    buildCodes(root->left, code + "0", codes);
    buildCodes(root->right, code + "1", codes);
}

int main() {
    string text = "hello world"; // 要压缩的字符串
    // 1. 统计每个字符出现的频率
    unordered_map<char, int> freqMap;
    for (char ch : text) {
        freqMap[ch]++;
    }

    // 2. 将每个字符节点放入优先队列
    priority_queue<Node*, vector<Node*>, Compare> pq;
    for (auto& pair : freqMap) {
        pq.push(new Node(pair.first, pair.second));
    }

    // 处理只有一个不同字符的情况
    if (pq.size() == 1) {
        Node* single = pq.top();
        // 此时哈夫曼树只有一个节点,编码设为 "0"
        cout << "编码: " << single->ch << " -> 0" << endl;
        // 压缩后的二进制串:每个字符都是0,但为了演示,输出一串0
        string compressed(text.size(), '0');
        cout << "压缩后的二进制串: " << compressed << endl;
        // 解码
        string decoded(text.size(), single->ch);
        cout << "解码后的原文: " << decoded << endl;
        return 0;
    }

    // 构建哈夫曼树
    while (pq.size() > 1) {
        Node* left = pq.top(); pq.pop();
        Node* right = pq.top(); pq.pop();
        Node* parent = new Node('\0', left->freq + right->freq);
        parent->left = left;
        parent->right = right;
        pq.push(parent);
    }
    Node* root = pq.top();

    // 生成编码表
    unordered_map<char, string> codes;
    buildCodes(root, "", codes);

    // 输出每个字符的编码
    cout << "字符编码表:" << endl;
    for (auto& p : codes) {
        cout << "'" << p.first << "' -> " << p.second << endl;
    }

    // 3. 压缩:用编码替换每个字符,得到二进制串
    string compressed = "";
    for (char ch : text) {
        compressed += codes[ch];
    }
    cout << "压缩后的二进制串: " << compressed << endl;
    cout << "原字符串大小: " << text.size() * 8 << " 位" << endl;
    cout << "压缩后大小: " << compressed.size() << " 位" << endl;
    cout << "压缩率: " << (double)compressed.size() / (text.size() * 8) * 100 << "%" << endl;

    // 4. 解码(根据哈夫曼树反向还原)
    string decoded = "";
    Node* current = root;
    for (char bit : compressed) {
        if (bit == '0') {
            current = current->left;
        } else {
            current = current->right;
        }
        // 到达叶子节点
        if (!current->left && !current->right) {
            decoded += current->ch;
            current = root; // 回到根,继续解码下一个字符
        }
    }
    cout << "解码后的原文: " << decoded << endl;

    // 释放内存(略)
    return 0;
}

运行这段代码,你会看到类似以下的输出:

字符编码表:
'h' -> 000
'e' -> 001
'l' -> 01
'o' -> 10
' ' -> 110
'w' -> 1110
'r' -> 1111
'd' -> 110?  (根据树结构而定)
压缩后的二进制串: 0000010110010111100111110...
原字符串大小: 88 位
压缩后大小: 26 位
压缩率: 29.55%
解码后的原文: hello world

注意:因为哈夫曼树不是唯一的(相同频率的节点合并顺序可以不同),所以编码结果可能与上面的不同,但解码后一定得到原文。

相关知识点指引

  • 前缀编码:哈夫曼编码是一种前缀编码,即任何字符的编码都不是另一个字符编码的前缀。这保证了解码时不会产生歧义。可以思考:为什么编码不是前缀就能唯一解码?
  • 优先队列(堆):学习 std::priority_queue 的用法,掌握自定义比较函数,这对很多算法都很重要。
  • 树与递归:哈夫曼树的构建和遍历是树和递归的经典应用,适合巩固二叉树知识。
  • 数据压缩的其他方法:除了哈夫曼编码,还有 LZ77(Lempel-Ziv)、算术编码等。哈夫曼编码常用于 ZIP、PNG、JPEG 等格式。
  • 时间复杂度:构建哈夫曼树需要 O(n log n) 时间,n 为不同字符的个数。如果字符集很大(比如 Unicode),一般先用其他方法(如游程编码)简化。

现在,你可以试试用这段代码压缩一下自己写的一段短文,看看能压缩多少?记得先做“频率统计”这一步哦!

例题精讲

1单选题

在构建哈夫曼树时,每次选择两个权值最小的节点合并,这保证了最终得到的哈夫曼编码具有什么性质?

A编码长度唯一
B编码前缀唯一
C带权路径长度最小
D编码速度最快
2判断题

哈夫曼编码中,出现频率越高的字符分配的编码长度越短。

3填空题
计算哈夫曼树带权路径长度的递归函数:int huffmanWPL(Node* root, int depth) { if (root->left == nullptr && root->right == nullptr) return depth * root->freq; return ___; }