Golang 哈夫曼树与哈夫曼编码
哈夫曼树与哈夫曼编码
一、哈夫曼树是什么
哈夫曼树(Huffman Tree),也叫最优二叉树,是一种带权路径长度最短的二叉树。它是 David Huffman 在 1952 年提出的,用于解决数据压缩问题。
核心思想非常朴素:出现频率越高的字符,给它越短的编码;出现频率越低的字符,给它越长的编码。这样整体编码长度就最小。
比如一篇英文文章中,字母 'e' 出现得最频繁,我们就给它分配最短的二进制编码;字母 'z' 很少出现,就给它分配较长的编码。这样整篇文章编码后的总比特数就比等长编码(比如每个字母都用 8 位 ASCII)要少得多。
关键术语
| 术语 | 含义 |
|---|---|
| 路径长度 | 从根节点到某节点的边数 |
| 带权路径长度(WPL) | 叶子节点的权值 × 该节点的路径长度 |
| 树的 WPL | 所有叶子节点的带权路径长度之和 |
| 哈夫曼树 | 在所有可能的二叉树中,WPL 最小的那棵 |
二、构建哈夫曼树
哈夫曼树的构建是一个自底向上的过程,它完美体现了贪心算法的思想——每次都选择当前最优的选择(频率最小的两个节点合并),最终得到全局最优解。
构建步骤
- 统计每个字符的出现频率,为每个字符创建一个叶子节点。
- 将所有节点放入一个最小优先队列(按频率排序)。
- 当队列中还有多个节点时,重复:
- 取出频率最小的两个节点
- 创建一个新节点,频率 = 两者频率之和
- 将两个节点作为新节点的左右子节点
- 把新节点放回队列
- 队列中只剩一个节点时,它就是哈夫曼树的根节点。
图解过程
假设有字符 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 是编码后的比特数,逐位遍历 |
六、哈夫曼编码的实际应用
- 文件压缩:ZIP、GZIP 等压缩格式都使用了哈夫曼编码作为核心算法之一。
- 图像压缩:JPEG 在量化步骤后对数据使用哈夫曼编码。
- 视频编码:MPEG、H.264 等视频编码标准都包含哈夫曼编码。
- 通信协议:HTTP/2 的 HPACK 头部压缩也使用了哈夫曼编码。
七、关键要点
- 贪心策略:每次取频率最小的两个节点合并,局部最优保证全局最优。
- 前缀码性质:任何字符的编码不是另一个字符编码的前缀,保证无歧义解码。
- 频率决定编码长度:频率越高编码越短,这是哈夫曼编码能压缩数据的根本原因。
- 哈夫曼编码不唯一:当两个节点频率相同时,谁左谁右可以互换;同一频率层的节点也可以交换。不同选择会产生不同的编码表,但 WPL 是相同的。
- 自底向上构建:和大多数树结构"先有根再长出叶子"的直觉相反,哈夫曼树是从叶子开始一层层往上合并的。

浙公网安备 33010602011771号