Huffman 压缩:从字符频率到最优前缀码
本节目标
- 理解哈夫曼编码的"高频短码、低频长码"思想
- 手写建树 + 编码 + 解码全流程
- 体会"用结构换空间"的压缩本质
思想:出现越多的字符,编码越短。用一棵二叉树,左 0 右 1,叶子是字符。构建时每次取频率最小的两节点合并,频率相加,反复直到成一棵树。
// 运行环境:Node.js 14+
// 保存为 aj-l15.js,执行:node aj-l15.js
class Huffman {
constructor(text) {
// 1) 统计频率
const freq = {}
for (const ch of text) freq[ch] = (freq[ch] || 0) + 1
// 2) 建树:最小堆取两个最小频率节点合并
let nodes = Object.entries(freq).map(([ch, f]) => ({ ch, f, left: null, right: null }))
while (nodes.length > 1) {
nodes.sort((a, b) => a.f - b.f)
const l = nodes.shift(), r = nodes.shift()
nodes.push({ ch: null, f: l.f + r.f, left: l, right: r })
}
this.root = nodes[0]
// 3) 生成编码表(遍历树)
this.code = {}
const walk = (n, prefix) => {
if (n.ch !== null) { this.code[n.ch] = prefix || '0'; return }
walk(n.left, prefix + '0'); walk(n.right, prefix + '1')
}
walk(this.root, '')
}
encode(text) { return text.split('').map(ch => this.code[ch]).join('') }
decode(bits) {
let cur = this.root, out = ''
for (const b of bits) {
cur = b === '0' ? cur.left : cur.right
if (cur.ch !== null) { out += cur.ch; cur = this.root }
}
return out
}
}
// 调用示例
const h = new Huffman('abracadabra')
const code = h.encode('abracadabra')
console.log('编码表:', h.code) // a:0 b:10 ...(高频更短)
console.log('编码:', code)
console.log('解码:', h.decode(code)) // abracadabra为什么最优:哈夫曼树是"带权路径最短"的二叉树,保证了平均码长最小(在"前缀码"约束下最优)。
名词解释
- 哈夫曼编码(Huffman Code):一种贪心构造的前缀码,高频字符短码、低频长码,平均码长最小。用于 ZIP、JPEG、MP3 等。
- 前缀码(Prefix Code):任何字符的编码都不是另一个字符编码的前缀,保证解码无歧义(无需分隔符)。
- 带权路径长度(WPL):每个叶子频率 × 到根距离 之和;哈夫曼树使其最小,即平均比特数最少。
课后练习
- 哈夫曼为什么能保证"解码无歧义"?
- 答案:它是前缀码——没有任何字符编码是别的编码的前缀;解码时沿树走到叶子即确定一字符,不会和更长的码混淆。
- 若所有字符频率相同,哈夫曼编码会怎样?
- 答案:退化成接近定长编码(如等长二叉树),此时压缩收益很小;哈夫曼的价值正在于"利用频率不均"。
总结
哈夫曼压缩是"用结构换空间"这一思想最经典的教学案例。它用一个优雅的贪心(每次合并最小的两堆)建出一棵最优二叉树,让高频字符用短码、低频用长码,平均比特数降到理论下限。我特别想强调"前缀码"这个设计的巧思——正因为没有任何编码是别的编码的前缀,解码时不需要分隔符、不会歧义,这比"逗号分隔"高明太多。你会发现,ZIP、JPEG、MP3 的压缩核心都有哈夫曼的影子,它是现代数字世界的隐形基石。更要紧的是,这课展示了算法工程的典型范式:先定义目标(最小带权路径),再找结构(二叉树),最后用贪心构造——这种"目标→结构→算法"的思维,比记住建树步骤本身更有迁移价值。理解了哈夫曼,你看"压缩"不再神秘,而是"为常见事物分配更少比特"的聪明账本。