Ajiu9

怎么把一整段文字塞进一个小数里?

Oct 14 · 9min

先问你一个问题

你能把一整段文字,编成一个数吗?

不用想复杂的,先凭直觉给个答案:能?还是不能?

……

如果你觉得"这怎么可能,一段文字有那么多字符,一个数怎么装得下"——恭喜你,这是正常人的第一反应。

但答案是:能。不但能,而且你手机里的 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" ✓

💡 一句话总结解码: 每猜一个字,就问"这个数落在谁家",然后把这个家的地盘重新拉成整张纸条那么大,继续猜下一个。

下面是互动版,动手点一点「自动播放」,看橙色纸条怎么一步步剪短:

a (31.0%) o (24.4%) i (19.8%) e (16.9%) y (7.9%)
全貌 —— 纸条的真实位置和宽度(每步缩小)
100.000%
00.250.50.751
放大镜 —— 把当前纸条拉满看内部(下一步落在哪块)
当前纸条──▶ 🔍放大成整条来看
a
o
i
e
y
当前纸条区间[0.000000, 1.000000)
纸条宽度1.000000
−log₂(宽度) = 几刀0.000 bits
已写文字(空)

等等,这件事有什么厉害的?

你可能觉得:不就是在纸条上划来划去吗,有啥了不起的?

厉害的地方藏在一个细节里,我不说你可能没注意——

看看纸条的宽度:

步骤纸条宽度等于什么
一开始1—
写了 B0.25 = ¼p(B)
写了 A0.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, cum

cum 就是那张划分图。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 ✓

完美还原。

💡 两个小知识:

  1. 为什么取中点? 其实区间里随便哪个数都能解回去,中点最安全——离两边都远,不怕小数点误差。
  2. 为什么要传 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 个字是:

字概率
a29.3%
o23.1%
i18.7%
e16.0%
y7.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 多行代码抄下来跑一遍,换不同的字符、不同的概率、不同的文字,看看纸条怎么缩、数字怎么变。亲手剪过一次纸条,比看十遍都管用。

> comment on mastodon / twitter
>
@2024-2025 湘ICP备2024048835号