一、哈夫曼树:从数据压缩说起

我记得刚入行那会儿,第一次接触哈夫曼树是在一个嵌入式项目里。当时要往一块只有 64KB Flash 的芯片上塞一套字库,怎么都装不下。后来用了哈夫曼编码压缩,硬是把字库压到了 40KB 以内。嗯,从那以后我就记住了——哈夫曼树这东西,关键时刻真能救命。

说白了,哈夫曼树是一种特殊的二叉树。它不是为了查找而生的,而是为了「最优编码」而生的。你想想看,如果我们要给一堆字符设计二进制编码,怎么让总长度最短?哈夫曼树就是干这个的。

1.1 什么是哈夫曼树

先给个正式定义:哈夫曼树(Huffman Tree),也叫最优二叉树,是带权路径长度(WPL)最小的二叉树

什么叫「带权路径长度」?我习惯用一个公式来理解:

WPL = Σ (每个叶子节点的权值 × 该节点到根节点的路径长度)

举个例子。假设我们有三个叶子节点,权值分别是 2、3、5。如果把它们排成一条直线(类似链表),那 WPL 就是 2×2 + 3×2 + 5×2 = 20。但如果用哈夫曼算法构造一棵树,WPL 能降到 2×2 + 3×1 + 5×1 = 12。差距很明显吧?

核心要点:哈夫曼树追求的是「高频字符路径短,低频字符路径长」。这个思想贯穿了整个数据压缩领域。

1.2 构造哈夫曼树的算法

构造过程其实不复杂。我给大家拆解一下:

  1. 初始化:把所有节点看成独立的树,每棵树只有一个根节点(就是它自己)。
  2. 选最小的两个:从森林中选出权值最小的两棵树。
  3. 合并:用一个新的根节点把这两棵树连起来,新根节点的权值等于两棵子树权值之和。
  4. 重复:把新树放回森林,继续找最小的两棵,直到只剩一棵树。

这个过程,我建议你拿纸笔走一遍。我在项目里调试哈夫曼编码时,就经常手算一遍来验证代码逻辑。

来看代码实现:

// 哈夫曼树节点结构
typedef struct HuffmanNode {
    int weight;                // 权值
    int parent, left, right;   // 父节点和左右孩子下标
} HuffmanNode;

// 构造哈夫曼树
void buildHuffmanTree(HuffmanNode *tree, int *weights, int n) {
    // 初始化所有节点
    for (int i = 0; i < 2 * n - 1; i++) {
        tree[i].parent = -1;
        tree[i].left = -1;
        tree[i].right = -1;
    }
    for (int i = 0; i < n; i++) {
        tree[i].weight = weights[i];
    }

    // 循环合并
    for (int i = n; i < 2 * n - 1; i++) {
        // 找两个权值最小且没有父节点的节点
        int min1 = -1, min2 = -1;
        for (int j = 0; j < i; j++) {
            if (tree[j].parent == -1) {
                if (min1 == -1 || tree[j].weight < tree[min1].weight) {
                    min2 = min1;
                    min1 = j;
                } else if (min2 == -1 || tree[j].weight < tree[min2].weight) {
                    min2 = j;
                }
            }
        }

        // 合并 min1 和 min2
        tree[i].weight = tree[min1].weight + tree[min2].weight;
        tree[i].left = min1;
        tree[i].right = min2;
        tree[min1].parent = i;
        tree[min2].parent = i;
    }
}

小技巧:实际编码时,我习惯用最小堆(优先队列)来优化找最小两个节点的过程。这样时间复杂度能从 O(n²) 降到 O(n log n)。

1.3 哈夫曼编码的原理

哈夫曼编码是哈夫曼树最经典的应用。原理其实很简单:

  • 从根节点出发,往左走记 0,往右走记 1
  • 走到叶子节点时,路径上的 0 和 1 就组成了该字符的编码
  • 因为每个字符都是叶子节点,所以编码是前缀码——没有任何一个编码是另一个编码的前缀

这一点特别重要。我曾经在项目中遇到过一个问题:用了一种非前缀码的编码方案,结果解码时怎么都对不上。后来换成哈夫曼编码,问题立刻解决了。前缀码保证了「唯一可译性」,这是数据压缩的基础。

来看一个具体的例子。假设我们有 5 个字符,出现频率如下:

字符 A B C D E
频率 5 4 3 2 1

构造哈夫曼树后,得到的编码可能是:

  • A: 00
  • B: 01
  • C: 10
  • D: 110
  • E: 111

你看,频率最高的 A 和 B 只用了 2 位,而频率最低的 D 和 E 用了 3 位。这就是哈夫曼编码的精髓——用最短的编码表示最常出现的内容

注意:哈夫曼编码不是唯一的。同样的频率分布,如果合并时选择不同的两个最小节点,或者左右子树交换位置,都会得到不同的编码树。但它们的 WPL 是一样的,都是最优的。

1.4 知识体系总览

下面这张图是我自己整理的哈夫曼树知识结构,方便你快速把握全局:

哈夫曼树知识体系 哈夫曼树 定义:最优二叉树 构造算法 哈夫曼编码 WPL = Σ(权值 × 路径长度) 高频字符路径短 初始化 → 选最小两个 合并 → 重复直到一棵树 左0右1,叶子节点即编码 前缀码,唯一可译 核心思想:用最短编码表示最常出现的内容

这张图把哈夫曼树的三个核心方面串起来了。我个人建议你从「定义」入手,理解 WPL 的概念,然后看「构造算法」怎么实现,最后再理解「编码」是怎么从树里提取出来的。这个顺序学起来最顺。

好了,哈夫曼树的上半部分就讲到这里。下一节我们会深入讨论哈夫曼编码的具体实现、解码过程,以及它在实际项目中的应用场景。到时候我会分享一些我在压缩工具开发中踩过的坑,希望对你有帮助。


公众号:蓝海资料掘金营,微信deep3321