随机性的公平性:为什么不是所有”随机”都公平
“随机抽取”看似简单——从 N 个选项中选一个,用 Math.random() 就行?实际上,真正公平的随机抽取远比想象中复杂。从伪随机的偏差到取模偏差,再到算法选择的影响,每一步都可能引入不公平。
本文将深入分析随机选择器的核心算法。
配套工具:随机选择器
一、随机数生成器:伪随机 vs 加密级随机
Math.random:伪随机数生成器(PRNG)
JavaScript 的 Math.random() 返回 [0, 1) 区间的伪随机浮点数。它基于确定性算法(通常是 xorshift128+ 或类似的 PRNG),特点是:
- 速度快:纳秒级生成
- 可预测:知道内部状态可以预测后续输出
- 周期性:序列会重复(尽管周期极长)
- 分布均匀:统计上均匀分布,但不保证密码学安全
对于动画、模拟、游戏等场景,Math.random 完全够用。但对于抽奖、安全、密钥等场景,它的可预测性是不可接受的。
crypto.getRandomValues:加密级随机数生成器(CSPRNG)
const buf = new Uint32Array(1);
crypto.getRandomValues(buf);
// buf[0] 是 [0, 2^32) 区间的加密级随机整数
浏览器的 crypto.getRandomValues 基于操作系统的熵池(Linux 的 /dev/urandom、Windows 的 CryptGenRandom),特点是:
- 不可预测:基于物理熵源(硬件噪声、用户输入时序等)
- 密码学安全:即使知道部分输出,也无法预测后续输出
- 稍慢:需要从系统熵池读取
- 公平性:每个值出现的概率完全相等
为什么抽奖必须用 crypto?
如果用 Math.random 抽奖,理论上攻击者可以通过观察前几次结果推断内部状态,从而预测下一次抽取结果。虽然实际操作难度极高,但对于需要公信力的抽奖场景,必须使用加密级随机数生成器。
二、取模偏差与拒绝采样
问题:直接取模引入偏差
从 [0, 2^32) 的随机整数映射到 [0, max) 时,最直觉的做法是取模:
const randomInt = buf[0] % max;
但当 max 不整除 2^32 时,[0, remainder) 的值会比其他值多出现一次(remainder = 2^32 % max),引入取模偏差。
举例:从 [0, 10) 映射到 [0, 3)
- 0 → 0, 1 → 1, 2 → 2
- 3 → 0, 4 → 1, 5 → 2
- 6 → 0, 7 → 1, 8 → 2
- 9 → 0(多了!)
结果中 0 出现 4 次,1 和 2 各出现 3 次。概率分布不均匀。
解决方案:拒绝采样
function secureRandomInt(max) {
// 计算 2^32 中 max 的最大整数倍
const limit = Math.floor(0xffffffff / max) * max;
const buf = new Uint32Array(1);
for (let i = 0; i < 10; i++) {
crypto.getRandomValues(buf);
// 随机值 < limit 时取模无偏差
if (buf[0] < limit) return buf[0] % max;
// 否则拒绝并重新采样
}
// 降级:偏差极小(2^32 的余数通常很小)
return buf[0] % max;
}
拒绝采样的核心思想是:丢弃导致偏差的值,只接受无偏差范围内的值。limit 是 2^32 中 max 的最大整数倍,[0, limit) 范围内取模完全均匀。
被拒绝的概率 = remainder / 2^32,通常极小(如 max=10 时仅 6/2^32 ≈ 1.4×10^-9),10 次重试足够覆盖极端情况。
三、两种抽取模式
模式一:允许重复(独立抽取)
每次从候选池中独立抽取一项,同一个项可以被多次选中。
for (let i = 0; i < count; i++) {
const idx = secureRandomInt(pool.length);
result.push(pool[idx]);
}
适用场景:
- 模拟独立重复试验(如掷骰子 10 次)
- 随机生成器(从字符集中随机选字符)
- 有放回抽样
注意:允许重复时抽取数量不受候选项数量限制。
模式二:不允许重复(部分洗牌)
每个候选项最多被选中一次,抽取数量不能超过候选项数量。
错误实现:重复抽取直到不重复
// 错误:效率低且有"生日问题"偏差
const result = new Set();
while (result.size < count) {
const idx = secureRandomInt(pool.length);
result.add(pool[idx]);
}
当 count 接近 pool.length 时,碰撞概率急剧上升(生日问题),性能退化到 O(n^2)。
正确实现:Fisher-Yates 部分洗牌
const n = Math.min(count, pool.length);
for (let i = 0; i < n; i++) {
// 从 [i, pool.length) 中随机选一个位置
const j = i + secureRandomInt(pool.length - i);
// 交换 pool[i] 和 pool[j]
[pool[i], pool[j]] = [pool[j], pool[i]];
}
// 前 n 个位置就是抽取结果
return pool.slice(0, n);
部分洗牌的优势:
- 时间复杂度 O(k)(k 为抽取数量),不需要全量洗牌
- 无碰撞,无生日问题
- 每个排列的概率完全相等
两种模式对比
| 特性 | 允许重复 | 不允许重复 |
|---|---|---|
| 抽取上限 | 无(实际上限 999) | 候选项数量 |
| 算法 | 独立抽取 | Fisher-Yates 部分洗牌 |
| 时间复杂度 | O(k) | O(k) |
| 同项可多次出现 | 是 | 否 |
| 典型场景 | 模拟、生成 | 抽奖、分组 |
四、候选项预处理的公平性
去除空行
空行如果被抽取到,毫无意义。预处理阶段过滤空行确保抽取结果有效。
候选项去重
对于不允许重复的模式,如果候选项本身有重复(如名单中有两个”张三”),去重后才能保证真正的公平抽取——否则重复的项被选中的概率更高。
注意:去重时是否区分大小写取决于”大小写敏感”选项。对于人名列表,建议去重以避免同一个人被多次抽取。
五、实际应用场景
场景一:年会抽奖
输入:100 名员工名单
模式:不允许重复
抽取数量:3(一等奖 1 名 + 二等奖 2 名)
预处理:去重 + 去除空行
公平性保证:crypto.getRandomValues + 拒绝采样 + Fisher-Yates 部分洗牌,每个员工被抽中的概率完全相等。
场景二:课堂随机点名
输入:全班学生名单
模式:不允许重复
抽取数量:1
预处理:去重
每次点击抽取一个不重复的学生,确保一节课内每个学生最多被点一次。
场景三:随机分组
输入:30 名参与者
模式:不允许重复
抽取数量:6(第一组)
→ 剩余 24 人中抽 6(第二组)
→ 依此类推
多次抽取实现随机分组,每次不重复确保分组互斥。
场景四:A/B 测试分配
输入:用户 ID 列表
模式:允许重复(如果用户可参与多组测试)
或 不允许重复(如果每组只参与一次)
抽取数量:根据实验设计
随机分配确保实验组和对照组无系统性偏差。
六、总结
真正的公平随机需要三个层面的保证:
- 随机源:使用
crypto.getRandomValues而非Math.random - 无偏差映射:拒绝采样消除取模偏差
- 正确算法:Fisher-Yates 部分洗牌实现无重复抽取
立即体验:无偏差随机抽取工具 — 加密级随机,全本地处理