
写这个题目的起因挺有意思的上周我帮一个小程序团队做抽奖活动改造发现他们直接用了arr.sort(() Math.random() - 0.5)来打乱用户 ID 列表再取前 10 名。上线后活动反馈里中奖用户总是集中在固定的几个人虽然现场没有闹出大事但这种偏差在抽奖、对战匹配、题库乱序里都属于隐藏雷。真正靠谱的做法是 Fisher-Yates 洗牌算法——它用很简单的交换逻辑把“随机打乱”这件事做到每种排列等概率。现代语言内置的 shuffle 工具底层基本都是它在工作。这篇文章我不打算写成算法课的讲义而是想把“为什么排序随机法不能用”“Fisher-Yates 为什么无懈可击”“落地实现的边界细节”以及“怎么验证随机性”这几件事讲透。适合写代码时会遇到抽奖、推荐列表打散、牌局发牌、随机抽样问题的开发者也适合想彻底搞清楚洗牌原理的初学者。1. 为什么最简单的“打乱”反而是坑1.1 排序随机法的偏差用一个实验就能看穿arr.sort(() Math.random() - 0.5)是互联网上流传最广的洗牌写法。它看起来轻轻松松原理是每次比较时随机返回正数或负数让排序算法“比较不出来”从而得到乱序结果。可问题恰恰出在这里排序算法的行为不是随机的它依赖元素之间的比较结果并且需要比较器满足一致性。当你返回的随机值不稳定排序过程就完全偏离正常逻辑。我在本地做过一个很小的实验让[1, 2, 3, 4, 5]这个数组用排序随机法洗 10 万次统计数字 1 出现在每个位置上的概率。结果偏差非常直观位置数字 1 出现的概率第 1 位22.3%第 2 位18.1%第 3 位16.3%第 4 位20.4%第 5 位22.9%理论上每个位置都应该是 20%但实际波动的幅度已经超过 10 个百分点。并且数组越长排序随机法的偏差越明显它还受不同浏览器排序算法实现的影响。V8 引擎在数组长度大于 10 时用 TimSort小于等于 10 时用插入排序两种情况下同样的写法会产出完全不同的概率分布。1.2 什么才算“洗得够均匀”所谓均匀洗牌就是对于长度为 n 的数组所有 n! 种排列出现的概率都等于 1/n!。这个定义看起来很严格但它才是判断一个洗牌方案好不好的唯一标准。排序随机法做不到原因是排序决策路径有确定性它只会把随机比较映射到一小段排序空间上而不是完整覆盖所有排列。另一个常见的错误是“逐个随机插入”遍历原数组每次生成一个目标位置把元素插进去。这个做法同样不是均匀的。因为后插入的元素会挤动前面元素的位置而不同插入顺序造成的排列并不是等概率出现。我之前在代码评审里看到过这种写法当时同事还反驳说“反正结果是乱的概率差一点有什么关系”。但在抽奖场景里“差一点”意味着某些用户的中奖概率会比其他用户高几个百分点这种风险是不能接受的。2. Fisher-Yates 的原理从扑克牌讲起2.1 每一步只做一件简单的事Fisher-Yates 的思路可以直接对应到现实洗牌动作。你手里有一副牌从最后一张开始往前处理每次在当前未处理的牌堆里随机抽一张和当前最后位置上的牌交换。被交换到后面的牌就不再参与后续抽取处理范围逐步缩短。用代码表达最经典的是后向版本function shuffle(arr) { const a [...arr]; for (let i a.length - 1; i 0; i--) { const j Math.floor(Math.random() * (i 1)); [a[i], a[j]] [a[j], a[i]]; } return a; }循环里的i是“当前待处理区间的最后一个位置”j从[0, i]中随机取。选取后把a[i]和a[j]交换i往前移一位已经确定的尾部元素就不再动。这里有两个细节新手容易写错。第一个是j的范围必须包含i。如果写成Math.random() * i那么每轮都可能让某个元素永远没机会留在当前位置最终分布一定不均匀。第二个是循环只跑到i 1不需要处理i 0因为只剩一个元素时不存在“随机抽取”的空间。2.2 概率均匀不是靠感觉是能推出来的Fisher-Yates 的正确性可以用一个简单递推说明。构造任意一个特定排列时看最后一个位置的元素它在第一次抽取中被选中的概率是 1/n剩下 n-1 个元素中倒数第二个位置的元素在第二次抽取中被选中的概率是 1/(n-1)依次类推。把每一步相乘最终某个排列出现的概率就是1/n × 1/(n-1) × 1/(n-2) × ... × 1/1 1/n!这个推导过程非常干净也是为什么这个算法在竞赛和工程中都有一席之地的原因它不是“看起来随机”而是从数学上保证每一种排列严格等概率。2.3 前向与后向两种实现的区别有些教材会写前向版本从第 0 位开始每次在当前未处理区间的开头固定一个元素交换的是“当前位”和“未来区间的某个位置”。function shuffleForward(arr) { const a [...arr]; for (let i 0; i a.length - 1; i) { const j i Math.floor(Math.random() * (a.length - i)); [a[i], a[j]] [a[j], a[i]]; } return a; }后向版本更常见因为循环条件i 0写起来不容易越界也和人脑从“尾部确定”的习惯一致。前向版本的作用主要在部分抽样场景比如只取前 K 个乱序结果时可以直接只循环 K 次逻辑更直观。两个版本在随机源相同的情况下产生的概率分布没有区别。完整证明中还有一个细节被很多人忽略Math.random()的精度和底层随机数周期也会影响实际分布。Fisher-Yates 假设随机数是真正的离散均匀分布但如果你用的是一个周期很短的伪随机生成器比如某些旧的rand()实现实测分布就会出现周期性规律。这一点放到后面安全随机源章节再展开。3. 多语言落地、实现细节与选型建议3.1 JavaScript 手写实现与三个注意点前端项目最常用的场景就是把一个展示列表打乱或者做“每日一题”的随机顺序。手写时要留意三个点第一避免修改原数组。绝大多数业务逻辑里原始数据还要继续用。所以我实现的默认行为是内部先复制一份返回新数组。如果你明确希望原地修改可以在函数名上贴inPlace标识或者直接接受一个已经拷贝过的数组。第二交换时注意解构赋值的顺序。[a[i], a[j]] [a[j], a[i]]在 JavaScript 里是安全的不会出现临时变量问题。但如果你用的是旧式写法const temp a[i]; a[i] a[j]; a[j] temp;要留意i和j相同的情况。虽然交换相同位置没有副作用但临时变量法必须正确处理这个分支才不会丢值。第三Math.floor(Math.random() * (i 1))中乘以i 1而不是i。这是 Fisher-Yates 最容易写错的一行少加 1 会导致最后一个元素不能留在原位置并且数组长度越大偏差越显著。我见过生产代码里犯这个错的在 100 条数据时概率偏差已经肉眼可测。3.2 内置 shuffle 工具的底层都在用它各语言的标准库早就把 Fisher-Yates 封装好了。Python 的random.shuffle就是原地版 Fisher-YatesGo 从 1.20 开始提供rand.ShuffleC 标准库里的std::shuffle也是同样的算法思路只是允许你传入自定义的随机数引擎和分布函数。很多开发者会问既然内置了为什么还要自己写我的体会是理解原理至少有三个价值一是排查问题时有方向比如 Python 的random.shuffle在新版本里支持自定义random参数你不知道它的底层行为就没办法调试固定种子二是当团队需要部分洗牌、加权洗牌这些标准库没覆盖的场景时可以基于原理做裁剪三是在面试和代码评审中能一眼识别同事写出的洗牌代码是否存在概率偏差。用 Python 举个例子如果你既想用内置实现又不想改原列表可以这样import random def shuffled(arr): a arr[:] random.shuffle(a) return a如果你需要固定随机种子做测试复现就传入Random实例rng random.Random(20250101) random.shuffle(a, randomrng)这是把随机源从全局状态里隔离出来的标准做法。千万不要在业务代码里创建一个新的random.Random()但传入同一个种子否则每次运行结果都一模一样测试会骗过你。3.3 随机源升级什么时候不能再用 Math.randomFisher-Yates 只负责“均匀交换”它自己不能产生随机性随机性完全来自你调用的随机数函数。Math.random()使用的是伪随机数生成器种子一般来自时间戳和进程状态对绝大多数业务场景够用。但多人在线牌局、公开抽奖、加密任务分配这类场景参与方一旦能推测出随机数的生成种子就能逆推出整副牌的排列顺序。我参与过的一个棋牌项目早期使用Math.random()洗牌后来安全测试发现可以通过对局记录倒推出牌序生成时刻的种子范围。最终方案换成crypto.getRandomValues()配合 Fisher-Yates才保证发牌结果不可预测。在 Node.js 里也一样使用crypto.randomInt生成均匀整数是不错的选择const { randomInt } require(crypto); function secureShuffle(arr) { const a [...arr]; for (let i a.length - 1; i 0; i--) { const j randomInt(0, i 1); [a[i], a[j]] [a[j], a[i]]; } return a; }需要刻意强调的一点是换成加密安全随机源之后Fisher-Yates 本身没有任何变化变的仅仅是“抽到第 j 个位置”的随机数来源。这是两个独立的问题不要混为一谈。4. 如何验证一次洗牌真的随机4.1 从频数统计到卡方检验单看一次洗牌结果人类根本判断不了随不随机。要验证只能大量重复实验然后检查统计规律。最直接的方法是频数检验把长度为 n 的数组洗 N 次统计某个值出现在每个位置上的次数。如果洗牌均匀每个位置的期望次数是 N/n。我在一个 10 元数组上跑了 10 万次 Fisher-Yates得到的计数基本围绕期望值 10000 波动最大偏差在 120 以内。这个数据本身还不够最好再计算卡方统计量。卡方统计量的公式是χ² Σ (观测次数 - 期望次数)² / 期望次数自由度是(n - 1)²。对于 n10自由度为 81。查卡方分布表95% 置信区间大致在 55 到 110 之间。如果计算出来的 χ² 落在这个范围内说明没有足够证据拒绝“均匀分布”的假设。写一个简单的采样代码可以快速验证function chiSquare(shuffleFn, n 10, trials 100000) { const arr Array.from({ length: n }, (_, i) i); const count Array.from({ length: n }, () Array(n).fill(0)); for (let t 0; t trials; t) { const out shuffleFn(arr); for (let pos 0; pos n; pos) { count[out[pos]][pos]; } } const expected trials / n; let chi2 0; for (let v 0; v n; v) { for (let pos 0; pos n; pos) { const diff count[v][pos] - expected; chi2 (diff * diff) / expected; } } return chi2; }然后可以用它分别跑排序随机法和 Fisher-Yates对比两种方案的 χ² 值。我在本地跑的结果是排序随机法的 χ² 常常超过 200而 Fisher-Yates 稳定在 60 到 90 之间。差异一眼可见。4.2 排列级别的验证数据量小才能做频数检验只检查了“每个元素在每个位置”的分布但某些有偏洗牌也可能恰好通过这个检验。更强的验证方式是检查所有排列的出现次数。对长度为 4 的数组一共只有 24 种排列。洗 24000 次每种排列期望出现 1000 次然后算卡方。这种验证非常直观我一般会在写自定义洗牌函数时先用 n4 做一轮排列级验证再上大数据量。代价是排列数增长太快n5 时有 120 种排列n6 时是 720 种n7 时已经 5040 种。所以生产环境很少直接做这种完整验证但在自己写的工具库单元测试里n4 的排列校验是一个性价比极高的“黄金标准”。4.3 工程上的“随机性自查清单”我习惯在把洗牌工具提交到业务代码之前过一遍下面的清单是不是每个位置都有机会和当前遍历位交换j的范围是否包含了i随机源是否满足场景的安全要求公开活动或牌局是否用了加密安全随机源原地修改还是返回副本有没有在函数签名和代码注释里写清楚空数组、单元素数组是否会走循环边界导致交换报错是否有单元测试覆盖排列分布而不只是断言“结果不等于原数组”这个清单的价值在于把随机性问题从“经验判断”变成“可验证指标”。我在一次代码评审中靠第 4 条拦住过一个 bug同事写的部分洗牌函数在数组长度小于 K 时会访问越界下标导致线上接口偶发 500。5. 实战中的常见坑、排查记录与经验总结5.1 数组被“顺手”改了原地修改与副本在业务项目里最常踩的坑是调用方没有意识到 shuffler 会修改原数组。有一次优化榜单展示我直接对this.rankList调用了shuffle(this.rankList)结果本应保留原始排名的列表被改了后续排序逻辑全部乱套。我现在的工具函数默认都返回新数组除非用户显式传入“可原地修改”的标志。如果需要原地修改就在参数名上写清楚比如shuffleInPlace(arr)并在注释里标注“会修改调用方传入的数组”。这样可以避免绝大多数误用。前端状态管理里的数组是引用类型尤其要小心。在 Vue 或 React 状态里直接原地洗牌并赋值可能造成不可预期的渲染副作用。最安全的做法是const ordered [...state.list]; // 拷贝 const result shuffle(ordered); // 返回新数组5.2 抽前 K 个的正确打开方式抽奖需求最常见的是“从 10000 个参与者里抽 10 个”。很多人的第一反应是全部洗牌再slice(0, 10)。这个做法没问题但没必要如果只取前 K 个只需要循环 K 轮。function sampleK(arr, k) { const a [...arr]; const n a.length; const count Math.min(k, n); for (let i 0; i count; i) { const j i Math.floor(Math.random() * (n - i)); [a[i], a[j]] [a[j], a[i]]; } return a.slice(0, count); }这里用了前向版本前 K 个位置每次都被一个从剩余区间随机选出的元素填充。对 10000 个用户抽 10 个只需要执行 10 次随机和交换性能上几乎无感。需要注意的是K 不能大于数组长度返回时也要做Math.min(k, n)的保护。5.3 常见问题速查表异常现象可能原因修复方案洗牌结果里某些元素总在原来附近用了sort(() Math.random() - 0.5)改用 Fisher-Yates最后一个元素从未留在原位Math.random() * i少乘了i 1确保随机范围是[0, i]洗牌后原数组被改掉函数内部没有做拷贝传入参数前复制一份或使用返回新数组的实现洗牌结果出现可预测的周期性使用了短周期伪随机数生成器换用高质量的随机源洗牌结果总是固定顺序随机种子固定或每次新建同种子Random使用不固定种子或默认随机源部分洗牌时 K 超过数组长度缺少边界保护用Math.min(k, n)截断5.4 最后再说点实战体会我刚开始接触洗牌算法时也觉得“打乱数组而已有什么难的”。后来在真实业务里吃过亏才明白随机性这种看不见摸不着的东西最容易被“差不多就行”的心态带偏。Fisher-Yates 不是唯一能实现均匀洗牌的方案但它是最容易写对、最容易证明、也最容易移植的方案。我的建议是核心逻辑尽量用标准库自定义版本一定要写概率验证用例安全敏感场景务必升级随机源。以后再遇到“抽个奖”“发个牌”“换个顺序”这类需求你会感谢当初肯花十分钟推演概率的自己。