算术编码:把整条消息编成一个小数
这是全书第一台真正踩到地板上的机器。它的想法和霍夫曼完全不同——它不做码表,不分配码字,它只做一件事:把一条线段反复切窄。
用算术编码压一段 2053 个字符的英文(零阶模型,熵地板 8287 比特)。真编出来的比特串,比理论地板多多少?
换一个完全不同的想法
忘掉码表,忘掉树。看这条线段:
现在,编码一条消息的规则只有一句:
读到一个符号,就把当前区间按同样的比例再切一遍,然后只保留那个符号对应的那一段。
读完整条消息,你手上剩下一个很窄的区间。报出落在这个区间里的任意一个数,编码就完成了。
手跟一遍,消息是 AAB:
起始区间 [0.0000, 1.0000) 宽 1.0000 读到 A → 取前 70% [0.0000, 0.7000) 宽 0.7000 读到 A → 再取前 70% [0.0000, 0.4900) 宽 0.4900 读到 B → 取 70%~90% [0.3430, 0.4410) 宽 0.0980 # 最终区间宽度 = 0.7 × 0.7 × 0.2 = 0.098 # 而 0.098 正是【这条消息的概率】。 # 要报出一个落在 [0.3430, 0.4410) 里的数,需要多少比特? −log₂(0.098) = 3.351 比特 # 而这三个符号的自信息之和: 0.515 + 0.515 + 2.322 = 3.351 比特 ← 一模一样
最终区间的宽度 = 每一步概率的乘积 = 整条消息的概率 P(消息)。
而要指定一个落在宽度 w 的区间里的数,需要约 −log₂ w 比特。
于是:比特数 = −log₂ P(消息) = Σ(−log₂ p(每个符号))。
这正好是每个符号的自信息之和,也正好是熵乘以长度。取整只在最后报数的时候发生一次,不是每个符号发生一次。第 7 章那笔税,就这么被摊到了整条消息上。
下面这台是真的:它一步步给你看区间怎么缩,最后跑一个真的 30 位整数区间编码器,编完再解回来:
三个必须解决的工程问题
上面的想法很漂亮,但直接照着写会立刻死。三个问题,每一个都有一个漂亮的解法。
问题一:小数会耗尽精度
编到第 50 个符号时,区间宽度大概是 2⁻¹⁵⁰。双精度浮点在 2⁻⁵³ 就废了。
解法:区间一旦确定了最高位,就把那一位吐出来,然后把区间放大一倍。
# 如果整个区间都落在 [0, 0.5) 里 # → 那这个数的二进制第一位一定是 0 # → 吐出 0,然后把区间 ×2 拉回 [0,1) # 如果整个区间都落在 [0.5, 1) 里 # → 第一位一定是 1 # → 吐出 1,减掉 0.5,再 ×2 # 于是区间永远不会窄到超过精度, # 比特是【边编边流出来】的,不是最后一次性算出来的。
问题二:区间骑在中点上,卡住不动
如果区间是 [0.4999, 0.5001)——它既不完全在左半边,也不完全在右半边,最高位还没定下来,但区间已经很窄了。
这个情况叫下溢(underflow),是算术编码实现里最容易写错的地方。
# 解法:如果区间落在中间那一半 [0.25, 0.75) 里, # → 我们不知道第一位是 0 还是 1, # → 但我们知道:【第一位和第二位一定相反】 # (0.4999 → 0.0111… 0.5001 → 0.1000…) # → 于是记一笔「欠一个反转位」,把区间围绕中点放大,继续。 # → 等到第一位终于确定了,就把欠着的反转位一次性补上。 # 这就是代码里那个 pending / underflow 计数器。 # 这本书的引擎里那几行就是干这个的。
问题三:整数运算
真实实现里不用浮点,用整数——因为编码器和解码器必须逐位一致,而浮点在不同平台上可能有微小差异,一位不同整条消息就全废了。
这本书的引擎用 30 位整数区间。精度账要自己算清楚:区间最大 2³⁰,频率总和最大 2¹⁸,乘积 2⁴⁸,还在双精度整数(2⁵³)的安全范围内。这类边界如果没算,程序会在跑了几千个符号之后毫无征兆地解出乱码。
拿真语料跑一次
在上面那个 demo 里点「压缩 2053 字符的英文语料」。结果是:
| 方式 | 比特 | 字节 | 说明 |
|---|---|---|---|
| 原文 ASCII | 16424 | 2053 | 8 比特一个字符 |
| 定长 5 比特码 | 10265 | 1284 | 27 种符号,2⁵ 够用 |
| 霍夫曼 | 8361 | 1046 | 比地板多 74 比特 |
| 算术编码 | 8288 | 1036 | 比地板多 1 比特 |
| 熵的地板 H×n | 8287 | 1036 | 4.0365 × 2053 |
整整两千零五十三个字符,一共只多花了 1 个比特。
而且解码验证一字不差——那不是「理论上应该」,是 demo 里真的编了又解了一遍再逐字符比对的结果。
算术编码解决的不只是「省那点取整损失」。它解锁了一件霍夫曼结构上做不到的事:
每个符号可以用不同的概率分布来编。
霍夫曼必须先建一棵树,然后整篇文章用同一棵。而算术编码每一步只需要「当前这一步的概率表」——这张表可以每一步都不一样,只要解码方能算出同一张表。
而解码方看到的历史和编码方完全相同(它已经解出前面所有符号了),所以任何「基于已解码内容」的模型都可以用。
这一句话就是第三卷的全部前提。它意味着你可以把任意一台预测模型——n-gram、神经网络、大语言模型——直接插到算术编码器上,得到一台压缩器。第 12 章会真的这么干。
算术编码明明更好,为什么 zip 用的还是霍夫曼?
- 专利。1980–2000 年代,IBM 等公司持有大量算术编码相关专利。JPEG 标准里其实定义了算术编码模式,但因为专利问题,几乎没有实现支持它——所以你手上每一张 JPEG 都在为专利多付 5%–10% 的体积。这些专利现在都过期了。
- 速度。霍夫曼解码可以用查表,一次出好几位;算术编码每个符号都要做乘除法。在 CPU 慢的年代这个差距很要命。
- 它其实无处不在,只是名字不叫这个。H.264/H.265 的 CABAC、JPEG 2000 的 MQ 编码器、CABAC 之前的 QM 编码器、以及现代的 zstd/Brotli 用的 ANS(非对称数系,2013 年提出)——全都是算术编码这一族。
ANS 值得单独提一句:它用一个整数状态代替区间,做到了「算术编码的精度 + 查表的速度」。这是压缩领域近十几年最重要的实用进展,而它出自一位波兰物理学家 Jarosław Duda 之手。一个看起来早就做完了的领域,基础层在十几年前还出了新东西。
这个误解很常见,因为几乎所有教科书都用 [0,1) 的小数来讲解——那是为了直观,不是实现方式。「概念上的模型」和「工程上的实现」在这里差得很远,而理解这个差距本身很有价值:很多算法都是这样,讲解版本和落地版本长得完全不一样。
不是每字符 1 比特,是整条消息一共 1 比特。
选 A 的人还停在霍夫曼的思维里(损失是按符号累积的);选 B、C 的人方向对了,只是低估了它有多干净。
那 1 个比特是收尾时为了「把区间钉死」多吐的位——它是一个常数,不随消息长度增长。所以消息越长,平摊的损失越接近零。
这一章的一句话
算术编码不给符号分配比特,它把区间反复切窄,最后报一个数。于是取整只发生一次,而且每一步都可以换一张概率表——后面这一点,比省下来的比特重要得多。
下一章是卷 II 的最后一章,也是一次换轨。前面四章走的都是「数概率」这条路。还有一条完全不同的路:不数概率,找重复。LZ77 看见的东西是概率表看不见的——它能抓到「request_id= 这七个字符刚才出现过」,而字符频率表对此一无所知。两条路各有主场,而 gzip 的做法是两条一起走。