fisher-yates洗牌实现抽奖引擎必须一次性倒序执行,每步在[0,i]等概率选j交换,禁用正向遍历、math.random()-0.5排序及动态过滤;输入须为完整无重列表,抽k个即取前k,允重复则每次独立shuffle。

要用 Fisher-Yates Shuffle 实现绝对公平的抽奖引擎,关键不是“循环随机置换”这个说法本身,而是严格按算法逻辑执行一次正确洗牌,再按需取号——它天然不依赖循环次数来提升公平性,错误地多次 shuffle 反而可能引入冗余或干扰。
必须一次性正确执行 Fisher-Yates
公平性不来自“多洗几次”,而来自每一步交换的数学严谨性:
- 从数组末尾(索引 i = n−1)开始,倒序遍历到索引 1(即
i > 0) - 每轮在 [0, i] 范围内(含端点)等概率选一个下标 j,与 i 位置交换
- 禁止使用
Math.random() * n或rand.Intn(n)这类固定范围取值,否则破坏均匀性 - 务必用原地交换,避免复制或重建数组导致隐式偏差
抽奖引擎 ≠ 洗牌函数,要封装业务语义
洗牌只是工具,引擎需明确回答三个问题:
- 抽谁? 输入必须是完整、无重复的参与者列表(如用户 ID 数组),不能动态增删中途参与
- 抽几个? 洗牌后直接取前 k 个,无需重试、查重或回退——Fisher-Yates 天然保证无重复且等概率
- 是否允许重复中奖? 若需多次抽奖且允许同一人中多次,每次都要独立初始化并完整执行 Fisher-Yates;若禁止重复,则只需 shuffle 一次,顺序抽取即可
避开高危实现陷阱
这些写法看似简洁,实则破坏公平根基:
- ❌ sort(() => Math.random() − 0.5):浏览器排序算法差异 + 非三态比较函数 → 位置分布严重偏斜(实测首尾位偏差可达 ±16%)
- ❌ 正向遍历 + j = rand(0, n−1):前面元素被反复交换,后面元素参与机会不足 → 某些排列概率趋近于零
-
❌ 使用 Math.random() 作密码学场景种子:伪随机可预测,攻击者可复现中奖序列;高安全需求应接入
crypto/rand(Go)或SecureRandom(Java) - ❌ 在 shuffle 过程中动态过滤/跳过元素:破坏“全量池”前提,使剩余元素概率失衡
工程落地建议
真正健壮的抽奖引擎还需补充以下设计:
- 输入校验:拒绝空数组、k 超出范围、含 null/undefined 元素
- 可重现性支持:提供 seed 参数注入(仅限测试环境),便于审计与复现
- 结果锁定机制:洗牌完成后立即冻结数组,防止运行时篡改
- 审计日志:记录洗牌起始状态、随机源类型、k 值、输出序列哈希(不可逆)
- 性能兜底:当 n > 100 万时,考虑分片 shuffle 或内存映射优化,避免阻塞主线程










