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

为什么最优:哈夫曼树是"带权路径最短"的二叉树,保证了平均码长最小(在"前缀码"约束下最优)。

名词解释

课后练习

  1. 哈夫曼为什么能保证"解码无歧义"?
    • 答案:它是前缀码——没有任何字符编码是别的编码的前缀;解码时沿树走到叶子即确定一字符,不会和更长的码混淆。
  2. 若所有字符频率相同,哈夫曼编码会怎样?
    • 答案:退化成接近定长编码(如等长二叉树),此时压缩收益很小;哈夫曼的价值正在于"利用频率不均"。

总结

哈夫曼压缩是"用结构换空间"这一思想最经典的教学案例。它用一个优雅的贪心(每次合并最小的两堆)建出一棵最优二叉树,让高频字符用短码、低频用长码,平均比特数降到理论下限。我特别想强调"前缀码"这个设计的巧思——正因为没有任何编码是别的编码的前缀,解码时不需要分隔符、不会歧义,这比"逗号分隔"高明太多。你会发现,ZIP、JPEG、MP3 的压缩核心都有哈夫曼的影子,它是现代数字世界的隐形基石。更要紧的是,这课展示了算法工程的典型范式:先定义目标(最小带权路径),再找结构(二叉树),最后用贪心构造——这种"目标→结构→算法"的思维,比记住建树步骤本身更有迁移价值。理解了哈夫曼,你看"压缩"不再神秘,而是"为常见事物分配更少比特"的聪明账本。