qinghuan
← 回到知识探索

计算机趣闻 / 可下载小项目

两份 Huffman 码表长得不一样,为什么都可能一样省?

四个同频字母能得到不同的 00、01、10、11 分配。检查前缀、解码与总位数,区分最优成本、确定顺序和完整压缩格式。

展开本文目录
  1. 下载完整练习包
  2. 为何变长编号还能连续读?
  3. 每次合并最轻两棵树,累积了什么费用?
  4. 平局如何影响码表,为什么解码者必须拿到同一份?
  5. 独立检查不再合并最小两棵树
  6. 只有一种字母时,一位约定并非理论下界
  7. 压缩账本还缺哪些项目?
  8. 记住这三点
  9. 别把例子用过头
  10. 自己试一小步
  11. 查原始资料
开始前,会这些就够了
遇到不熟悉的词,先看这 2 个解释
前缀码
没有一个完整码字是另一个完整码字的开头。
平局规则
两项优先级相同时,用额外顺序决定先取谁。
打开完整术语小抄 ↗

给高频字母更短的二进制编号,可以减少一段文本的编码位数。可是两个人都运行 Huffman 算法,却给同一字母分了不同码字:这不一定说明其中一方有错。

本文把“能正确还原”“花了多少位”“每次是否给同一份码表”分成三张检查表。实验只统计消息载荷的比特数量;码表本身、文件头和字节对齐没有免费消失。

把例子带到自己的电脑上

下载完整练习包

需要 Python 3.10 或更新版本,无第三方依赖;制作时实测 Windows / Python 3.14.0。使用标准库,不需要安装第三方包。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。

下载 ZIP · 5 个文件
包里有什么
  • huffman.py:完整可运行示例
  • README.md:运行说明与独立练习
  • observed.stdout.txt:本次实际输出
  • verification.json:源码和产物验证记录
  • SHA256SUMS.txt:内容摘要

在这个文件夹打开 PowerShell,逐行运行

python --version
python -B huffman.py

解压到独立练习文件夹,在含 huffman.py 的目录打开终端运行。原创二进制前缀码教学程序,最多64单码点符号;只算载荷位数;单符号0码;独立树分割参考最多6符号;非DEFLATE实现。

找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。

为何变长编号还能连续读?

如果 A 的码是 0,B 的码是 01,读到一个 0 时就不知它是完整的 A,还是 B 的开头。前缀码要求任何一个完整码字都不是另一个码字的开头。把字母只放在二叉树叶子,沿左边记0、右边记1,就自然满足这个要求。

解码者从树根沿比特行走,到叶子输出一个字母,然后回到根。本文会拒绝出现不在树中的路线,以及停在树中间的截断码字。但一段损坏比特仍可能变成另一段合法文本;前缀码不是完整性校验。

每次合并最轻两棵树,累积了什么费用?

一开始每个字母自己是一棵树,重量是出现次数。取出最轻的两棵,给它们加一个共同父节点,再把合并重量放回最小堆。每做一次合并,这两棵树内所有字母的码长都多一位,新增费用正好是两棵树重量之和。

Huffman 的最优性针对已知频率、逐符号二进制前缀编码的加权码长。它不保证最好的字典压缩、上下文模型或任何压缩格式都采用同样结果,也不保证每个字母的码字唯一。

平局如何影响码表,为什么解码者必须拿到同一份?

A、B、C、D 各出现一次。按字母正序进入平局队列,可以得到 A=00、B=01、C=10、D=11;倒序进入则把对应关系反过来。两者都是两位一字母,四个字母总计八位,但同一比特串可能读出不同文本。

程序用递增编号作为相同重量的第二比较键,避免去比较有时是字母、有时是树节点的对象。固定初始字母顺序和入堆顺序能让本程序重跑得到同一份结果;这不是所有 Huffman 实现共享的标准。DEFLATE 还规定同长度码字和字母次序等附加规则,本文未实现这些格式规则。

独立检查不再合并最小两棵树

运行 python -B huffman.py。forward_ties 与 reverse_ties 的码表不同,bit_costs 都是8。随后看 singleton 的单字符约定:Q 编成0,因此五个Q占五位。空输入对应空码表与空载荷。

核对器将符号集合尝试分成所有可能的左右子集,每次分层增加全部子符号的重量,用枚举全部根分割的办法找最低树成本。它不使用最小堆,也不执行最轻两项贪心。程序检查一到五个符号、每项频率1到3的全部363组频率,在两种平局次序下核对总成本、往返还原和无前缀冲突。

展开收起代码 · 小实验python · 137 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

小实验 · python
"""Huffman ties: different valid codes can have the same optimal bit cost."""
import heapq
import itertools
import json
from functools import lru_cache


def build_codes(weights, reverse_ties=False):
    if len(weights) > 64 or any(not isinstance(k, str) or len(k) != 1 for k in weights):
        raise ValueError("at most 64 single-character symbols")
    if any(type(v) is not int or v <= 0 for v in weights.values()):
        raise ValueError("positive integer frequencies required")
    if not weights:
        return {}
    serial = itertools.count()
    heap = [(weights[k], next(serial), k) for k in sorted(weights, reverse=reverse_ties)]
    heapq.heapify(heap)
    while len(heap) > 1:
        wa, _, a = heapq.heappop(heap)
        wb, _, b = heapq.heappop(heap)
        heapq.heappush(heap, (wa + wb, next(serial), (a, b)))
    codes = {}
    def visit(node, prefix):
        if isinstance(node, str):
            codes[node] = prefix or "0"  # Explicit single-symbol convention.
        else:
            visit(node[0], prefix + "0")
            visit(node[1], prefix + "1")
    visit(heap[0][2], "")
    return codes


def decode(bits, codes):
    if any(bit not in "01" for bit in bits):
        raise ValueError("non-binary payload")
    if not codes:
        if bits:
            raise ValueError("payload without codebook")
        return ""
    trie = {}
    for symbol, code in codes.items():
        if not code or any(c not in "01" for c in code):
            raise ValueError("invalid code")
        node = trie
        for bit in code:
            if "symbol" in node:
                raise ValueError("prefix conflict")
            node = node.setdefault(bit, {})
        if node:
            raise ValueError("duplicate or prefix conflict")
        node["symbol"] = symbol
    result, node = [], trie
    for bit in bits:
        if bit not in node:
            raise ValueError("unknown prefix")
        node = node[bit]
        if "symbol" in node:
            result.append(node["symbol"])
            node = trie
    if node is not trie:
        raise ValueError("truncated code")
    return "".join(result)


def exhaustive_tree_cost(weights):
    # Independent oracle: enumerate every root split of the symbol set.
    # Each split increases every descendant depth by one; no greedy merging.
    values = tuple(weights.values())
    if len(values) > 6:
        raise ValueError("oracle limited to six symbols")
    @lru_cache(None)
    def cost(mask):
        if mask & (mask - 1) == 0:
            return 0
        first, sub, choices = mask & -mask, (mask - 1) & mask, []
        while sub:
            if sub & first and sub != mask:
                choices.append(cost(sub) + cost(mask ^ sub))
            sub = (sub - 1) & mask
        total = sum(v for i, v in enumerate(values) if mask >> i & 1)
        return total + min(choices)
    if len(values) <= 1:
        return sum(values)  # Match the explicit one-bit singleton convention.
    return cost((1 << len(values)) - 1)


def main():
    checks, cases = 0, 0
    def check(ok):
        nonlocal checks
        if not ok:
            raise AssertionError("Huffman check failed")
        checks += 1
    for n in range(1, 6):
        for values in itertools.product((1, 2, 3), repeat=n):
            weights = dict(zip("ABCDE", values))
            message = "".join(k * v for k, v in weights.items())
            target = exhaustive_tree_cost(weights)
            for reverse in (False, True):
                codes = build_codes(weights, reverse)
                bits = "".join(codes[k] for k in message)
                check(len(bits) == target)
                check(decode(bits, codes) == message)
                check(all(not b.startswith(a) for i, a in enumerate(codes.values())
                          for j, b in enumerate(codes.values()) if i != j))
            cases += 1
    check(build_codes({}) == {})
    check(decode("", {}) == "")
    check(build_codes({"Q": 5}) == {"Q": "0"})
    for weights in [{"A": 0}, {"A": True}, {"AB": 2}]:
        try:
            build_codes(weights)
        except ValueError:
            check(True)
        else:
            raise AssertionError("invalid frequency accepted")
    for bits, codes in [("1", {"A": "0"}), ("0", {"A": "00", "B": "01"}),
                        ("x", {"A": "0"}), ("0", {"A": "0", "B": "01"})]:
        try:
            decode(bits, codes)
        except ValueError:
            check(True)
        else:
            raise AssertionError("invalid bitstream or codebook accepted")
    weights = {"A": 1, "B": 1, "C": 1, "D": 1}
    forward, reverse = build_codes(weights), build_codes(weights, True)
    check(forward != reverse)
    print(json.dumps({"checks_passed": checks, "frequency_cases": cases,
                      "forward_ties": forward, "reverse_ties": reverse,
                      "bit_costs": [sum(len(c[k]) * v for k, v in weights.items())
                                    for c in (forward, reverse)],
                      "singleton": build_codes({"Q": 5}),
                      "scope": "payload bit cost only; codebook storage excluded; not a DEFLATE codec"}, indent=2))


if __name__ == "__main__":
    main()

怎样运行:在独立练习文件夹中执行 python -B huffman.py。2026-10-18 已在 Windows、Python 3.14.0 运行此份源码并通过自带检查。原创二进制前缀码教学程序,最多64单码点符号;只算载荷位数;单符号0码;独立树分割参考最多6符号;非DEFLATE实现。

运行后对照结果
{
  "checks_passed": 2189,
  "frequency_cases": 363,
  "forward_ties": {
    "A": "00",
    "B": "01",
    "C": "10",
    "D": "11"
  },
  "reverse_ties": {
    "D": "00",
    "C": "01",
    "B": "10",
    "A": "11"
  },
  "bit_costs": [
    8,
    8
  ],
  "singleton": {
    "Q": "0"
  },
  "scope": "payload bit cost only; codebook storage excluded; not a DEFLATE codec"
}

只有一种字母时,一位约定并非理论下界

只有 Q 一种符号时,若额外知道消息长度,理论上可以让它用空码字,靠次数恢复。本文为了让每个码字非空,明确使用0;因此单符号分支只按这个约定核对,不把五位宣称为所有编码方案中的绝对最小值。

空输入无需编码任何符号。源码会拒绝零或负频率、布尔频率、不是单字符的符号和超过64种的字母表。这里的字符是 Python 字符串中的单个码点,并不等同于 UTF-8 的一个字节。

压缩账本还缺哪些项目?

对 k 个不同符号,用堆建树的主要选择成本是 O(k log k)。本例还用字符串拼接构造码字,保存码表的总长度最坏可到 O(k²);编码消息再按实际输出位数 B 花费 O(B)。程序把比特保存在字符字符串里,真实内存大小并不是 B/8 字节。

完整文件需要让解码者知道码表、原文长度或停止条件,还要处理字节对齐、格式标记和损坏检测。本篇没有生成 ZIP、DEFLATE 或其他压缩文件;下载练习包的 ZIP 只是分发容器,不是本例压缩算法的产物。

记住这三点

  • 最优总位数不要求码字唯一。
  • 平局处理决定复现顺序,编码与解码必须共享码表。
  • 载荷位数、码表成本和真实文件大小是不同指标。

别把例子用过头

  • 只比较某个字母的码字就判算法错误。
  • 把0/1字符串长度直接当作内存字节数或压缩文件大小。
  • 忽略空输入和单字符的明确约定。

自己试一小步

频率改变以后,如何验证你的码表仍正确?

  1. 自行选择五个字符的正整数频率,至少有两个相同。
  2. 改变平局顺序,分别记录各码长、总载荷位数和一段消息的解码结果。
  3. 另算发送码表所需的开销,并明确你的存储方式。

做到这些,就算完成

  • 检查所有码字之间没有前缀关系。
  • 总成本用频率乘码长逐项累加,不能只数不同字母。
  • 把码表开销与消息载荷分别报告,并说明如何传递消息长度或结束标记。

查原始资料

想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。