开始前,会这些就够了
- 会读树的根与叶、字符串和字典
- 先看 树、堆与优先队列 的最小堆
给高频字母更短的二进制编号,可以减少一段文本的编码位数。可是两个人都运行 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组频率,在两种平局次序下核对总成本、往返还原和无前缀冲突。
展开收起代码 · 小实验
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
"""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字符串长度直接当作内存字节数或压缩文件大小。 - 忽略空输入和单字符的明确约定。
自己试一小步
频率改变以后,如何验证你的码表仍正确?
- 自行选择五个字符的正整数频率,至少有两个相同。
- 改变平局顺序,分别记录各码长、总载荷位数和一段消息的解码结果。
- 另算发送码表所需的开销,并明确你的存储方式。
做到这些,就算完成
- 检查所有码字之间没有前缀关系。
- 总成本用频率乘码长逐项累加,不能只数不同字母。
- 把码表开销与消息载荷分别报告,并说明如何传递消息长度或结束标记。
查原始资料
想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。
- RFC 1951:前缀码与 DEFLATE 的码字规则 (新标签页)↗
支持前缀码的树表示、按频率最优及格式的附加规则。
- Algorithms 作者:Huffman.java (新标签页)↗
支持合并最小两棵树、码表与解码树需配套。
- Python 3.14:heapq 优先队列说明 (新标签页)↗
支持最小堆及相同优先级使用递增编号打破平局。