并非真正的洗牌
JavaScript 中最常见的随机化错误:
const shuffled = items.sort(() => Math.random() - 0.5); // 错误
它看起来没问题,而且随处可见。但它并不是真正的洗牌。排序比较器期望是一致的,而这个是不一致的,因此引擎的排序会产生倾斜的排列。
在四个测验选项上测量:
| 位置 | 正确答案落在此处的概率 |
|---|---|
| 0 | 28.3% |
| 1 | 28.2% |
| 2 | 24.7% |
| 3 | 18.8% |
均匀分布应该是每个位置 25%。实际上,总是猜第一个选项比随机猜测更好——注意到这一点的玩家会获得优势,而测验就默默地不公平了。
修复方法
Fisher-Yates 洗牌,写成一个辅助函数,只调用一次:
function shuffle(a) {
const out = [...a];
for (let i = out.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[out[i], out[j]] = [out[j], out[i]];
}
return out;
}
然后替换所有出现的地方。这个错误通常不只出现一次——一个代码库中曾在十一个地方出现过:洗牌选项、决定出现哪些问题、选择哪些项目被提供、排序菜单选项。每个地方都稍微倾斜了某些东西。
不要基于模式匹配进行全局替换。 (Math.random() - 0.5) 也会在物理学和抖动中合法地作为噪声出现:
velocity += (Math.random() - 0.5) * 0.012; // 这个保留不动
检查每个匹配项是否实际上是排序比较器。
测试分布
不要凭肉眼判断随机性。用计数来检验:
const pos = [0,0,0,0], N = 200000;
for (let i = 0; i < N; i++) {
const order = shuffle(['a','b','c','d'].map((t,i) => [t,i]));
pos[order.findIndex(o => o[1] === 0)]++;
}
pos.forEach((c,i) => console.log(i, (c/N*100).toFixed(1) + '%'));
各位置之间的百分比差距超过约 3 个百分点意味着存在偏差。
为测试使用合适的生成器。 手工编写的线性同余生成器可能产生 1-2 个百分点的“误差”,看起来像真正的错误,但实际上是测试本身的问题。Math.random() 对检查分布来说足够均匀——将自定义生成器留到需要可重现种子的时候使用,并单独进行验证。
检查你展示给用户的几率
如果你在任何地方显示几率——百分比、进度条、成功概率——确保它们与代码实际滚动的结果匹配。模拟真实滚动几十万次并进行比较:
cred 0 显示 5.0%/40.0%/55.0% 实际 5.0%/40.0%/55.0%
cred 100 显示 40.0%/40.0%/20.0% 实际 40.0%/40.1%/20.0%
最差误差低于半个百分点意味着显示没有说谎。显示的概率与实际结果不符比不显示更糟糕,因为人们会据此做决策。
顺便检查一下
确认声明的目标实际可达。一个显示“3 of 5”的计数器告诉玩家目标是 5;如果 5 只发生 0.17% 的时间,那么界面就是在承诺一个实际上永远不会发生的事情。计算出你显示的每个里程碑的真实概率。