贪心算法从 0 开始 —— 第 5 章:哈夫曼编码 Huffman Coding
作者:就是滴九点半
标签:算法 / 贪心 / 哈夫曼树 / 优先队列 / JavaScript
✨ 前言
前面几章我们一直在解决路径选择问题:要么跳得最远,要么耗油最少。而这章,我们的目标从“选择路径”变成了“构造结构”。
本章介绍的 哈夫曼编码(Huffman Coding),是贪心算法在“构建最优树”方面的经典代表,用于数据压缩、编码、通信协议中。
📌 问题描述
给定一组字符及其出现频率,设计一种二进制编码方案,使得总编码长度最小。
要求:
- 每个字符的编码不能是其他字符编码的前缀(前缀码)
- 编码方式为:构造一棵二叉树,左子节点为
0
,右子节点为1
目标是:构造一棵哈夫曼树,从而得到最短的平均编码长度。
示例:
假设字符频率如下:
字符:A B C D E
频率:5 9 12 13 16
构造出的哈夫曼树如下:
ABCDE(55)
/ \
CD(25) ABE(30)
/ \ / \
C(12) D(13) AB(14) E(16)
/ \
A(5) B(9)
对应编码:
字符 | 编码 |
---|---|
A | 100 |
B | 101 |
C | 00 |
D | 01 |
E | 11 |
编码长度按频率加权求和,就是最小的总代价。
❓ 暴力法能做吗?
理论上可以穷举所有合法的前缀编码方案,比较总长度,找最短。但这涉及到指数级的排列组合,根本无法计算。
所以我们需要一个高效的策略 —— 贪心法 + 最小堆构造哈夫曼树。
✅ 哈夫曼编码的贪心策略
🎯 核心思想:
每次从当前的节点集合中,取出两个频率最小的节点,合并为一个新节点,其频率为两者之和。将新节点加入集合中,重复直到剩下一个根节点。
✅ 为什么贪心成立?
哈夫曼编码保证:
- 更“重”的节点(频率大的)离根更近
- 更“轻”的节点(频率小的)被优先合并,形成更深的路径
这会导致高频字符拥有较短的编码,低频字符拥有较长编码,总体长度最小。
它是“构造型贪心”:每一步做局部最优合并,构造出全局最优树
✅ JavaScript 实现
// 哈夫曼树节点类
class HuffmanNode {
constructor(char, freq) {
this.char = char; // 字符(叶节点才有)
this.freq = freq; // 频率
this.left = null; // 左子节点(编码0)
this.right = null; // 右子节点(编码1)
}
}
// 最小堆类,用于维护频率最小的节点
class MinHeap {
constructor() {
this.heap = [];
}
insert(node) {
this.heap.push(node);
this.bubbleUp(this.heap.length - 1);
}
bubbleUp(index) {
while (index > 0) {
let parent = Math.floor((index - 1) / 2);
if (this.heap[index].freq >= this.heap[parent].freq) break;
[this.heap[index], this.heap[parent]] = [
this.heap[parent],
this.heap[index],
];
index = parent;
}
}
extractMin() {
if (this.heap.length === 0) return null;
if (this.heap.length === 1) return this.heap.pop();
const min = this.heap[0];
this.heap[0] = this.heap.pop();
this.bubbleDown(0);
return min;
}
bubbleDown(index) {
const length = this.heap.length;
while (true) {
let left = 2 * index + 1;
let right = 2 * index + 2;
let smallest = index;
if (left < length && this.heap[left].freq < this.heap[smallest].freq) {
smallest = left;
}
if (right < length && this.heap[right].freq < this.heap[smallest].freq) {
smallest = right;
}
if (smallest === index) break;
[this.heap[index], this.heap[smallest]] = [
this.heap[smallest],
this.heap[index],
];
index = smallest;
}
}
size() {
return this.heap.length;
}
}
// 构建哈夫曼树
function buildHuffmanTree(chars, freqs) {
const minHeap = new MinHeap();
// 初始化节点并插入最小堆
for (let i = 0; i < chars.length; i++) {
minHeap.insert(new HuffmanNode(chars[i], freqs[i]));
}
// 合并节点直到只剩一个
while (minHeap.size() > 1) {
const left = minHeap.extractMin(); // 频率最小的节点
const right = minHeap.extractMin(); // 次小的节点
const newNode = new HuffmanNode(null, left.freq + right.freq);
newNode.left = left; // 左子节点分配0
newNode.right = right; // 右子节点分配1
minHeap.insert(newNode);
}
return minHeap.extractMin(); // 返回根节点
}
// 生成哈夫曼编码
function generateHuffmanCodes(root, code = "", codes = {}) {
if (!root) return;
// 叶节点,记录编码
if (root.char !== null) {
codes[root.char] = code || "0"; // 单一节点情况
}
// 左子节点加0,右子节点加1
generateHuffmanCodes(root.left, code + "0", codes);
generateHuffmanCodes(root.right, code + "1", codes);
return codes;
}
// 计算加权路径长度(总编码长度)
function calculateWPL(codes, freqs, chars) {
let wpl = 0;
for (let i = 0; i < chars.length; i++) {
wpl += freqs[i] * codes[chars[i]].length;
}
return wpl;
}
// 主函数
function huffmanCoding(chars, freqs) {
const root = buildHuffmanTree(chars, freqs);
const codes = generateHuffmanCodes(root);
const wpl = calculateWPL(codes, freqs, chars);
return { codes, wpl };
}
// 测试数据
const chars = ["A", "B", "C", "D", "E"];
const freqs = [5, 9, 12, 13, 16];
// 运行并输出结果
const { codes, wpl } = huffmanCoding(chars, freqs);
console.log("哈夫曼编码:");
for (const char in codes) {
console.log(`${char}: ${codes[char]}`);
}
console.log(`总编码长度 (WPL): ${wpl}`);
代码说明
- HuffmanNode 类:
- 定义哈夫曼树节点,包含字符(char)、频率(freq)、左子节点(left)和右子节点(right)。
- 叶节点存储字符,非叶节点 char 为 null。
- MinHeap 类:
- 实现最小堆,用于每次提取频率最小的两个节点。
- 提供插入(insert)、移除最小值(extractMin)、上浮(bubbleUp)和下沉(bubbleDown)操作。
- buildHuffmanTree 函数:
- 使用贪心算法:
- 初始化所有字符和频率为节点,插入最小堆。
- 每次提取两个频率最小的节点,合并为新节点(频率为两者之和),并将新节点插回堆。
- 合并时,左子节点(频率较小)分配 0,右子节点分配 1。
- 重复直到堆中只剩一个节点(根节点)。
- 使用贪心算法:
- generateHuffmanCodes 函数:
- 递归遍历哈夫曼树,左子节点加 0,右子节点加 1,生成每个字符的编码。
- 存储在 codes 对象中,确保前缀码性质。
- calculateWPL 函数:
- 计算加权路径长度(WPL),即总编码长度:∑(频率 × 编码长度)。
- 用于验证编码的最优性。
- huffmanCoding 函数:
- 整合构建树、生成编码和计算 WPL,返回编码和总长度。
运行结果对于输入:
- 字符:A, B, C, D, E
- 频率:5, 9, 12, 13, 16
输出:
哈夫曼编码:
A: 100
B: 101
C: 00
D: 01
E: 11
总编码长度 (WPL): 124
验证
- 前缀码:编码 A(100)、B(101)、C(00)、D(01)、E(11) 互不构成前缀,满足要求。
- 最优性:WPL = 5×3 + 9×3 + 12×2 + 13×2 + 16×2 = 15 + 27 + 24 + 26 + 32 = 124,是最优编码长度。
- B 的编码:如你之前确认,B 的编码为 101,与手算一致。
- 二叉树编码:左子节点为 0,右子节点为 1,符合要求。
哈夫曼树结构
ABCDE(55)
/ \
CD(25) ABE(30)
/ \ / \
C(12) D(13) AB(14) E(16)
/ \
A(5) B(9)
使用方法
- 复制代码到 JavaScript 环境(Node.js 或浏览器控制台)。
- 调用 huffmanCoding(chars, freqs),传入字符数组和频率数组。
- 输出包括每个字符的编码和总编码长度(WPL)。
扩展
- 如果需要可视化哈夫曼树,可以添加一个函数递归打印树结构。
- 如果需要处理特殊情况(如单一字符),代码已包含处理(codes[root.char] = code || ‘0’)。
- 若有其他字符和频率数据,直接修改 chars 和 freqs 数组即可。
📈 时间与空间复杂度分析
操作 | 复杂度 |
---|---|
构建哈夫曼树 | O(n log n) (n 次堆操作) |
生成编码 | O(n) (DFS) |
空间复杂度 | O(n) (树节点与编码表) |
✅ 小结
知识点 | 内容 |
---|---|
贪心策略 | 每次合并最小的两个频率 |
数据结构 | 优先队列 / 最小堆 |
应用场景 | 字符压缩、文件编码、二进制通信协议等 |
核心思想 | 局部最优合并,构造全局最优编码树 |
📚 结束
我是就是滴九点半,我们下期再见!
—— END ——