AIGC标识 Golang 哈夫曼树与哈夫曼编码

哈夫曼树与哈夫曼编码


一、哈夫曼树是什么

哈夫曼树(Huffman Tree),也叫最优二叉树,是一种带权路径长度最短的二叉树。它是 David Huffman 在 1952 年提出的,用于解决数据压缩问题。

核心思想非常朴素:出现频率越高的字符,给它越短的编码;出现频率越低的字符,给它越长的编码。这样整体编码长度就最小。

比如一篇英文文章中,字母 'e' 出现得最频繁,我们就给它分配最短的二进制编码;字母 'z' 很少出现,就给它分配较长的编码。这样整篇文章编码后的总比特数就比等长编码(比如每个字母都用 8 位 ASCII)要少得多。

关键术语

术语 含义
路径长度 从根节点到某节点的边数
带权路径长度(WPL) 叶子节点的权值 × 该节点的路径长度
树的 WPL 所有叶子节点的带权路径长度之和
哈夫曼树 在所有可能的二叉树中,WPL 最小的那棵

二、构建哈夫曼树

哈夫曼树的构建是一个自底向上的过程,它完美体现了贪心算法的思想——每次都选择当前最优的选择(频率最小的两个节点合并),最终得到全局最优解。

构建步骤

  1. 统计每个字符的出现频率,为每个字符创建一个叶子节点。
  2. 将所有节点放入一个最小优先队列(按频率排序)。
  3. 当队列中还有多个节点时,重复:
    • 取出频率最小的两个节点
    • 创建一个新节点,频率 = 两者频率之和
    • 将两个节点作为新节点的左右子节点
    • 把新节点放回队列
  4. 队列中只剩一个节点时,它就是哈夫曼树的根节点。

图解过程

假设有字符 A(5), B(9), C(12), D(13), E(16), F(45),括号里是频率。

第1步: 取出最小的两个 A(5) + B(9) → 合并为 AB(14)
        队列: C(12), D(13), AB(14), E(16), F(45)

第2步: 取出 C(12) + D(13) → 合并为 CD(25)
        队列: AB(14), E(16), CD(25), F(45)

第3步: 取出 AB(14) + E(16) → 合并为 ABE(30)
        队列: CD(25), ABE(30), F(45)

第4步: 取出 CD(25) + ABE(30) → 合并为 CDABE(55)
        队列: F(45), CDABE(55)

第5步: 取出 F(45) + CDABE(55) → 合并为根(100)
        队列: 根(100)

最终得到的树:

          (100)
         /     \
      F(45)   (55)
             /    \
         CD(25)  ABE(30)
         /   \    /    \
      C(12) D(13)AB(14) E(16)
                 /   \
              A(5)  B(9)

三、哈夫曼编码

构建好哈夫曼树后,编码规则非常自然:从根出发,往左走记 0,往右走记 1,到达某个叶子节点时,沿途记录的 0/1 序列就是这个字符的哈夫曼编码。

编码表

以上面的树为例:

字符 频率 路径 编码 编码长度
A 5 根→右→右→左→左 1100 4
B 9 根→右→右→左→右 1101 4
C 12 根→右→左→左 100 3
D 13 根→右→左→右 101 3
E 16 根→右→右→右 111 3
F 45 根→左 0 1

可以看到,频率最高的 F 只用 1 位编码,而频率最低的 A 用了 4 位。整体编码效率大幅提升。

前缀码性质

哈夫曼编码是前缀码(Prefix Code),意味着任何一个字符的编码都不是另一个字符编码的前缀。这保证了编码可以无歧义地解码

例如收到比特流 011011100

  • 遇到 0 → F
  • 遇到 1100 → A
  • 遇到 1101 → B
  • 遇到 111 → E
  • 遇到 0 → F
  • 解码结果:F A B E F

解码时不需要分隔符,直接从根出发,遇到 0 往左走、遇到 1 往右走,走到叶子就输出一个字符然后回到根。


四、Go 语言实现要点

使用 container/heap 实现最小堆

Go 标准库提供了 container/heap 接口,只需实现五个方法就能得到一个功能完备的堆:

type HuffmanHeap []*HuffmanNode

func (h HuffmanHeap) Len() int           { return len(h) }
func (h HuffmanHeap) Less(i, j int) bool { return h[i].Freq < h[j].Freq }
func (h HuffmanHeap) Swap(i, j int)      { h[i], h[j] = h[j], h[i] }
func (h *HuffmanHeap) Push(x any)        { *h = append(*h, x.(*HuffmanNode)) }
func (h *HuffmanHeap) Pop() any {
    old := *h
    n := len(old)
    x := old[n-1]
    *h = old[:n-1]
    return x
}

生成编码表

从根节点出发,深度优先遍历整棵树,沿途记录路径。到达叶子节点时,路径字符串就是这个字符的编码:

func generateCodes(node *HuffmanNode, prefix string, codes map[rune]string) {
    if node == nil {
        return
    }
    // 叶子节点:有字符值
    if node.Left == nil && node.Right == nil {
        codes[node.Char] = prefix
        return
    }
    // 非叶子节点:继续向下,左 0 右 1
    generateCodes(node.Left, prefix+"0", codes)
    generateCodes(node.Right, prefix+"1", codes)
}

解码

从根节点开始,逐个读取比特位:0 走左边,1 走右边。到达叶子节点后输出字符,然后回到根节点继续。

func decode(encoded string, root *HuffmanNode) string {
    var result []rune
    curr := root
    for _, bit := range encoded {
        if bit == '0' {
            curr = curr.Left
        } else {
            curr = curr.Right
        }
        if curr.Left == nil && curr.Right == nil {
            result = append(result, curr.Char)
            curr = root
        }
    }
    return string(result)
}

五、复杂度分析

操作 时间复杂度 说明
统计频率 O(n) 遍历一次输入字符串
建堆 O(k log k) k 是不同字符数,通常远小于 n
构建哈夫曼树 O(k log k) 每次取出两个最小频率节点,共 k-1 次合并
生成编码表 O(k) 遍历哈夫曼树的所有节点
编码 O(n) 每个字符查表替换
解码 O(m) m 是编码后的比特数,逐位遍历

六、哈夫曼编码的实际应用

  1. 文件压缩:ZIP、GZIP 等压缩格式都使用了哈夫曼编码作为核心算法之一。
  2. 图像压缩:JPEG 在量化步骤后对数据使用哈夫曼编码。
  3. 视频编码:MPEG、H.264 等视频编码标准都包含哈夫曼编码。
  4. 通信协议:HTTP/2 的 HPACK 头部压缩也使用了哈夫曼编码。

七、关键要点

  1. 贪心策略:每次取频率最小的两个节点合并,局部最优保证全局最优。
  2. 前缀码性质:任何字符的编码不是另一个字符编码的前缀,保证无歧义解码。
  3. 频率决定编码长度:频率越高编码越短,这是哈夫曼编码能压缩数据的根本原因。
  4. 哈夫曼编码不唯一:当两个节点频率相同时,谁左谁右可以互换;同一频率层的节点也可以交换。不同选择会产生不同的编码表,但 WPL 是相同的。
  5. 自底向上构建:和大多数树结构"先有根再长出叶子"的直觉相反,哈夫曼树是从叶子开始一层层往上合并的。
posted @ 2026-07-30 09:35  FfHUCisI  阅读(17)  评论(0)    收藏  举报