随机选择器原理:无偏差抽取与 Fisher-Yates 算法

随机性的公平性:为什么不是所有”随机”都公平

“随机抽取”看似简单——从 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;
}

拒绝采样的核心思想是:丢弃导致偏差的值,只接受无偏差范围内的值limit2^32max 的最大整数倍,[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 列表
模式:允许重复(如果用户可参与多组测试)
  或 不允许重复(如果每组只参与一次)
抽取数量:根据实验设计

随机分配确保实验组和对照组无系统性偏差。

六、总结

真正的公平随机需要三个层面的保证:

  1. 随机源:使用 crypto.getRandomValues 而非 Math.random
  2. 无偏差映射:拒绝采样消除取模偏差
  3. 正确算法:Fisher-Yates 部分洗牌实现无重复抽取

立即体验:无偏差随机抽取工具 — 加密级随机,全本地处理