JavaScript数组随机抽取:从Math.random到Fisher-Yates洗牌的完整指南
数组随机抽取这个需求在JS开发里出现的频率高到离谱。不管是前端做抽奖组件、随机展示Banner还是后端Node写个推荐接口甚至刚学JS的时候自己做练习题都会碰到“从数组里随机拿一个/拿几个元素”这种需求。我见过很多老手写出来的代码能跑但细看全是坑概率不均、数组被污染、count大于数组长度直接报错、抽奖结果被用户薅羊毛……这篇文章我就把这一个知识点彻底讲透从Math.random()的原理说到Fisher-Yates洗牌再到加权随机和安全随机最后配上三个真实项目里的落地代码保证你看完能直接用到项目里。1. 先从随机数说起Math.random()的区间与整数映射很多人写随机代码从来不看Math.random()的边界条件导致后面踩坑都不知道为什么。这里先把地基打牢。1.1 Math.random()返回的到底是什么Math.random()的规范定义是返回一个伪随机浮点数范围是[0,1)也就是大于等于0严格小于1。0出现的概率极低但不是不可能1永远不会出现。这个“左闭右开”的区间设计是有讲究的它让后续做整数映射的时候非常干净。比如我要生成[0, 6)区间的浮点数直接Math.random() * 6就行得到的结果最小是0最大无限接近6但永远到不了6。这一步是后面所有数组随机取值的基础因为数组下标正好是0到length-1的整数而Math.random() * arr.length天然把结果限制在[0, length)区间向下取整就得到合法的数组下标。理解这个之后扩展就很简单。如果你想生成[min, max)的半开区间浮点数公式是Math.random() * (max - min) min。如果你想要[min, max]的闭区间整数公式是Math.floor(Math.random() * (max - min 1)) min注意这里的max - min 1是因为整数区间长度要包含两端这个细节看起来小实际写错的人非常常见。1.2 从浮点数到数组下标的转换取数组随机下标的标准写法就是一行const index Math.floor(Math.random() * arr.length);有人会问为什么不用Math.round或者Math.ceil因为这两个函数都会破坏均匀性和边界安全性。Math.ceil(Math.random() * arr.length)在极端情况会返回arr.length下标直接越界变成undefinedMath.round更隐蔽它会让首尾两个值的概率比其他值低一半。这个概率问题我在第5章的踩坑部分会详细展开这里先记住取整用Math.floor不要用其他。1.3 封装一个通用随机整数函数如果项目里随机逻辑比较多建议抽一个通用函数出来统一维护边界逻辑function randomInt(min, max) { // 生成 [min, max] 区间内随机整数 return Math.floor(Math.random() * (max - min 1)) min; }这个函数接受闭区间参数语义清楚不容易出错。有了它随机取数组下标就变成randomInt(0, arr.length - 1)。我在项目里经常把这类基础函数放在utils里避免每个业务模块都重新写一遍既容易统一做单元测试也能避免不同人风格不一致导致的问题。2. 随机取一项最常用也最容易出错的“三行代码”随机从数组里取一项这个需求几乎每个项目都有写法看着简单但细节里藏着不少坑。2.1 标准写法与函数封装最简单直接的写法是const item arr[Math.floor(Math.random() * arr.length)];这段代码没有问题但每次重复写有点啰嗦而且数组为空时会返回undefined。我一般会封装成函数连空数组异常一起处理掉function randomPickOne(arr) { if (!Array.isArray(arr) || arr.length 0) { return undefined; } const index Math.floor(Math.random() * arr.length); return arr[index]; }这里做了两类检查第一是判断是否为数组防止误传对象或null进来导致length属性异常第二是判断数组是否为空空数组直接返回undefined让调用方自行处理。实际项目中很多人忽略空数组判断结果页面某天数据源为空时随机推荐的东西就变成了undefined渲染在界面上就是空白或者报错。2.2 处理类数组和超长数组有些场景里拿到的不是真正的数组而是类数组对象比如arguments或者DOM的NodeList。直接把类数组传给randomPickOne会有兼容性问题。处理办法是先转换成数组const list Array.from(nodeList); const item randomPickOne(list);Array.from不仅可以转化NodeList、arguments还可以转化Set、Map这些可迭代对象是我平时最常用的转换方式。如果你担心Array.from的性能那在NodeList长度只有几十的场景下完全不必在意转化一次的开销可以忽略不计。2.3 随机取一项的真实应用场景我在实际项目里用到这个函数的地方非常多。举几个典型的例子用户打开页面时随机展示一条欢迎文案从策略列表里随机挑一个运营动作执行轮播图在用户刷新时随机初始播放某一张以及AB实验里给用户分到不同桶时对取模结果做随机化处理。这些场景的共同点是“只要一个结果、别太偏就行”用randomPickOne这个函数就足够应对。如果你把这段代码用在抽奖这类需要保证公平性的场景那就要换成第5章讲的安全随机方案普通Math.random无法防住恶意用户预测结果。3. 随机取几项不重复四种方法一次讲透随机取一个容易随机取多个且不重复就有点意思了。这里我整理了四种常见实现从最直观的splice方法讲到性能最好的部分洗牌你可以按项目场景自由选择。3.1 splice法直观但必须拷贝数组思路非常简单从原数组里随机选中一个元素把它从数组里移除然后继续选下一个。因为被选过的元素已经从候选池里移除了所以不会重复。function randomPickUnique(arr, count) { if (!Array.isArray(arr) || arr.length 0) { return []; } if (count arr.length) { throw new Error(randomPickUnique: count不能大于数组长度); } const pool arr.slice(); const result []; for (let i 0; i count; i) { const index Math.floor(Math.random() * pool.length); result.push(pool[index]); pool.splice(index, 1); } return result; }这个方法最容易理解面试里也常有人这么回答。splice删除指定下标的元素之后数组长度减一下一次随机下标的范围自动缩小逻辑上非常干净。但也因为splice的时间复杂度是O(n)每删除一个元素都需要把后面的元素往前挪所以整个算法的时间复杂度是O(n * count)。如果数组长度和count都不大这个方案完全没问题但如果你有一个十万级的数组要抽几百个元素splice法的性能就不太行了。3.2 Fisher-Yates洗牌法最推荐的“标准答案”Fisher-Yates也叫Knuth洗牌是计算机科学里最经典的洗牌算法核心思路是遍历数组从当前位置到末尾之间随机选一个位置交换。正确实现之后每种排列的出现概率都是均等的这也是它能写进各种教科书的原因。function shuffle(arr) { const result arr.slice(); for (let i result.length - 1; i 0; i--) { const j Math.floor(Math.random() * (i 1)); [result[i], result[j]] [result[j], result[i]]; } return result; } function randomPickUnique(arr, count) { if (count arr.length) { throw new Error(randomPickUnique: count不能大于数组长度); } return shuffle(arr).slice(0, count); }关键点有两个。第一个是为什么从后往前遍历如果从前往后遍历第i个元素可能会在后续的交换中再次被换到其他位置整个过程会变得混乱且难以保证每个位置被选中的概率均等。从后往前遍历时每轮操作确定下来的尾部元素不会再被碰到保证了均匀性。第二个是一次解构赋值完成两个元素的交换代码更简洁不用引入临时变量。Fisher-Yates洗牌的典型应用是随机播放列表。我做过一个音乐播放器的随机播放功能歌单加载后用这个洗牌函数打乱顺序生成新的播放列表。这种方案不仅不会重复而且保证每首歌都出现一次比“每次都随机跳一首”体验好很多。3.3 部分洗牌抽少量数据时的高效方案完整洗牌会处理整个数组但如果你只需要从十万条里面抽十条把十万条全部洗一遍其实有点浪费。部分洗牌的思路是只洗前count个位置让前count个位置的元素足够随机然后直接截取前count个。function randomPickUnique(arr, count) { if (count arr.length) { throw new Error(randomPickUnique: count不能大于数组长度); } const result arr.slice(); for (let i 0; i count; i) { const j i Math.floor(Math.random() * (result.length - i)); [result[i], result[j]] [result[j], result[i]]; } return result.slice(0, count); }和完整Fisher-Yates相比区别在于只交替循环count次而不是arr.length - 1次。第i轮循环时从i到数组末尾随机选一个位置j和第i个元素交换。这样前count个位置就像被随机“扫描”过一遍每个位置都有可能落入不同元素已经足够满足绝大多数业务需求。时间复杂度是O(count)在抽取数量远小于数组长度时优势非常明显。我的经验是如果count超过数组长度的一半直接用完整洗牌更好代码也更简单如果count只占数组长度的很小比例部分洗牌是更优选择。3.4 Set去重法写起来快但碰撞是硬伤还有一种思路是利用Set天然去重的特性不断生成随机下标把对应元素丢进Set直到Set的size满足count为止。这个方案代码很短function randomPickUnique(arr, count) { if (count arr.length) { throw new Error(randomPickUnique: count不能大于数组长度); } const result new Set(); while (result.size count) { const index Math.floor(Math.random() * arr.length); result.add(arr[index]); } return Array.from(result); }乍看很简洁但它有一个致命问题当count接近arr.length时随机下标会大量命中已经选过的元素出现严重的碰撞循环次数急剧上升。举个例子数组长度100要随机抽99个最后一个元素可能需要每次以1%的概率随机到运气差一点可能要循环几百次甚至上千次才能集齐。所以这个方法只适合count远小于length的场景比如从千条数据里抽三五条。3.5 四种方法选型对比方法时间复杂度空间复杂度是否修改原数组适用场景splice法O(n*count)O(n)不影响原数组小到中等数组代码直观Fisher-Yates完整洗牌O(n)O(n)不影响原数组随机打乱全部或抽取比例较高部分洗牌O(count)O(n)不影响原数组大数组抽少量性能最优Set去重法不确定(碰撞严重)O(count)不影响原数组仅限count远小于length排序依据是实际项目里的综合体验。我个人的默认选择是数据量小用splice法数据量大用部分洗牌直接不要犹豫。完整洗牌在“需要全部打乱”或“抽取比例超过一半”时用Set去重法仅限临时脚本或小数组场景。4. 允许重复与加权随机更贴近业务需求的扩展不重复抽取是很多场景的默认要求但也有一些场景恰恰需要允许重复或者需要让某些元素被抽中的概率比其他元素更高。这两个方向都很有意思展开讲讲。4.1 允许重复的随机抽样需求场景很简单从奖品池里连续抽N次每次独立抽取上一次抽中的结果不影响下一次。代码比不重复抽取简单很多function randomPickWithRepeat(arr, count) { if (!Array.isArray(arr) || arr.length 0) { return []; } const result []; for (let i 0; i count; i) { const index Math.floor(Math.random() * arr.length); result.push(arr[index]); } return result; }这个函数的关键点在于每次循环都重新计算随机下标不做任何记忆或剔除。业务上典型的场景是“抽盲盒”一个盒子的库存没被抽完之前每次都从完整池子里随机出结果允许重复抽到同一种款式。用这套逻辑就可以不需要额外维护“已抽中”的数据结构。4.2 加权随机从等概率到按权重分配很多运营活动希望不同奖品的概率不同比如一等奖1%二等奖10%三等奖89%。等概率随机就满足不了了这时候用加权随机。加权随机的核心原理非常简单把每个元素按权重映射到一条线段上然后随机丢一个点看它落在哪一段。实现起来有两种思路这里提供一份常见且好懂的代码function weightedRandomPick(arr, weights) { if (arr.length ! weights.length) { throw new Error(weightedRandomPick: 数组和权重长度必须一致); } const totalWeight weights.reduce((sum, w) sum w, 0); let random Math.random() * totalWeight; for (let i 0; i arr.length; i) { random - weights[i]; if (random 0) { return arr[i]; } } return arr[arr.length - 1]; }它的过程相当于随机数从线段起点开始走每经过一个奖品就扣掉它对应的权重当随机数减到小于0时说明“这把随机标尺”停在了这个奖品的区间内。比如三个奖品的权重分别是1、2、3总权重是6那么Math.random() * 6的结果在0到6之间均匀分布。0到1会选中第一个奖品1到3选中第二个3到6选中第三个正好对应权重比例。用的时候要小心权重为0的元素。权重为0时它的区间长度为0随机数永远不会落在上面这个元素就永远不会被选中这在某些场景正是你想要的效果比如临时下架某个奖品。4.3 用seed让随机结果可复现正常业务里我们希望结果随机但调试和测试的时候反而希望结果稳定可复现。比如某个线上问题只在某个特定随机序列下出现如果能固定随机种子复现用户看到的那个顺序排查效率会高很多。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; }; } const random mulberry32(42); console.log(random()); // 固定种子会产生固定序列这个函数生成的范围同样是在[0,1)之间使用方式和Math.random()几乎一致只是每次调用前要先拿到带种子的random函数。A/B测试分组、游戏随机地图生成、自动化测试等场景用这个方案可以保证同样的种子永远得到同样的结果。把证明“随机结果符合期望”变成“随机过程可复现”调试难度会低很多。5. 高频踩坑概率不均、数组污染、性能与安全这段内容是这些年我看到别人踩坑的集锦也是我自己写随机函数时真正长记性的地方。这些问题很多都不会直接报错但会在业务层面表现得很诡异。5.1 概率分布不均别让Math.round坑了你把取整数下标写成Math.round(Math.random() * arr.length)是最常见的概率陷阱。很多人觉得绝对随机就是“取整随机”但Math.round会把边界值压缩到相邻区间里导致首尾概率偏差。具体算一下就能明白。假设数组长度是2也就是只有下标0和1。Math.random() * 2的结果在[0, 2)之间如果用Math.round取整[0, 0.5)会变成0[0.5, 1.5)会变成1[1.5, 2)会变成2。这意味着下标0的概率只有25%下标1的概率是50%而下标2根本不存在拿到直接undefined。即使只讨论0和1两个下标概率也是1比2完全谈不上均匀。正确的做法永远是Math.floor。如果你真遇到了Math.round写出概率不均的问题不用怀疑就是取整方式错了换成Math.floor即可修复。5.2 原数组被改动先拷贝再操作使用splice法时如果没事先拷贝原数组就会被一步步掏空。很多人写完这个函数后在组件里反复调用发现列表数据越来越少排查半天才发现是随机函数内部动了原数组。解决方案很简单在所有可能修改数组的函数里第一步都先执行arr.slice()确保后续操作都是在副本上进行的。如果你用Fisher-Yates洗牌这点尤其要注意因为交换操作虽然不删除元素但会改变元素顺序同样会污染原数组。我的习惯是写工具函数时默认“不改入参”这个约定能避免大量隐性问题配合单元测试很容易过滤掉这类bug。5.3 count参数异常越界、负数、NaN随机抽取多个元素的函数传入count时可能出现三种异常count大于数组长度、count是负数、count是NaN。count大于数组长度时如果直接截取结果就是整个数组但业务上大多数情况这是意图不明确应当主动抛错或截断。count是负数时正常循环不会取到任何元素但也意味着“抽取0个”这种静默行为如果没被调用方察觉可能掩盖上游数据的错误。count是NaN时会出现死循环或返回错误结果因为循环条件永远判断不出正确次数。我的习惯是入口严格校验count不是有限正整数时直接返回空数组count大于数组长度时直接抛错。宁可让错误往下游暴露也不要让代码带着模糊状态继续执行。5.4 大数组、高频调用时的性能问题如果你的数组有几十万甚至上百万条数据每次抽一项都调用完整洗牌性能会有明显损耗。更合理的做法是只抽少量用部分洗牌只抽一个用randomPickOne从不重复抽取切换到允许重复抽取因为少了去重数组的操作随机数的调用次数更少、更快。高频调用的另外一个大坑是“每次都重新生成随机数组”。有些业务场景是同一个固定池子被很多用户高频访问如果每个请求都把数组拷贝一遍再洗牌内存和CPU都会被白白消耗。这种情况应该把“池子数据不变”和“洗牌结果”分开看如果能保证池子不可变可以做一层缓存只对固定池子做一次洗牌然后按顺序让每个用户取一组。当然这要看具体业务是否允许顺序随取走而推进不能乱用但这个优化思路很值得参考。5.5 抽奖场景别用Math.random安全随机与拒绝采样这是我在一个抽奖项目里被坑过后才学到的一课。Math.random()是伪随机数生成器而且不同引擎的实现有差异安全性不高。如果抽奖系统直接依赖Math.random()一旦用户知道随机序列的规律理论上可以预测后续抽奖结果从而实现“作弊”。真正严肃的抽奖场景应该用Web Crypto API提供的密码学安全随机数。浏览器里是crypto.getRandomValuesNode.js里是crypto.randomInt。这里提供一个基于crypto.getRandomValues的安全随机数组抽取函数function secureRandomPick(arr) { if (!Array.isArray(arr) || arr.length 0) { return undefined; } const buf new Uint32Array(1); crypto.getRandomValues(buf); const index buf[0] % arr.length; return arr[index]; }但注意用取模运算会引入模偏差modulo bias。因为Uint32Array的最大值是4294967295它整除arr.length后可能有余数余数区间的值出现的概率就会略微偏高。如果奖品数量不多这个偏差微乎其微但如果你对公平性有极致要求需要做拒绝采样。function secureRandomIndex(maxExclusive) { const limit Math.floor(0xFFFFFFFF / maxExclusive) * maxExclusive; const buf new Uint32Array(1); let value; do { crypto.getRandomValues(buf); value buf[0]; } while (value limit); return value % maxExclusive; }这个函数的逻辑是只接受0到limit之间均匀分布的数大于等于limit的结果直接重新采样直到结果落在均匀区间内。代码看起来有点绕但这是密码学上正确的做法真正做抽奖系统时值得下这个功夫。6. 实战串联抽奖、抽题、随机播放列表的代码落地最后拿三个真实业务场景把前面所有内容串一遍每个场景都给出可以落地的完整代码。这才是这个知识点真正发挥价值的地方。6.1 场景一按权重抽奖的完整实现抽奖活动最核心的逻辑是奖品有不同权重每个用户请求时要随机返回一个奖品。安全方面必须用crypto.getRandomValues不能用Math.random。function lotteryDraw(prizes, weights) { if (!Array.isArray(prizes) || prizes.length 0) { return null; } if (prizes.length ! weights.length) { throw new Error(奖品数量与权重数量不匹配); } const totalWeight weights.reduce((sum, w) sum w, 0); const random secureRandomIndex(totalWeight); let cumulative 0; for (let i 0; i prizes.length; i) { cumulative weights[i]; if (random cumulative) { return prizes[i]; } } return prizes[prizes.length - 1]; }secureRandomIndex接受的是maxExclusive参数传总权重进去就能得到[0, totalWeight)之间的均匀随机整数。然后累加权重找到随机数所在的区间。这样每个奖品的概率严格等于它的权重占总权重的比例不会有模偏差。另外还得提醒一句抽奖系统的完整性不止随机抽奖算法还包括库存扣减、防刷、幂等等一系列设计这里只讨论随机抽取核心部分。6.2 场景二从题库随机抽不重复的N道题在线考试系统经常有“从一千道题里随机抽五十道”的需求。题库数量大、抽取数量占比小用部分洗牌最合适。如果抽题数量接近题库总量我一般会退回到完整洗牌因为两种方案性能差异已经不大了完整洗牌代码更简单。const questionBank getQuestionBank(); // 假设这里有一千道题 function extractExamQuestions(bank, count) { if (count bank.length) { throw new Error(抽取题目数不能超过题库大小); } const shuffled bank.slice(); for (let i 0; i count; i) { const j i Math.floor(Math.random() * (shuffled.length - i)); [shuffled[i], shuffled[j]] [shuffled[j], shuffled[i]]; } return shuffled.slice(0, count); } const examQuestions extractExamQuestions(questionBank, 50);这里没有污染原题库因为开头就做了slice。返回的题目顺序是部分洗牌后的顺序符合“随机抽取”的业务语义。如果你需要题目顺序也完全随机那就用完整洗牌。实际我做在线组卷时还会注意一个细节要防止相同的两个考生拿到完全一样的试卷排列顺序这时候只要每次生成试卷前用当前时间戳做种子初始化随机函数就能保证每次组卷顺序都不同。6.3 场景三随机播放列表的生成音乐播放器里的随机播放如果直接用Math.random()一首一首地跳用户很容易听到重复歌曲体验很差。标准做法是生成一个随机排列的播放列表播放完再生成下一轮。function buildShufflePlaylist(songList) { const playlist songList.slice(); for (let i playlist.length - 1; i 0; i--) { const j Math.floor(Math.random() * (i 1)); [playlist[i], playlist[j]] [playlist[j], playlist[i]]; } return playlist; }这个就是标准Fisher-Yates洗牌。实际项目里我会在这个基础上再扩展两个逻辑第一是“避免上一轮最后一首歌和这一轮第一首歌相同”判断一下边界如果相同就把新列表第一首歌和另一首歌交换第二是支持播放进度记忆用户切后台回来还能恢复到原来的位置。这些需求看似小但都是随机播放体验中直接影响用户好感度的细节。我自己的经验里随机播放列表生成这块最容易犯的错误是直接在原歌单数组上进行洗牌导致切歌时原顺序也乱了用户想倒回去找之前那首歌根本找不到。所以这个场景里slice拷贝是必不可少的。最后再分享一个体会。数组随机这个需求看起来特别小甚至小到很多人觉得不值得单独写篇文章但真正实现得又对又好并不容易。从Math.random的边界理解到概率均匀性从不重复抽取的算法选择到安全随机的落地每一个细节在真实项目里都有可能变成线上事故或用户投诉。我建议你在项目里把这几个函数沉淀成独立的工具模块配上单元测试把概率分布、数组不改动、参数边界全部测一遍。数据结构和算法的基本功往往就在这些不起眼的小函数里体现得最真也最值得花时间去打磨。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →