ARTICLE DETAIL

资讯详情

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

JS数组随机取值实战:从Math.random到Fisher-Yates洗牌算法

JS数组随机取值实战:从Math.random到Fisher-Yates洗牌算法 JS数组随机取值从一行代码到可复用的随机取样器做前端这么久我越发觉得随机这两个字在业务里出现的频率远超想象首页轮播图想每次刷新换一批内容、解谜游戏要从题库里抽题、抽奖活动要保证不重复中奖、AB实验要把用户均匀分到不同策略组……这些需求背后都是同一件事——从JS数组里按某种规则随机取出一项或几项。但真到了实现的时候很多同学还是会愣一下取一项好办Math.random()一把梭取几项呢要不要去重顺序要不要保持会不会越到后面越难抽中这些问题一旦叠加代码就越来越拧巴。我见过不少项目里写了好几个随机函数各自为战有的还带着隐秘的概率偏差。这篇文章我打算从最基础的单元素随机说起把Math.random()的边缘行为讲清楚再逐步深入到无重复取样、洗牌算法、通用函数封装最后聊聊实际业务里那些容易踩的坑。整个链路走完你手上的就不只是一个随机函数而是一套可以应对绝大多数场景的随机取值方案。1. 随机取一项Math.random()的边界陷阱与三种取值写法先把最基础的说透。从数组里随机取出一个元素核心就是生成一个合法的随机下标然后通过arr[index]访问它。1.1 [0,1) 区间决定了你的下标取值范围Math.random()返回的是[0, 1)区间内的浮点数包含0不包含1。这是很多人写随机代码时第一个忽略的细节。因为不包含1所以拿它去乘数组长度得到的结果永远小于length天然不会出现下标越界。const arr [苹果, 香蕉, 橘子, 葡萄]; const index Math.floor(Math.random() * arr.length); console.log(arr[index]);这里的Math.floor向下取整把[0, length)的浮点数映射到0到length - 1的整数区间。逻辑上非常干净0 → 00.999 * 4 → 3恰好覆盖全部下标。提示如果你用的是Math.round(Math.random() * (arr.length - 1))那就掉进概率陷阱了。Math.round会把首尾下标挤到更窄的区间里导致第一个和最后一个元素被选中的概率只有中间元素的一半。这类问题在纯随机场景可能感知不明显但在抽奖这种对公平性敏感的场景属于事故级别。1.2 三种写法的对比除了Math.floor前端圈还流行另外两种写法我把它们放在一起对比写法代码易读性风险点标准写法arr[Math.floor(Math.random() * arr.length)]高无位运算写法arr[Math.random() * arr.length | 0]低数值超过32位整型范围时失效parseInt写法arr[parseInt(Math.random() * arr.length)]中parseInt会先转字符串再解析性能和语义都更差Math.random() * arr.length | 0是利用位或运算强制转成32位有符号整数顺手完成向下取整。这个写法短是短但有两个问题一是可读性差团队协作时容易被当成bug二是当数组长度超过 2^31 - 1 时实际几乎不可能位运算会直接溢出。我个人只在写压缩代码或追求极致的场景用业务代码里规规矩矩用 Math.floor 就够了。1.3 边界情况空数组与单元素数组空数组是最容易被忽略的边界。Math.floor(Math.random() * 0)结果是0arr[0]返回undefined。如果业务代码里没做防御后面再对这个结果调用.name、.id之类的方法就会一路Cannot read properties of undefined炸下去。所以任何封装好的随机函数第一行就应该处理空数组function randomPick(arr) { if (!Array.isArray(arr) || arr.length 0) return undefined; return arr[Math.floor(Math.random() * arr.length)]; }单元素数组就无所谓了只有唯一选择函数正常返回arr[0]。2. 随机取多项从抽一张到抽一副牌的算法升级需求稍微复杂一点从数组里随机取出 N 项。这里首先要明确一个关键问题——允不允许重复。抽奖场景里一个人不能重复中奖这叫无放回取样数据增强场景里每条样本可以反复被抽到这叫有放回取样。两种模式的代码逻辑完全不同。2.1 有放回取样简单但要注意期望偏差有放回的实现非常直接循环 N 次每次都按第一节的写法随机取一个即可。function randomPickWithRepeat(arr, count) { const result []; for (let i 0; i count; i) { result.push(arr[Math.floor(Math.random() * arr.length)]); } return result; }这种方式每次抽取独立可能出现同一项被抽中多次。从概率论角度这是典型的二项分布或多项分布试验均值没有问题。但在实践里有个体验问题如果count接近数组长度你得到的是一堆重复元素聚集的结果而不是看起来随机且均匀的结果。比如播放器做随机播放如果直接有放回地抽歌播到第8首时可能还没覆盖完整张专辑用户的第一反应是播放器有bug。2.2 无放回取样核心在如何避免重复无放回取样的第一反应通常是随机取一个记下这个下标下次取的时候判断一下是不是取过了取过就重抽。function randomPickWithoutRepeat_naive(arr, count) { const result []; const pickedIndex new Set(); while (result.length count) { const index Math.floor(Math.random() * arr.length); if (!pickedIndex.has(index)) { pickedIndex.add(index); result.push(arr[index]); } } return result; }这个写法在小数组、小数量下没问题。但你要是拿它做从10000个用户里抽9999个就会遇到一个著名的概率现象——生日悖论。最后剩下那一个未被抽中的下标每次随机命中它的概率只有1/10000你可能要随机几万次才能碰巧抽中它算法整体退化成了重试循环耗时不可控。2.3 Fisher-Yates洗牌算法换一种思路彻底解决问题处理无放回取样真正的正解是洗牌把数组本身打乱然后从头取 N 个即可。随机顺序里的前 N 项天然就是无放回且均匀的随机样本。Fisher-Yates也叫 Knuth shuffle是实现均匀打乱最经典的算法从后往前遍历每轮从未处理的部分随机挑一个元素与当前位置交换function shuffleInPlace(arr) { for (let i arr.length - 1; i 0; i--) { const j Math.floor(Math.random() * (i 1)); [arr[i], arr[j]] [arr[j], arr[i]]; } return arr; }为什么它均匀关键在每次从当前未处理区间内等概率随机选一个。第一步任何元素被放到最后一个位置的概率都是1/n第二步在剩余n-1个元素中任何元素被放到倒数第二个位置的概率是(n-1)/n * 1/(n-1) 1/n。以此类推每个元素落在任意位置的概率都是1/n公平性有严格的数学保证。复杂度是 O(n)而且不需要额外的Set去重一次遍历解决全部问题。有了shuffleInPlace无放回取样就变成一行function randomPickWithoutRepeat(arr, count) { return shuffleInPlace([...arr]).slice(0, count); }这里我拷贝了新数组保证原数组不被修改。slice(0, count)在count arr.length时返回整个打乱后的数组在count 0时返回空数组行为足够稳健。2.4 别用 sort Math.random 打乱数组网上流传最广的一行打乱是arr.sort(() Math.random() - 0.5)这个做法我必须专门拎出来说一句强烈不推荐它既不均匀也不稳定。sort的比较器期望返回一个确定性的偏序关系而Math.random() - 0.5是随机的这本身就违反了sort的设计契约。更实际的问题是不同浏览器或不同版本的排序算法V8 对小数组用插入排序、对大数组用快速排序/堆排序混合在随机比较器下的表现完全不同。有人专门验证过在 Chrome 里用这种方式打乱长数组分布会呈现肉眼可见的偏差——某些位置的元素出现频率明显高于理论值。如果你在面试里写了这个方案面试官大概率会追问一句你怎么证明它是均匀的然后你就发现证明不了。3. 封装一个真正好用的random工具函数上面的功能点都聊清楚了接下来是工程化的问题项目里多个模块都需要随机取值总不能每个文件都复制一份shuffleInPlace。我建议把这些逻辑收敛成一个独立的工具模块对外暴露两个函数randomPick取一个和randomSamples取多个。3.1 参数设计与默认行为我在实际开发中对randomSamples的参数是这样设计的function randomSamples(arr, count 1) { if (!Array.isArray(arr) || arr.length 0) return []; if (count arr.length) { return shuffleInPlace([...arr]); } const copy shuffleInPlace([...arr]); return copy.slice(0, count); }几个关键决策count arr.length时返回全量洗牌。这是取出来的数量比原数组还多时的合理降级调用方拿到的仍然是一个完整、不乱顺序不是全覆盖的结果语义是数量不够我全给你但帮你打乱。count不取整函数内部不需要专门处理因为slice(0, count)本身会对count做ToInteger转换。但如果传入-1那结果就是空数组也符合直觉。返回新数组而不是原地修改。这个决策很重要。在实际业务里源数组常常是状态数据原地打乱会导致 React 的useState、Vue 的reactive在 diff 时出问题。拷贝一份再打乱副作用为零。3.2 为资源敏感的批量场景提供部分洗牌上一版的randomSamples在count很小、数组很大的场景里其实有点奢侈明明只要 2 个随机用户却把 10 万用户的数组全部打乱了。复杂度从理想的 O(k) 退化成了 O(n)。这里可以做一个优化Fisher-Yates 洗牌只需要执行到count轮因为每一轮末尾的元素就是最终结果的一部分我们不需要处理剩余部分。function partialShuffle(arr, count) { const copy [...arr]; const n Math.min(count, copy.length); for (let i 0; i n; i) { const j i Math.floor(Math.random() * (copy.length - i)); [copy[i], copy[j]] [copy[j], copy[i]]; } return copy.slice(0, n); }这个函数从前往后洗每轮把随机选中的元素放到已确定区的末尾执行count轮后前count个位置就是均匀随机的无放回样本。复杂度 O(k)k 远小于 n 时性能显著优于全量洗牌。3.3 让随机可复现给取样器加种子还有一个很多人没考虑过的场景——测试。前端写单元测试时如果测试用例依赖随机数据每次跑结果都不一样会让断言非常难写。此时需要一种可复现的随机同一个种子产生同一串随机数。JS 内置的Math.random不暴露种子接口要支持种子模式需要自己实现一个伪随机数生成器。最实用的是 mulberry32代码非常短随机性也够用function mulberry32(seed) { return function() { seed | 0; seed seed 0x6D2B79F5 | 0; let t Math.imul(seed ^ seed 15, 1 | seed); t t Math.imul(t ^ t 7, 61 | t) ^ t; return ((t ^ t 14) 0) / 4294967296; }; }用的时候替换Math.random即可。比如一个支持seed的洗牌函数function seededShuffle(arr, seed) { const rand mulberry32(seed); const copy [...arr]; for (let i copy.length - 1; i 0; i--) { const j Math.floor(rand() * (i 1)); [copy[i], copy[j]] [copy[j], copy[i]]; } return copy; }这在复现用户反馈时有奇效用户报了一个随机排序后布局错乱的 bug你让他提供触发时的种子本地用同一个种子就能精确复现不用碰运气。4. 实际业务里的随机应用与性能避坑工具函数封装好了不等于可以直接放心用。我在一些项目里见过不少看似随机、实则有问题的用法专门列一节来说也分享一些我在线上场景里的实战经验。4.1 随机播放的近因规避音乐 App 的随机播放很少直接用纯随机因为纯随机可能连续几首都来自同一张专辑。所以我司播放器在随机排序后加了一个规则如果相邻两首歌的专辑ID相同就把后一首往后推一段让同专辑歌曲尽量分散。这个逻辑本质是带约束的随机洗牌。实现上你在shuffleInPlace之后过一遍相邻检查即可代价很低。类似的策略还能用于广告投放里避免同一广告主连续出现、抽奖里避免同一奖品连续中出。4.2 超大数据量场景从百万级数组里随机取100条用partialShuffle已经很快了。但如果你面对的是流式数据——数据源源不断进来不知道总量却需要保证取出的样本是均匀随机的——洗牌算法就不合适了因为流式数据没有一个固定数组让你洗。这个场景的标准解法是蓄水池抽样Reservoir Samplingfunction reservoirSample(stream, k) { const reservoir []; for (let i 0; i stream.length; i) { if (i k) { reservoir.push(stream[i]); } else { const j Math.floor(Math.random() * (i 1)); if (j k) { reservoir[j] stream[i]; } } } return reservoir; }它的核心是遍历到第 i 个元素时以k/i的概率决定它是否替换掉蓄水池里的某个旧元素。数学上可以证明遍历结束后蓄水池中的每个元素被选中的概率都是k/n。我拿它与全量洗牌对比过在100万数据量下全量洗牌大概要几十毫秒蓄水池只需几毫秒而且内存占用只与 k 相关。4.3 尾部随机法一个轻量替代技巧如果数据总量 n 已知且能装进内存还有一个轻量技巧叫尾部随机法从数组尾部开始每轮把当前元素与随机一个更靠前的元素交换只需要执行 k 轮。它其实等价于部分洗牌的特例——从后向前洗。这里不展开因为你已经掌握了partialShuffle。4.4 Math.random 不是真随机最后说一个容易被忽略的技术决策Math.random()是伪随机数生成器不保证密码学安全。如果你拿它做抽奖算法的核心遇到恶意用户可以通过多次试验推断出随机序列规律极端情况更稳妥的做法是用crypto.getRandomValues()生成均匀的 32 位随机整数来代替。前端实现如下function secureRandomIndex(length) { if (length 1) return 0; const array new Uint32Array(1); const limit Math.floor(0xFFFFFFFF / length) * length; let x; do { crypto.getRandomValues(array); x array[0]; } while (x limit); return x % length; }这里拒绝一部分超出范围的值是处理模运算偏差的标准做法。直接用x % length在 length 不是 2 的幂时会导致某些余数出现概率略高产生不公平。金融级抽奖别省这一步。4.5 防重策略随机ID从哪来还有一种业务场景需要生成一个看起来随机的唯一ID。很多人直接Math.random().toString(36).slice(2)但要注意在超高并发下仍有碰撞概率。实际项目中我习惯加个时间戳前缀再拼随机串或者直接用crypto.randomUUID()。如果你就是想在数组里随机挑一个不重复的项前面讲的洗牌方案已经覆盖了不需要在ID上纠缠。5. 实战收尾一个广告场景的随机调度拼接很多讲随机取值的文章到算法就结束了但实际业务里随机取值往往只是某个更大流程的一环。最后我分享一个我在广告系统里做过的真实案例把整篇文章的知识点串起来。需求是这样的页面上一块区域要展示广告。规则有四个从全量广告池中随机抽出 5 条广告池里有品牌广告和效果广告两种类型要求前者最多出现2条同一条广告在一小时内不能被重复展示去重逻辑基于曝光记录不在这里整体曝光比例要接近预设的流量分配比如品牌:效果 3:7。这个需求单独拆开看都不难合在一起就需要打磨。我当时的实现思路是把广告池分成品牌组和效果组两个数组用partialShuffle分别从两组随机取样算出各自需要的数量拼接后整体做一次洗牌避免品牌全部在前面、效果全部在后面最后对结果做一次同广告主相邻检查把同广告主的条目尽量隔开。整个流程下来代码量不大但每一步都在和真随机做对抗。真随机是纯粹的概率业务要的是在约束条件下的随机感。从单个Math.floor(Math.random() * arr.length)到带种子的洗牌算法再到分组的约束随机这条链路几乎覆盖了前端日常所有和数组随机取值相关的需求。我自己踩过Math.round概率不均的坑也被sort(() Math.random() - 0.5)的分布偏差坑过一次那次还是用户反馈抽奖概率有问题才发现的。希望这篇能帮你把随机取值这个看似简单、实则门道不少的需求一次做对。
返回列表