Day 39-40:用 Map、Array.sort 和 Fisher-Yates 洗牌,拆解一個「顏色抽獎」Kata

這篇筆記整理的是:
如何用 JavaScript 原生資料結構(Array / Object / Map / Set)搭配 Array.prototype.sort、Fisher-Yates 洗牌,實作一個小型 CLI「顏色抽獎」模擬程式 。 developer.mozilla

過程中會刻意用 TDD 思維,把邏輯拆成小函式,一步步構出整體流程。


題目情境:Color Raffle Kata

想像一個開源的 JavaScript Kata:「Color Raffle」:

  • 有最多 50 個參加者,每人身上有 3 個彩色標籤。

  • 標籤有 5 種顏色,每種顏色一開始各準備 30 個(總共 150 個標籤)。

  • 遊戲過程中玩家互相交換標籤,最後每個人的身上都有 3 個顏色(可以重複)。

  • 主辦單位進行抽獎:

    • 統計每種「顏色組合」出現幾個人(例如「紅-藍-綠」有 5 人)。

    • 顯示所有組合及人數,並依人數排序。

    • 再統計一次五種顏色目前各自出現幾次,確認總數仍是 150 個。

這個 Kata 的重點不是「模擬交換過程」,而是練習幾個關鍵技能 : developer.mozilla

  • 用 Array / Object / Map / Set 表達不同型態的資料。

  • 用 Map 做聚合統計(aggregation)。

  • 用 Array.prototype.sort 做多條件排序 。 cs.unb

  • 實作 Fisher-Yates 洗牌演算法,生成隨機分布 。 en.wikipedia

  • 用 TDD 把大問題拆成小函式。


整體架構:拆成五個函式

為了讓程式容易測試與維護,可以先用自然語言拆出五個函式:

  1. generateDancers(dancerCount)
    產生隨機參加者及其顏色組合(不寫測試,因為帶隨機性)。

  2. countColorTotals(dancers)
    統計五種顏色各自出現幾次(TDD)。

  3. countCombinations(dancers)
    統計每種顏色組合出現幾個人(TDD)。

  4. sortCombinations(combinationsMap)
    將組合 Map 轉成排序後的陣列,方便輸出(TDD)。 developer.mozilla

  5. main()
    串起整個流程,作為 CLI 入口(不寫測試)。

檔案結構可以這樣規劃:

color-raffle-kata/
├── core.js          // 核心邏輯
├── core.test.js     // TDD 測試
├── main.js          // CLI 入口
└── package.json

generateDancers:建顏色池+洗牌+分配

目標

  • 輸入:dancerCount: number,例如 50。

  • 輸出:Array<Array<string>>,每個元素代表一位參加者的三個顏色,例如:

    [  ['red', 'blue', 'green'],
      ['red', 'red', 'yellow'],
      // ...
    ]
    

顏色池與分配規則

  • 顏色種類:['red', 'blue', 'green', 'yellow', 'purple']。

  • 每種顏色放入 30 個標籤。

  • 總標籤數:dancerCount * 3(預設 50 人 → 150 個)。

  • 流程:

    1. 建立顏色池 pool(150 個元素)。

    2. 使用 Fisher-Yates 演算法洗牌 pool 。 en.wikipedia

    3. 每 3 個標籤分配給一個參加者。

Fisher-Yates 洗牌(shuffle)

Fisher-Yates 是一個經典的、無偏差(unbiased)的洗牌演算法,適合用來隨機排列有限長度陣列 。 stackoverflow

實作(原地修改陣列):

/**
 * Fisher-Yates 洗牌演算法(原地修改 array)
 * @param {Array} array - 要打亂的陣列(會直接修改原陣列)
 * @returns {void}
 */
export function shuffle(array) {
  for (let i = array.length - 1; i > 0; i -= 1) {
    const randomIndex = Math.floor(Math.random() * (i + 1));
    [array[i], array[randomIndex]] = [array[randomIndex], array[i]];
  }
}

幾個重點:

  • 迴圈從最後一個索引往前走。

  • 每次在 [0, i] 範圍內選出一個 randomIndex,與 i 位置交換 。 en.wikipedia

  • Math.random() * (i + 1) 產生 [0, i+1) 範圍的浮點數,再用 Math.floor 轉成 [0, i] 的整數 。 tc39

  • 這是 in-place 演算法:不會回傳新陣列,而是直接修改傳入的陣列 。 en.wikipedia

generateDancers 設計草稿

export function generateDancers(dancerCount) {
  const pool = [];
  const colors = ['red', 'blue', 'green', 'yellow', 'purple'];
  const tokensPerColor = 30;

  // 建立顏色池
  colors.forEach((color) => {
    for (let i = 0; i < tokensPerColor; i += 1) {
      pool.push(color);
    }
  });

  // 洗牌(原地修改 pool)
  shuffle(pool);

  // 每 3 個顏色分配給一個參加者
  const dancers = [];
  for (let i = 0; i < dancerCount; i += 1) {
    dancers.push(pool.slice(i * 3, i * 3 + 3));
  }

  return dancers;
}

這個函式因為帶有隨機性,可以用手動印出、簡單檢查總數與分布,而不一定用單元測試檢查具體值。


countColorTotals:用 Object 做顏色計數器

目標

  • 輸入:dancers: Array<Array<string>>。

  • 輸出:一個物件,紀錄每個顏色出現次數:

{
  red: number,
  blue: number,
  green: number,
  yellow: number,
  purple: number,
}

TDD 測試案例(語意)

  1. 空陣列:

    • 輸入:[]

    • 輸出:所有顏色 0。

  2. 單一參加者、三個不同顏色:

    • 輸入:[['red', 'blue', 'green']]

    • 輸出:red/blue/green 各 1,yellow/purple 為 0。

  3. 多個參加者顏色重複:

    • 輸入:

      [  ['red', 'blue', 'green'],
        ['red', 'red', 'red'],
        ['blue', 'green', 'yellow'],
      ]
      
    • 輸出:

      { red: 4, blue: 2, green: 2, yellow: 1, purple: 0 }
      

實作模式:Object 當計數器

export function countColorTotals(dancers) {
  const counts = {
    red: 0,
    blue: 0,
    green: 0,
    yellow: 0,
    purple: 0,
  };

  dancers.forEach((dancer) => {
    dancer.forEach((color) => {
      counts[color] += 1;
    });
  });

  return counts;
}

這裡 Object 是自然的選擇,因為:

  • key 集合是固定、已知的一組字串 。 developer.mozilla

  • 查找 counts[color] 平均是 O(1) 。 tc39


countCombinations:用 Map 統計顏色「組合」

目標

統計每種顏色組合有幾個人,順序不同但組合相同要算在一起。

  • 輸入:dancers: Array<Array<string>>

    const dancers = [  ['red', 'blue', 'green'],
      ['green', 'red', 'blue'],
      ['red', 'red', 'red'],
    ];
    
  • 輸出:Map<string, number>

    Map {
      'blue-green-red' => 2,
      'red-red-red' => 1,
    }
    

為何用 Map 而不是 Object?

這裡的 key 是「顏色陣列」轉成的字串,例如 'blue-green-red'。
可以用 Object 當作 map,但 Map API 的語意更清楚:

標準化 key 的關鍵(normalize)

同一種組合即使順序不同:

  • ['red', 'blue', 'green']

  • ['green', 'red', 'blue']

都應該被當成同一 key。

標準化策略:

  1. 複製顏色陣列(避免修改原資料)。

  2. 排序(使用 Array.prototype.sort(),依字串的 Unicode/UTF-16 編碼排序)。 developer.mozilla

  3. 用 '-' join 成字串當作 key。

TDD 測試案例(語意)

  1. 空陣列 → 回傳空 Map。

  2. 單一參加者 → Map.size === 1,該 key 的 count 為 1。

  3. 兩個參加者顏色順序不同但組合相同 → Map.size === 1,count 為 2。

  4. 多個參加者、組合都不同 → Map.size 等於組合種類數,各自 count 正確。

實作

export function countCombinations(dancers) {
  const combinations = new Map();

  dancers.forEach((dancer) => {
    const key = dancer.slice().sort().join('-');

    if (combinations.has(key)) {
      combinations.set(key, combinations.get(key) + 1);
    } else {
      combinations.set(key, 1);
    }
  });

  return combinations;
}

注意:

  • dancer.slice().sort():先用 slice() 建立副本,再 sort,避免直接修改原陣列 。 developer.mozilla

  • 用 Map.prototype.has、get、set 來維護計數 。 developer.mozilla


sortCombinations:Map → 陣列 + 排序

目標

  • 輸入:Map<string, number>。

  • 輸出:排好序的陣列,每個元素形如 { combo: string, count: number }。

範例輸入:

const combinationsMap = new Map([  ['blue-green-red', 5],
  ['red-red-red', 8],
  ['green-yellow-purple', 3],
]);

範例輸出(依 count 降序):

[
  { combo: 'red-red-red', count: 8 },
  { combo: 'blue-green-red', count: 5 },
  { combo: 'green-yellow-purple', count: 3 },
]

目前需求只規定「依人數排序」,沒有要求人數相同時的順序,因此不額外實作次要排序,避免過度設計 。 developer.mozilla

步驟 1:Map → 陣列

const result = [];

combinationsMap.forEach((value, key) => {
  result.push({ combo: key, count: value });
});

也可以用 entries() + 展開運算子:

const result = [...combinationsMap.entries()].map(([key, value]) => ({
  combo: key,
  count: value,
}));

Map.prototype.entries() 回傳的是 iterator,展開運算子 ... 會依插入順序消耗 iterator 的值,轉成陣列 。 developer.mozilla

步驟 2:用 Array.sort 排序

Array.prototype.sort(compareFn) 的比較函式規則 : cs.unb

  • 回傳值 < 0 → a 排在 b 前面。

  • 回傳值 > 0 → a 排在 b 後面。

  • 回傳值 === 0 → 相對順序不變。

要做「count 降序」:

  • 當 a.count > b.count 時,希望 a 在前 → 讓 compareFn(a, b) 回傳負數。

  • 寫成算式就是 b.count - a.count。

實作:

export function sortCombinations(combinationsMap) {
  const result = [];

  combinationsMap.forEach((value, key) => {
    result.push({ combo: key, count: value });
  });

  result.sort((combination1, combination2) =>
    combination2.count - combination1.count
  );

  return result;
}

TDD 測試可以檢查:

  1. 空 Map → 回傳空陣列。

  2. 單一 entry → 陣列長度 1,內容正確。

  3. 多個 entry、count 都不同 → 按 count 降序排列。


main:組合所有函式,輸出結果

最後用一個 main() 把整個流程串起來,當作 CLI 入口(例如用 Node.js 執行):

export function main() {
  const dancerCount = 50;

  const dancers = generateDancers(dancerCount);
  const combinationsMap = countCombinations(dancers);
  const sortedCombinations = sortCombinations(combinationsMap);
  const colorTotals = countColorTotals(dancers);

  console.log('Color combinations (sorted by count):');
  sortedCombinations.forEach((entry) => {
    console.log(`${entry.combo}: ${entry.count} people`);
  });

  console.log('\nColor totals:');
  let total = 0;
  Object.entries(colorTotals).forEach(([color, count]) => {
    console.log(`${color}: ${count} tokens`);
    total += count;
  });

  console.log(`Total: ${total} tokens`);

  if (total !== dancerCount * 3) {
    console.error('Data inconsistency detected!');
  }
}

這個函式本身通常不寫單元測試,而是用手動執行檢查整體行為。


In-Place 演算法設計注意事項

在這個 Kata 裡,有幾個地方會碰到「原地(in-place)操作」 : geeksforgeeks

  • shuffle(array):直接修改傳入的陣列。

  • Array.prototype.sort():也是 in-place,會修改原陣列 。 cs.unb

設計時可以注意:

  1. 命名要誠實
    例如 shuffle 或 shuffleInPlace,清楚表達它會修改原陣列。

  2. 內部變數可以 in-place
    像 generateDancers 裡的 pool 是函式內部變數,外部看不到原始值,對它 in-place 洗牌是安全的。

  3. 對輸入資料要小心
    像 countCombinations 中不能直接 sort 呼叫方傳入的 dancer,要用 dancer.slice().sort() 先複製再操作 。 developer.mozilla


小結:這個 Kata 練到的 JS 核心技能

這個 Color Raffle Kata,其實是一個蠻好的 JS 資料結構與語言機制綜合練習:

  • 用 Array 存 list,用 Object 當固定 key 的計數器,用 Map 當動態 key 的 map,用 Set 處理唯一值集合 。 developer.mozilla

  • 用 Map.prototype.forEach、entries() + 展開運算子、Object.entries / Object.values 在資料結構之間切換 。 alexop

  • 用 Array.prototype.sort 的比較函式做降序排序與多條件排序 。 developer.mozilla

  • 用 Fisher-Yates 洗牌做公平隨機排列,而不是 sort(() => Math.random() - 0.5) 這類有偏差的寫法 。 ronluo.coderbridge

  • 意識到哪些操作是 in-place,避免不小心修改呼叫方傳入的資料 。 en.wikipedia

  • 用 TDD 把功能拆小:先設計輸入輸出,再實作最小邏輯,最後才組合成 CLI。

這樣整理完之後,下次要重用這個模式(例如票券組合統計、產品選配組合統計)時,只要換掉顏色和情境,整套邏輯幾乎可以原封帶走。