紀錄工作經驗、相關知識,解決技術相關問題。

Javascript, 新手 Jamp Holes, 網站相關

[JS] JavaScript 隨機取出陣列元素值與陣列洗牌 (Math.random / Fisher-Yates)

[JS] JavaScript 隨機取出陣列元素值與陣列洗牌 (Math.random / Fisher-Yates) 特色圖片

在實作活動抽籤、隨機推薦文章或測驗題庫抽題時,最常遇到的需求便是使用 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 洗牌演算法。

參考資料與延伸閱讀

發佈留言