数组随机抽取这个需求,在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,下标直接越界变成undefined;Math.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.getRandomValues,Node.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的边界理解到概率均匀性,从不重复抽取的算法选择到安全随机的落地,每一个细节在真实项目里都有可能变成线上事故或用户投诉。我建议你在项目里把这几个函数沉淀成独立的工具模块,配上单元测试,把概率分布、数组不改动、参数边界全部测一遍。数据结构和算法的基本功,往往就在这些不起眼的小函数里体现得最真,也最值得花时间去打磨。