在日常开发中,文本文件、图片、音频的存储与传输都离不开压缩技术。如何将 "AAAAABCD" 这类高频字符重复出现的数据高效编码?1952年,David Huffman 提出的 霍夫曼编码(Huffman Coding) 利用 贪心策略 构建最优前缀码,使带权编码长度最小化,至今仍是 ZIP、JPEG、MP3 等格式的底层基础。本文用 Java 完整实现霍夫曼树构建、编码与解码全流程,深入讲解贪心正确性、前缀码特性与工程优化技巧。
从定长编码到变长编码的问题
假设字符集为 {A, B, C, D},各字符出现频率如下:
| 字符 | 出现次数 | 定长编码(2位) | 变长编码(直觉) |
|---|---|---|---|
| A | 45 | 00 | 0 |
| B | 13 | 01 | 1 |
| C | 12 | 10 | 00 |
| D | 10 | 11 | 01 |
定长编码总长度:2 × (45+13+12+10) = 160 位。
变长编码如果随意分配,可能出现 二义性:收到 001 时,无法判断是 A(0) + B(1) 还是 C(00) + ?。因此,编码必须满足 前缀码(Prefix Code) 性质——任意字符的编码都不是其他字符编码的前缀。
前缀码与二叉树的等价关系
前缀码可以用一棵 满二叉树 直观表示:
– 每个叶子节点代表一个字符
– 从根到叶子的路径中,左分支标记 0,右分支标记 1
– 叶子节点的深度即为该字符的编码长度
关键结论:当高频字符靠近根节点(编码短)、低频字符远离根节点(编码长)时,整棵树的带权路径长度最小。这正是霍夫曼编码的核心优化目标。
霍夫曼编码的贪心策略
霍夫曼算法的贪心选择极其简洁:
每次选择频率最低的两个节点,合并为一个新节点,新节点频率为两者之和,重复直至只剩一棵树。
为什么贪心能得到全局最优?
通过交换论证可证明:设 x 和 y 是频率最低的两个字符,则存在某棵最优前缀码树,使得 x 和 y 是深度最大且互为兄弟的叶子节点。将 x、y 合并后,问题规模缩小为 n-1,满足最优子结构。因此贪心选择是安全的。
完整 Java 实现
以下实现包含霍夫曼树节点定义、贪心构建、编码表生成、文本编码与解码的完整流程,可直接运行。
import java.util.*;
/**
* 霍夫曼编码完整实现
* 核心:贪心策略 + 优先队列(最小堆)构建最优前缀码
* 时间复杂度:O(n log n),n 为不同字符数量
*/
public class HuffmanCoding {
/**
* 霍夫曼树节点
* 叶子节点存储字符与频率,内部节点只存储合并后的频率
*/
static class HuffmanNode implements Comparable<HuffmanNode> {
char ch; // 字符(内部节点可用占位符)
int freq; // 出现频率
HuffmanNode left; // 左子树(编码0)
HuffmanNode right; // 右子树(编码1)
boolean isLeaf; // 是否为叶子节点
HuffmanNode(char ch, int freq) {
this.ch = ch;
this.freq = freq;
this.isLeaf = true;
}
HuffmanNode(int freq, HuffmanNode left, HuffmanNode right) {
this.freq = freq;
this.left = left;
this.right = right;
this.isLeaf = false;
this.ch = '\0';
}
@Override
public int compareTo(HuffmanNode other) {
return Integer.compare(this.freq, other.freq);
}
}
/**
* 步骤1:统计文本中各字符频率
* @param text 输入文本
* @return 字符到频率的映射
*/
public static Map<Character, Integer> buildFrequencyMap(String text) {
Map<Character, Integer> freqMap = new HashMap<>();
for (char c : text.toCharArray()) {
freqMap.merge(c, 1, Integer::sum);
}
return freqMap;
}
/**
* 步骤2:使用贪心策略构建霍夫曼树
* 每次从优先队列中取出频率最小的两个节点合并
* @param freqMap 字符频率映射
* @return 霍夫曼树的根节点
*/
public static HuffmanNode buildHuffmanTree(Map<Character, Integer> freqMap) {
// 最小堆:按频率升序排列
PriorityQueue<HuffmanNode> minHeap = new PriorityQueue<>();
// 将所有字符作为叶子节点入堆
for (Map.Entry<Character, Integer> entry : freqMap.entrySet()) {
minHeap.offer(new HuffmanNode(entry.getKey(), entry.getValue()));
}
// 贪心合并:每次取两个最小频率节点
while (minHeap.size() > 1) {
HuffmanNode left = minHeap.poll(); // 频率最小
HuffmanNode right = minHeap.poll(); // 频率次小
// 创建内部节点,频率为两者之和
HuffmanNode merged = new HuffmanNode(
left.freq + right.freq, left, right
);
minHeap.offer(merged);
}
return minHeap.poll(); // 返回树根
}
/**
* 步骤3:从霍夫曼树生成编码表
* DFS遍历,左分支追加0,右分支追加1
* @param root 霍夫曼树根节点
* @return 字符到编码的映射
*/
public static Map<Character, String> buildCodeTable(HuffmanNode root) {
Map<Character, String> codeTable = new HashMap<>();
if (root == null) return codeTable;
// 特殊情况:只有一个字符
if (root.isLeaf) {
codeTable.put(root.ch, "0");
return codeTable;
}
dfsBuildCode(root, new StringBuilder(), codeTable);
return codeTable;
}
private static void dfsBuildCode(HuffmanNode node, StringBuilder prefix,
Map<Character, String> codeTable) {
if (node == null) return;
if (node.isLeaf) {
// 叶子节点:保存当前路径作为该字符的编码
codeTable.put(node.ch, prefix.toString());
return;
}
// 向左:追加0
prefix.append('0');
dfsBuildCode(node.left, prefix, codeTable);
prefix.deleteCharAt(prefix.length() - 1);
// 向右:追加1
prefix.append('1');
dfsBuildCode(node.right, prefix, codeTable);
prefix.deleteCharAt(prefix.length() - 1);
}
/**
* 步骤4:将文本编码为二进制字符串
* @param text 原始文本
* @param codeTable 编码表
* @return 编码后的二进制字符串
*/
public static String encode(String text, Map<Character, String> codeTable) {
StringBuilder encoded = new StringBuilder();
for (char c : text.toCharArray()) {
encoded.append(codeTable.get(c));
}
return encoded.toString();
}
/**
* 步骤5:从二进制字符串解码为原文
* 从根节点出发,0向左、1向右,到达叶子即输出字符并回到根
* @param encoded 编码后的二进制字符串
* @param root 霍夫曼树根节点
* @return 解码后的原文
*/
public static String decode(String encoded, HuffmanNode root) {
StringBuilder decoded = new StringBuilder();
HuffmanNode current = root;
for (char bit : encoded.toCharArray()) {
if (bit == '0') {
current = current.left;
} else {
current = current.right;
}
if (current.isLeaf) {
decoded.append(current.ch);
current = root; // 回到根,开始解码下一个字符
}
}
return decoded.toString();
}
/**
* 可视化打印霍夫曼树结构与编码表
*/
public static void printTree(HuffmanNode node, String prefix) {
if (node == null) return;
if (node.isLeaf) {
System.out.printf(" 叶子: '%c' (频率=%d, 编码=%s)%n",
node.ch, node.freq, prefix.isEmpty() ? "0" : prefix);
} else {
System.out.printf(" 内部节点 (合并频率=%d)%n", node.freq);
printTree(node.left, prefix + "0");
printTree(node.right, prefix + "1");
}
}
// 主程序:完整演示
public static void main(String[] args) {
String text = "this is an example of a huffman tree";
System.out.println("=== 原始文本 ===");
System.out.println(text);
System.out.println("原始长度: " + text.length() * 8 + " 位(ASCII定长)\n");
// 1. 统计频率
Map<Character, Integer> freqMap = buildFrequencyMap(text);
System.out.println("=== 字符频率 ===");
freqMap.entrySet().stream()
.sorted(Map.Entry.<Character, Integer>comparingByValue().reversed())
.forEach(e -> System.out.printf(" '%c': %d%n", e.getKey(), e.getValue()));
// 2. 构建霍夫曼树
HuffmanNode root = buildHuffmanTree(freqMap);
System.out.println("\n=== 霍夫曼树结构 ===");
printTree(root, "");
// 3. 生成编码表
Map<Character, String> codeTable = buildCodeTable(root);
System.out.println("\n=== 编码表 ===");
codeTable.entrySet().stream()
.sorted(Comparator.comparingInt(e -> e.getValue().length()))
.forEach(e -> System.out.printf(" '%c' -> %s (长度=%d)%n",
e.getKey(), e.getValue(), e.getValue().length()));
// 4. 编码
String encoded = encode(text, codeTable);
System.out.println("\n=== 编码结果 ===");
System.out.println("编码长度: " + encoded.length() + " 位");
System.out.printf("压缩率: %.1f%%%n",
(1.0 - encoded.length() / (double)(text.length() * 8)) * 100);
// 5. 解码验证
String decoded = decode(encoded, root);
System.out.println("\n=== 解码验证 ===");
System.out.println("解码结果: " + decoded);
System.out.println("一致性: " + text.equals(decoded));
// 6. 边界测试:单字符重复
System.out.println("\n=== 边界测试:单字符重复 'AAAAA' ===");
String repeat = "AAAAA";
Map<Character, Integer> freq2 = buildFrequencyMap(repeat);
HuffmanNode root2 = buildHuffmanTree(freq2);
Map<Character, String> table2 = buildCodeTable(root2);
String enc2 = encode(repeat, table2);
String dec2 = decode(enc2, root2);
System.out.println("编码: " + enc2);
System.out.println("解码: " + dec2);
System.out.println("一致: " + repeat.equals(dec2));
}
}
关键代码解读
优先队列的贪心选择
while (minHeap.size() > 1) {
HuffmanNode left = minHeap.poll();
HuffmanNode right = minHeap.poll();
minHeap.offer(new HuffmanNode(left.freq + right.freq, left, right));
}
每次 poll() 取出频率最小的两个节点,保证低频字符被推向树的深处,高频字符靠近根节点。PriorityQueue 的底层是最小堆,使每次插入和取出均为 O(log n)。
前缀码的解码安全性
if (current.isLeaf) {
decoded.append(current.ch);
current = root;
}
由于霍夫曼树的叶子节点之间不存在祖先-后代关系,任何编码串都能被 唯一确定地 解析,不会出现 0 既是 A 的编码、又是 AB 前缀的歧义。
复杂度分析
| 阶段 | 时间复杂度 | 空间复杂度 | 说明 |
|---|---|---|---|
| 频率统计 | O(n) | O(k) | n 为文本长度,k 为不同字符数 |
| 构建霍夫曼树 | O(k log k) | O(k) | 优先队列的插入与取出 |
| 生成编码表 | O(k) | O(k) | DFS 遍历树的所有节点 |
| 编码过程 | O(n) | O(n) | 逐字符查表拼接 |
| 解码过程 | O(m) | O(1) | m 为编码串长度,逐位遍历树 |
当字符集较小(如 ASCII 的 128 个字符)时,k 为常数,整体接近线性性能。
扩展:从理论到工程实践
- 字节对齐:上述实现输出二进制字符串,实际存储时需按 8 位对齐为字节流,剩余位用填充标记处理。
- 序列化编码表:压缩文件必须附带编码表(或频率表),否则接收方无法解码。通常将编码表以头部元数据形式写入。
- 自适应霍夫曼编码:动态统计流数据频率并实时调整树结构,适用于网络传输等无法预先扫描全文的场景。
- 与算术编码对比:霍夫曼编码以整数位为单位分配码长,对概率极度倾斜的分布(如
A=99%, B=1%)不如算术编码紧凑,但实现简单、解码高效。
总结
霍夫曼编码是 贪心算法 的经典范例:每一步只关注局部最优(合并频率最低的两个节点),却能证明达到全局最优(最小带权路径长度)。掌握它不仅有助于理解数据压缩原理,更能训练对 最优子结构 与 贪心选择性质 的敏感度——这种思维在设计缓存淘汰策略、任务调度算法时同样适用。