先问你一个问题 #
你能把一整段文字,编成一个数吗?
不用想复杂的,先凭直觉给个答案:能?还是不能?
……
如果你觉得"这怎么可能,一段文字有那么多字符,一个数怎么装得下"——恭喜你,这是正常人的第一反应。
但答案是:能。不但能,而且你手机里的 ZIP 压缩、PNG 图片、微信视频,底层都在用类似的方法。
这个方法叫算术编码。听起来很数学,但核心思路简单得离谱。今天我用一个生活比喻 + 60 行 Python 代码,把它讲清楚。
一个你从小就懂的比喻:在纸条上画线 #
想象你有一张纸条,上面写着 0 到 1:
0 ─────────────────────────────────────────── 1现在有四个人来抢这块地:A、B、C、D。A 最壮,占一半;B 占四分之一;C 和 D 各占八分之一。
0 A 0.5 B 0.75 C 0.875 D 1
├─────────────────┼───────┼───────┼───────┤好了,这张纸条就是我们的"编码板"。
编码的规则只有一条:每写一个字,就把纸条剪到这个字的地盘。
比如我想写 "BA" 两个字:
第一步:写 B B 的地盘是 [0.5, 0.75)。咔嚓,纸条剪成了:
0.5 ─────────────────────────────────── 0.75
(原来整条纸条的四分之一)第二步:写 A 但现在纸条上没有 ABCD 了啊?别急,我们把这块小纸条当成新的一整张,再按 A:B:C:D = ½:¼:⅛:⅛ 重新划分:
0.5 A 0.625 B 0.6875 C 0.71875 D 0.75
├───────────────────┼─────────┼─────────┼─────────┤A 在最前面那块,就是 [0.5, 0.625)。再咔嚓一刀:
0.5 ────────────────────────── 0.625
(原来的八分之一大小)好了,"BA" 这两个字就编完了。最终的结果是一段区间:[0.5, 0.625)。
你可以随便从这段区间里挑一个数当"编码结果"——比如挑中点 0.5625。
💡 一句话总结算术编码: 每写一个字,就把纸条缩小到这个字的地盘。最后纸条剩多大,编码结果就是那段区间里的任意一个数。
那怎么解码?倒着走就行 #
假设我收到了一个数 0.5625,我也知道用的是同一张"地盘划分图"(A 半张、B 四分之一……),还知道原文有 2 个字。
怎么还原?
第一步:判断 0.5625 落在谁家的地盘?
对照划分图:
- A 家:[0, 0.5) → 0.5625 不在
- B 家:[0.5, 0.75) → 正好在里面!
第一个字是 B。
第二步:把 B 家的地盘"拉回"整张纸条大小。
B 家是 [0.5, 0.75),宽度 0.25。0.5625 在 B 家里的位置是:从 B 家起点往右走了多远?
(0.5625 - 0.5) ÷ 0.25 = 0.0625 ÷ 0.25 = 0.25也就是从 B 家左边界往右走了 25% 的距离。把它映射回整张纸条,就是 0.25。
第三步:0.25 又落在谁家?
- A 家:[0, 0.5) → 在里面!
第二个字是 A。
两个字齐了,停止。结果:"BA" ✓
💡 一句话总结解码: 每猜一个字,就问"这个数落在谁家",然后把这个家的地盘重新拉成整张纸条那么大,继续猜下一个。
下面是互动版,动手点一点「自动播放」,看橙色纸条怎么一步步剪短:
等等,这件事有什么厉害的? #
你可能觉得:不就是在纸条上划来划去吗,有啥了不起的?
厉害的地方藏在一个细节里,我不说你可能没注意——
看看纸条的宽度:
| 步骤 | 纸条宽度 | 等于什么 |
|---|---|---|
| 一开始 | 1 | — |
| 写了 B | 0.25 = ¼ | p(B) |
| 写了 A | 0.125 = ⅛ | p(B) × p(A) |
纸条的宽度 = 这段文字的概率!
每写一个字,纸条就窄一分,宽度自动乘上这个字的概率。一连串乘下来,最后的宽度就是"这段文字出现的概率"。
再换算一个东西:−log₂(宽度)。(不记得 log 是什么没关系,你就当是"把概率换成几刀"的翻译器。)
−log₂(0.125) = 3 刀而每个字的刀数加起来呢?
- B:−log₂(¼) = 2 刀
- A:−log₂(½) = 1 刀
- 合计:3 刀
一样的。
这就是信息论里最核心的那条公式:整段文字的信息量 = 每个字的信息量加起来。
算术编码的妙处在于——你根本不用自己算乘法、算加法,你只要咔嚓咔嚓剪纸条,这些数学自动就藏在纸条的宽度里了。
上手写代码:只需要 3 个函数 #
说干就干。我们用 Python 把刚才的过程写出来。全程不到 60 行。
准备:地盘划分图 #
第一步,我们把字符表和概率表翻译成两张好用的表:
def build_rank_and_cum(chars, probs):
# 字符 → 编号的字典:{'A':0, 'B':1, ...}
rank = {c: i for i, c in enumerate(chars)}
# 前缀和:每个字的"左边界"位置
cum = [0.0]
for p in probs:
cum.append(cum[-1] + p)
# 比如 [0, 0.5, 0.75, 0.875, 1.0]
return rank, cumcum 就是那张划分图。cum[i] 是第 i 个字的左边界,cum[i+1] 是右边界。
编码:剪纸条 #
def encode(text, chars, probs):
rank, cum = build_rank_and_cum(chars, probs)
lo, hi = 0.0, 1.0 # 初始纸条:从 0 到 1
for c in text:
w = hi - lo # 当前纸条宽度
i = rank[c] # 这个字是第几个
hi = lo + w * cum[i+1] # 新的右边界(先算!要用旧的 lo)
lo = lo + w * cum[i] # 新的左边界
return lo, hi就这么短。每循环一次,纸条就窄一截。
小细节:必须先算
hi再算lo。因为hi的计算需要用旧的lo,如果你先把lo改了,hi就错了。剪纸条嘛,肯定得先量好两头再下刀。
试一下:
chars = ['A', 'B', 'C', 'D']
probs = [0.5, 0.25, 0.125, 0.125]
lo, hi = encode("BA", chars, probs)
print(f"区间:[{lo}, {hi})") # 区间:[0.5, 0.625)跟我们手剪的一模一样 ✓
解码:找地盘 + 拉回去 #
def decode(x, length, chars, probs):
rank, cum = build_rank_and_cum(chars, probs)
result = []
for _ in range(length):
# 一个字一个字猜:x 落在谁家?
for i, c in enumerate(chars):
if cum[i] <= x < cum[i + 1]:
result.append(c)
# 拉回满屏:(x - 左边界) ÷ 宽度
x = (x - cum[i]) / (cum[i+1] - cum[i])
break
return "".join(result)试一下:
mid = (lo + hi) / 2 # 取中点 0.5625 当编码结果
text = decode(mid, 2, chars, probs)
print(text) # BA ✓完美还原。
💡 两个小知识:
- 为什么取中点? 其实区间里随便哪个数都能解回去,中点最安全——离两边都远,不怕小数点误差。
- 为什么要传 length? 不然解码器不知道什么时候停。0.5 可以无限猜下去,永远有下一个字。真实系统里要么传长度,要么约定一个"结束符"(比如特殊字符
$,一遇到就停)。
对账:宽度的刀数 vs 各字刀数之和 #
最后验证一下数学是不是真的成立:
import math
width = hi - lo
bits_from_width = -math.log2(width)
bits_from_sum = sum(-math.log2(probs[rank[c]]) for c in "BA")
print(f"纸条宽度算出来的:{bits_from_width}") # 3.0
print(f"各字加起来的: {bits_from_sum}") # 3.0
assert math.isclose(bits_from_width, bits_from_sum)
print("✓ 对平了")又一个小细节:浮点数比较不能用
==,要用math.isclose。因为计算机存小数是近似的,0.1 + 0.2 不等于 0.3 这种坑,写过代码的都懂。
用真实数据试试 #
上面是用的玩具数据。我们来试试真家伙——我之前训了一个迷你 GPT,它看到 "compr" 这几个字母后,会给出下一个字的概率。
概率最高的 5 个字是:
| 字 | 概率 |
|---|---|
| a | 29.3% |
| o | 23.1% |
| i | 18.7% |
| e | 16.0% |
| y | 7.4% |
等等,加起来只有 94.5%,不是 100%。剩下的 5.5% 分给了其他字母。
但我们现在只打算用这 5 个字做编码,那地盘就铺不满整张纸条了——会留一块空白。解码的时候数字落在空白里,就找不到字了。
怎么办?归一化——每个概率都除以总合,让它们加起来正好是 1:
import json
with open("dists.json") as f:
data = json.load(f)
alphabet = data["alphabet"]
compr_dist = data["dists"]["compr"]
# 按概率从大到小排,取前 5 个
pairs = sorted(zip(alphabet, compr_dist), key=lambda x: -x[1])[:5]
top5_chars = [c for c, p in pairs] # ['a', 'o', 'i', 'e', 'y']
top5_probs_raw = [p for c, p in pairs] # 原始概率
# 归一化:每个数除以总合
total = sum(top5_probs_raw)
top5_probs = [p / total for p in top5_probs_raw]归一化之后的概率:a:31.0%, o:24.4%, i:19.8%, e:17.0%, y:7.9%。加起来正好 100%。
现在用这 5 个字编 "aoie":
lo, hi = encode("aoie", top5_chars, top5_probs)
print(f"区间:[{lo:.10f}, {hi:.10f})")
# 区间:[0.1493450736, 0.1518825387)
mid = (lo + hi) / 2
print(decode(mid, 4, top5_chars, top5_probs))
# aoie ✓再对一次账:
纸条宽度算出来的:8.622396
各字加起来的: 8.622396
✓ 对平了四个字符、8.6 个 bit 的信息,被塞进了一个 0 到 1 之间的小数里。
所以这玩意儿有什么用? #
三个层面,你感受一下:
日常层面:你每天都在用。 你手机里的 ZIP 压缩文件、PNG 图片、看的视频——它们的压缩算法里,核心思路都跟这个"剪纸条"差不多。本质都是一句话:常出现的东西少占空间,稀有的多占点。
数学层面:它证明了熵的极限。 信息论之父香农说过:"一段文字的最短编码长度,等于它的熵。"算术编码几乎贴着这个下界——剪纸条剪出来的宽度,换算成 bits 就是熵。它不是什么聪明的脑筋急转弯,它是数学上几乎最优的解法。
AI 层面:它是理解大模型的钥匙。 你可能会问,算术编码和大模型有什么关系?关系大了。
一句话:预测下一个字,就是在做压缩。模型越准,压缩率越高。
算术编码就是把这句话落地的桥梁:
- 模型给你一个概率分布("下一个字 29% 是 a")
- 算术编码拿这个分布当"地盘划分图",剪一刀
- 模型越好 → 概率越准 → 常用字占的地盘越大 → 剪完剩的纸条越宽 → 花的 bits 越少
模型越好,压缩越狠。这就是"压缩即智能"的字面意思。
下一篇,我们就来聊这件事里最有意思的部分:当模型能看到前面写了什么字,它猜下一个字就越准——压缩率也就越高。 这叫"条件分布",也是 GPT 这类语言模型的核心玩法。
自己动手试试吧:把文中 60 多行代码抄下来跑一遍,换不同的字符、不同的概率、不同的文字,看看纸条怎么缩、数字怎么变。亲手剪过一次纸条,比看十遍都管用。