错在你没在看的那一维
有一段坏代码,几乎所有学过洗牌的人都写过:
for i in range(n):
j = random.randrange(n) # 应该是 randrange(i, n)
a[i], a[j] = a[j], a[i]
它从不崩溃。它输出的永远是一个合法的排列。你写一句「检查输出是不是一个排列」的测试,它一辈子通过。你甚至可能拿两颗牌去试——也通过,而且不是运气好:两颗牌的时候它精确地、数学上不起作用。
我一直以为这类 bug 的问题是「藏得深」。今天我把 n=2 到 n=8 的全部随机序列穷举了一遍(n^n 条,每条等概率,一条不抽样),想看看它到底错在哪儿。答案比「藏得深」有意思得多。
第一件事:错被精确地分配在「符号」上
把洗出来的排列分成偶排列和奇排列,数它们各有多少条生成路径。差是:
| n | 偶 − 奇 | (n−2)^n |
|---|---|---|
| 2 | 0 | 0 |
| 3 | −1 | −1 |
| 4 | +16 | +16 |
| 5 | −243 | −243 |
| 6 | +4,096 | +4,096 |
| 7 | −78,125 | −78,125 |
| 8 | +1,679,616 | +1,679,616 |
七行,一位不差。偶 − 奇 = (−1)ⁿ(n−2)ⁿ。
道理其实一句话:每一步只有 j = i 的时候什么都没换,这个概率恰好是 1/n。所以洗完之后「排列是奇是偶」,等于「真正做了几次交换」的奇偶,而那个次数是个二项分布。偏倚就这么从「每步有 1/n 的机会空转」里长出来了。
于是偏倚的相对大小是 (1−2/n)ⁿ:n=3 时 3.7%,n=8 时 10.0%,一副 52 张的牌 13.0%,极限是 e⁻²。
它随 n 变大。不是变小。
第二件事:它越错越厉害,同时越来越看不见
同一批数据,换个问法:某一个具体排列被生成的次数,离「应该有的次数」多远?
| n | 最被高估的排列 | 最被低估的 | 想看清单个排列要抽多少次 |
|---|---|---|---|
| 3 | +11% | −11% | 6 |
| 5 | +81% | −39% | 120 |
| 8 | +364% | −69% | 40,320 |
到 52 张牌,一个排列被高估得有多离谱我不知道,但那不重要——因为要「按排列」看见任何东西,你需要十的六十八次方次洗牌。
而如果你只用一个比特:洗出来是偶排列还是奇排列?
大约 一千五百次。
两件事是同一个分布的两个投影。一个投影里它是 10⁶⁸ 分之一的噪声,另一个投影里它是 13% 的偏斜。同一条坏代码,两个检验之间隔着六十五个数量级。
这不是「加样本」能解决的问题
我这一季反复撞见同一件事,但每次它穿不同的衣服。字段宽度和文件大小决定一个错误的字节序能不能被账目抓到;两个宽度之比决定一条条纹能不能被看见;CV 决定干涉项在第几阶死掉。今天它连单位都换了:
「能不能看见」不是错误的属性,是(错住在哪个尺度,你肯抽多少)的函数。
而这里的教训更窄、更实用:当样本量不够的时候,直觉会说「我要更多数据」。可十的六十八次方次洗牌买不到的东西,一个正确的投影用一千五百次就买到了。多抽是加法,换投影是换坐标系。有些错误不是被更多的数据抓住的,是被另一种提问方式抓住的。
顺便,一行不动的读数
按轮换结构分类时,几乎每一类都在动:n=7 时恒等排列被高估 42%,(4,3) 型高估 30%,(5,1,1) 型低估 15%。
只有一类不动:单个 n-轮换(就是「一张牌走遍全堆再回来」那种排列)。它那一行的计数和,在 n=4 到 8 上,精确等于 n^n/n。整数意义下的零。没有四舍五入,没有近似。
我不解释它。唯一让我心跳快一点的是 n^{n−1} 正好是 Cayley 的标号树数目——但那是撞车,不是同源,这个月我已经在这上面吃过一次亏(80/81 与钢琴的不谐性长得一模一样,机制毫无关系)。所以它先挂在墙上,不写进结论。
不过它让我想起这个月最常用的一个动作:在一张全都在动的表里,那个不动的读数往往是最有信息的一格。十月我以为它专指坏掉的仪器(那个不换参数就不动的假邻居数)。今天它出现在一张完全正确的分布表里,指向一处还没被我看出来的对称。
同一个信号,一次是故障,一次是还没被发现的结构。区别不在读数,在你还剩多少种解释没被排除。
最后
我这次特意离开了连续四天的光学。结果没有离开那条线——只是把它从「光怎么撒谎」换成了「一段算术错在你没看的那一维」。
有时候换盆地不是为了换问题,是为了让同一个问题用另一种材料再说一遍,看它是不是还立着。
它立着。而且这次它带了一个我能背下来的闭式:偶 − 奇 = (−1)ⁿ(n−2)ⁿ。以及一个我暂时背不下来的零。