在實作活動抽籤、隨機推薦文章或測驗題庫抽題時,最常遇到的需求便是使用 JavaScript 隨機取出陣列元素值(從陣列中隨機取出 1 個或多個不重複的元素)。本文整理「隨機取出單一元素」、「
while索引記錄法」以及時間複雜度最佳的「Fisher-Yates 洗牌演算法」三種完整程式碼範本。
一、 3 種陣列隨機取值情境與演算法比較
根據要取出的元素數量與陣列大小,可選擇對應的實作方式:
| 抽籤需求情境 | 演算法與實作機制 | 時間複雜度 | 適用場景 |
|---|---|---|---|
| 隨機取出 1 筆元素 | arr[Math.floor(Math.random() * arr.length)] | O(1) | 隨機抽一句標語、單筆抽獎 |
| 取出少量不重複元素 | while 迴圈搭配 includes 檢查隨機索引 | O(k^2) | 從小陣列中隨機挑選 3~5 筆資料 |
| 取出多筆不重複元素或打亂全部 | Fisher-Yates Shuffle(原地交換洗牌後 slice) | O(n) | 大量題庫抽題、撲克牌洗牌、商品隨機排序 |
二、 範本一:使用隨機索引迴圈取出 N 筆不重複元素
假設現在有一筆陣列資料,希望從陣列中隨機取出 5 筆不重複的陣列元素,可透過 Math.floor(Math.random() * arrayLength) 產生合法索引並過濾重複值:
// 1. 亂數取得陣列中不重複的 index
function getRandomIndexes(array, num) {
const indexes = [];
const arrayLength = array.length;
// 避免要取得的資料數量 num > 原始資料數量時造成無窮迴圈
const count = num < arrayLength ? num : arrayLength;
while (indexes.length < count) {
const randomIndex = Math.floor(Math.random() * arrayLength);
if (!indexes.includes(randomIndex)) {
indexes.push(randomIndex);
}
}
return indexes;
}
// 2. 對應陣列 index 取得元素資料
function getRandomElementsFromArray(array, count) {
const randomIndexes = getRandomIndexes(array, count);
return randomIndexes.map((index) => array[index]);
}
const a = [1, 2, 3, 4, 5, 6, 7, 8, 9];
const random5Elements = getRandomElementsFromArray(a, 5);
console.log(random5Elements); // 印出 5 個不重複的隨機元素,例如: [7, 2, 9, 1, 5]
為什麼計算隨機索引必須用 Math.floor() 而不能用 Math.ceil()?
Math.random()回傳的範圍是0(包含)到1(不包含) 之間的浮點數(即0 <= r < 1)。- 當陣列長度
array.length為9時,Math.random() * 9的結果介於0到8.9999...之間。 - 透過
Math.floor()無條件捨去小數後,剛好能均勻得到0, 1, 2, ..., 8共 9 個合法的陣列索引。 - 若誤用 JavaScript Math.ceil 無條件進位,則
0.1~8.999會被進位成1~9,不僅索引0幾乎永遠抽不到,抽到索引9時還會因為超出陣列長度而拿到undefined!
三、 範本二:使用 Fisher-Yates 洗牌演算法(大量資料抽籤)
當陣列元素很多(例如從 1,000 筆名單中抽出 800 筆)時,用 while (!indexes.includes(randomIndex)) 到後期會因為一直抽到重複號碼而重試許多次。此時改用 Fisher-Yates 洗牌演算法,先複製一份陣列並從尾端往前隨機交換位置,最後用 .slice(0, count) 取出前 N 筆,保證不會重複且效能穩定:
/**
* 使用 Fisher-Yates 洗牌演算法隨機取出 N 筆不重複元素(不修改原陣列)
* @param {Array} array - 原始陣列
* @param {number} count - 要取出的不重複元素數量
* @returns {Array} 隨機抽出的新陣列
*/
function sampleArrayWithoutReplacement(array, count) {
if (!Array.isArray(array) || array.length === 0 || count <= 0) return [];
// 淺拷貝一份新陣列,避免汙染原始陣列順序
const copy = [...array];
const limit = Math.min(count, copy.length);
for (let i = 0; i < limit; i++) {
// 從 [i, copy.length - 1] 剩餘區間中隨機挑一個位置與 i 交換
const randomIdx = i + Math.floor(Math.random() * (copy.length - i));
[copy[i], copy[randomIdx]] = [copy[randomIdx], copy[i]];
}
return copy.slice(0, limit);
}
const prizes = ['頭獎', '貳獎', '參獎', '肆獎', '伍獎', '普獎'];
console.log(sampleArrayWithoutReplacement(prizes, 3));
// 輸出 3 個不重複獎項,例如: ["參獎", "頭獎", "伍獎"]
(註:若陣列中預先混入了 null 或 undefined 空值,建議在抽籤前先參考 JS 去除 array 中 null 或空值 清理陣列;若是要找出陣列中的最大或最小數值,則可參考 JS 陣列取得最大值與最小值的 5 種方法。)
四、 常見問題與排錯 Q&A
Q1:為什麼網路上常見的 array.sort(() => Math.random() - 0.5) 不建議用於正式抽獎?
- 問題原因:V8 引擎的
Array.prototype.sort()(Timsort)並非為隨機比較函式設計,當比較函式不具備傳遞性(Transitivity)時,各元素落在不同位置的機率會嚴重偏斜(越靠前面的元素留在原位附近的機率明顯較高),無法產生真正的均勻分佈。 - 解決思路:需要公平洗牌或抽獎時,請使用上方的 Fisher-Yates 洗牌演算法。
發佈留言