贪心算法从 0 开始 —— 第 5 章:哈夫曼编码 Huffman Coding

贪心算法从 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)

对应编码:

字符编码
A100
B101
C00
D01
E11

编码长度按频率加权求和,就是最小的总代价。


❓ 暴力法能做吗?

理论上可以穷举所有合法的前缀编码方案,比较总长度,找最短。但这涉及到指数级的排列组合,根本无法计算。

所以我们需要一个高效的策略 —— 贪心法 + 最小堆构造哈夫曼树


✅ 哈夫曼编码的贪心策略

🎯 核心思想:

每次从当前的节点集合中,取出两个频率最小的节点,合并为一个新节点,其频率为两者之和。将新节点加入集合中,重复直到剩下一个根节点。

✅ 为什么贪心成立?

哈夫曼编码保证:

  • 更“重”的节点(频率大的)离根更近
  • 更“轻”的节点(频率小的)被优先合并,形成更深的路径

这会导致高频字符拥有较短的编码,低频字符拥有较长编码,总体长度最小。

它是“构造型贪心”:每一步做局部最优合并,构造出全局最优树


✅ 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}`);

代码说明

  1. HuffmanNode 类:
    • 定义哈夫曼树节点,包含字符(char)、频率(freq)、左子节点(left)和右子节点(right)。
    • 叶节点存储字符,非叶节点 char 为 null。
  2. MinHeap 类:
    • 实现最小堆,用于每次提取频率最小的两个节点。
    • 提供插入(insert)、移除最小值(extractMin)、上浮(bubbleUp)和下沉(bubbleDown)操作。
  3. buildHuffmanTree 函数:
    • 使用贪心算法:
      • 初始化所有字符和频率为节点,插入最小堆。
      • 每次提取两个频率最小的节点,合并为新节点(频率为两者之和),并将新节点插回堆。
      • 合并时,左子节点(频率较小)分配 0,右子节点分配 1。
      • 重复直到堆中只剩一个节点(根节点)。
  4. generateHuffmanCodes 函数:
    • 递归遍历哈夫曼树,左子节点加 0,右子节点加 1,生成每个字符的编码。
    • 存储在 codes 对象中,确保前缀码性质。
  5. calculateWPL 函数:
    • 计算加权路径长度(WPL),即总编码长度:∑(频率 × 编码长度)。
    • 用于验证编码的最优性。
  6. 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)

使用方法

  1. 复制代码到 JavaScript 环境(Node.js 或浏览器控制台)。
  2. 调用 huffmanCoding(chars, freqs),传入字符数组和频率数组。
  3. 输出包括每个字符的编码和总编码长度(WPL)。

扩展

  • 如果需要可视化哈夫曼树,可以添加一个函数递归打印树结构。
  • 如果需要处理特殊情况(如单一字符),代码已包含处理(codes[root.char] = code || ‘0’)。
  • 若有其他字符和频率数据,直接修改 chars 和 freqs 数组即可。

📈 时间与空间复杂度分析

操作复杂度
构建哈夫曼树O(n log n)(n 次堆操作)
生成编码O(n)(DFS)
空间复杂度O(n)(树节点与编码表)

✅ 小结

知识点内容
贪心策略每次合并最小的两个频率
数据结构优先队列 / 最小堆
应用场景字符压缩、文件编码、二进制通信协议等
核心思想局部最优合并,构造全局最优编码树

📚 结束

我是就是滴九点半,我们下期再见!

—— END ——

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值