ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

JavaScript 数组随机排列(shuffle)实战:从 `sort` + `Math.random` 的陷阱到 Fisher-Yates 洗牌算法

JavaScript 数组随机排列(shuffle)实战:从 `sort` + `Math.random` 的陷阱到 Fisher-Yates 洗牌算法 文档教程前端【免费下载链接】zh.javascript.info现代 JavaScript 教程The Modern JavaScript Tutorial以最新的 ECMAScript 规范为基准通过简单但足够详细的内容为你讲解从基础到高阶的 JavaScript 相关知识。项目地址https://gitcode.com/gh_mirrors/zh/zh.javascript.info点击查看免费下载在《现代 JavaScript 教程》的数组方法章节末尾有一道经典任务编写shuffle(array)函数让数组元素以完全相等的概率随机重排。这道题表面上只有寥寥数行代码背后却隐藏着两个截然不同的技术路线——一条是看似简洁、实则分布严重倾斜的sort投机写法另一条是数学上严格均匀、业界公认标准的 Fisher-Yates 洗牌算法。本文以该任务为骨架结合仓库中任务原文与官方题解完整演示两种实现、用一百万次运行统计进行事实检验并从sort比较函数语义出发剖析倾斜的根因最终给出生产环境下的选型结论。任务编写一个概率均匀的shuffle(array)任务的完整要求见 task.md非常明确编写函数shuffle(array)来随机排列数组的元素多次运行shuffle可能导致元素顺序的不同例如let arr [1, 2, 3]; shuffle(arr); // arr [3, 2, 1] shuffle(arr); // arr [2, 1, 3] shuffle(arr); // arr [3, 1, 2] // ...关键约束是所有元素顺序应该具有相等的概率。对[1,2,3]来说[1,2,3]、[1,3,2]、[3,1,2]等 6 种排列即3! 6种出现的概率应当完全相同。概率相等这四个字正是区分合格洗牌与不合格洗牌的试金石。接下来我们先看一个最容易被想到的实现。方案一一行代码的sort(() Math.random() - 0.5)最简单的解决方案在 solution.md 中给出function shuffle(array) { array.sort(() Math.random() - 0.5); } let arr [1, 2, 3]; shuffle(arr); alert(arr);它为什么看起来可行这个写法利用了数组方法章节中讲解的arr.sort(fn)语义见数组方法的sort(fn)小节第 398–497 行sort会调用我们传入的比较函数来决定两个元素的先后关系——返回正数表示第一个大于第二个返回负数表示第一个小于第二个返回0表示相等。而Math.random()返回[0, 1)区间内均匀分布的随机小数减去0.5后得到[-0.5, 0.5)区间内的随机数大约一半情况下为正、一半情况下为负。于是比较函数每次都在随机地给出正负号sort据此随机地重新排列元素。从直觉上看这确实把数组打乱了。事实检验运行一百万次统计分布问题恰恰出在看起来可行上。题解中提供了一个客观的检验方法运行 100 万次shuffle统计 6 种排列各自出现的次数function shuffle(array) { array.sort(() Math.random() - 0.5); } // 所有可能排列的出现次数 let count { 123: 0, 132: 0, 213: 0, 231: 0, 321: 0, 312: 0 }; for (let i 0; i 1000000; i) { let array [1, 2, 3]; shuffle(array); count[array.join()]; } // 显示所有可能排列的出现次数 for (let key in count) { alert(${key}: ${count[key]}); }在某个 JavaScript 引擎上示例结果结果取决于具体引擎但倾斜趋势是共通的如下123: 250706 132: 124425 213: 249618 231: 124880 312: 125148 321: 125223如果洗牌是均匀的100 万次里每种排列应当各自接近1000000 / 6 ≈ 166667次。但实际统计中123约 25 万次和213约 25 万次的出现频率几乎是其他排列约 12.5 万次的两倍——分布严重倾斜远不满足任务所有排列概率相等的要求。为什么sort 随机比较函数会倾斜题解对此给出了精准的定性结论可以归纳为两点第一sort的比较函数被设计为确定性比较器而不是随机源。题解原文直言sort是一个黑匣子——我们把数组和一个比较函数放进去期望它把数组排好序但当比较本身完全随机时这个黑匣子就乱了套而它发疯的精确程度完全取决于引擎内部的具体实现。数组方法章节也明确指出第 457 行arr.sort(fn)实现的是通用排序算法大多数情况下由快速排序或 Timsort 优化而来它会反复调用比较函数对元素两两比较并重排。为了尽量少的比较次数排序算法内部依赖于比较结果的一致性可传递性、反对称性等而Math.random() - 0.5每次调用都可能给出自相矛盾的结果破坏了这些性质最终排列分布就被特定算法的比较路径扭曲了。第二倾斜模式因引擎而异无法预测。题解特别提醒用不同引擎运行同一段示例代码得到的结果可能不同但这种方法不可靠这一结论是确定的。也就是说即便某次运行恰好看起来均匀也不能说明算法正确——它只是某个引擎内部实现碰巧产生的假象。方案二Fisher-Yates 洗牌算法题解紧接着给出了业界标准的正解Fisher-Yates 洗牌算法。算法思路逆向遍历数组将每个元素与其前方含自身随机位置的一个元素交换。从最后一个元素开始每一步在0到当前下标i之间等概率地取一个随机下标j然后交换array[i]与array[j]直到i走到1为止。每一步都锁定一个最终位置后面的步骤不再动它从而保证每个排列等概率。实现与三种交换写法function shuffle(array) { for (let i array.length - 1; i 0; i--) { let j Math.floor(Math.random() * (i 1)); // 从 0 到 i 的随机索引 // 交换元素 array[i] 和 array[j] // 我们使用解构分配destructuring assignment语法来实现它 // 你将在后面的章节中找到有关该语法的更多详细信息 // 可以写成 // let t array[i]; array[i] array[j]; array[j] t [array[i], array[j]] [array[j], array[i]]; } }代码中有三个值得展开的细节随机下标j的取值范围是0..i含两端Math.random() * (i 1)得到[0, i1)Math.floor后恰好映射到0, 1, ..., i。注意必须是i 1而不是i因为元素也允许留在原位置——否则array[i]永远不会与自身交换最后一个元素就没有原地不动的机会均匀性会被破坏。解构赋值写法[array[i], array[j]] [array[j], array[i]]一行完成交换语法细节见解构赋值章节等价于传统的三变量临时交换let t array[i]; array[i] array[j]; array[j] t。原地in-place修改与sort一样shuffle直接修改传入的数组本身不返回新数组。验证同样的一百万次统计用与方案一完全相同的统计代码测试 Fisher-Yates 版本function shuffle(array) { for (let i array.length - 1; i 0; i--) { let j Math.floor(Math.random() * (i 1)); [array[i], array[j]] [array[j], array[i]]; } } // 所有可能排列的出现次数 let count { 123: 0, 132: 0, 213: 0, 231: 0, 321: 0, 312: 0 }; for (let i 0; i 1000000; i) { let array [1, 2, 3]; shuffle(array); count[array.join()]; } // 显示所有可能排列的出现次数 for (let key in count) { alert(${key}: ${count[key]}); }示例输出123: 166693 132: 166647 213: 166628 231: 167517 312: 166199 321: 166316六种排列的计数都在1000000 / 6 ≈ 166667附近轻微波动没有任何一种明显偏离——这正是均匀随机应有的样子。题解的评价是现在看起来不错所有排列都以相同的概率出现。为什么 Fisher-Yates 在数学上严格均匀从概率论角度可以严格论证这是从算法结构推出的结论对长度为n的数组循环共执行n - 1步。第i步i从n-1递减到1从0..i这i 1个位置中等概率选取j并交换于是第i个位置最终落到任一候选元素的概率都是1 / (i 1)且与前面步骤相互独立。把所有步骤的概率相乘任意一个具体排列被生成的概率都是1/n × 1/(n-1) × ... × 1/2 1/n!而n个元素的全排列总数恰好是n!个因此每个排列等概率均匀性不依赖任何引擎的排序实现细节只依赖Math.random()自身的均匀性。性能没有排序开销题解在收尾时特别强调在性能方面Fisher-Yates 算法要好得多没有排序开销。原因很直接sort方案需要先启动一套完整的通用排序算法快速排序/Timsort 级别的复杂度与比较开销再被随机比较函数带偏Fisher-Yates 只是单次线性扫描 等概率交换时间复杂度为 O(n)且每次迭代只调用一次Math.random()。对于一次性的小数组洗牌两者耗时差异或许不易察觉但在游戏抽卡、数据打散、抽样、A/B 测试分组等需要高频或大数组洗牌的真实场景中线性复杂度和恒定内存开销的优势会非常明显。两种方案对比与选型结论维度sort(() Math.random() - 0.5)Fisher-Yates 洗牌均匀性倾斜123/213出现频率约为其他排列的 2 倍数学上严格均匀1/n!结果稳定性依赖引擎内部排序实现不同引擎结果不同与引擎无关只依赖Math.random()时间复杂度通用排序算法的复杂度之上再叠加随机比较O(n)单次线性扫描原地修改是sort本身是原地排序是交换实现代码量1 行3 行左右适用场景仅演示、教学不可用于对均匀性有要求的场景生产环境、游戏、抽样、分组等一切正式场景选型结论非常清晰凡是把shuffle用于真实业务抽奖、随机分组、抽样、公平洗牌等一律采用 Fisher-Yates 算法sort Math.random的写法只能当作展示比较函数返回值语义的趣味示例绝不能因为代码短而投入生产。判断一个洗牌实现是否合格也请始终使用本文介绍的百万次统计 对比 1/n!方法而不是凭肉眼观察几次运行结果。延伸阅读任务原文随机排列数组task.md官方题解含两种方案的完整统计代码solution.mdsort(fn)的比较函数语义与内部排序算法说明数组方法 · sort(fn) 小节交换元素所用语法解构赋值章节前置基础Math.random()、数组的sort/join等方法的完整讲解见数组方法主章节赞分享文档教程前端【免费下载链接】zh.javascript.info现代 JavaScript 教程The Modern JavaScript Tutorial以最新的 ECMAScript 规范为基准通过简单但足够详细的内容为你讲解从基础到高阶的 JavaScript 相关知识。项目地址https://gitcode.com/gh_mirrors/zh/zh.javascript.info点击查看免费下载相关推荐JavaScript 数组随机打乱Shuffle从 Math.random() 排序陷阱到无偏的 Fisher-Yates 算法JavaScript 数组随机打乱Shuffle从 Math.random 排序陷阱到无偏的 Fisher Yates 算法 本篇文章基于 Modern文档/教程前端现代 JavaScript 教程从 sort 随机洗牌陷阱到 Fisher-Yates 算法的数组乱序实战现代 JavaScript 教程从 sort 随机洗牌陷阱到 Fisher Yates 算法的数组乱序实战 在《现代 JavaScript 教程》的「数组方法文档教程前端JavaScript 专题之数组乱序从 Math.random 的陷阱到 Fisher–Yates 洗牌算法JavaScript 专题之数组乱序从 Math.random 的陷阱到 Fisher–Yates 洗牌算法 导读 数组乱序shuffle是日常开发中经常技术博客文档教程创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表