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

By [雞蛋糕的前端修煉屋](https://paragraph.com/@gcake) · 2026-01-16

---

這篇筆記整理的是：  
如何用 JavaScript 原生資料結構（Array / Object / Map / Set）搭配 `Array.prototype.sort`、Fisher-Yates 洗牌，實作一個小型 CLI「顏色抽獎」模擬程式 。 [developer.mozilla](https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Global_Objects/Array/sort)

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

* * *

題目情境：Color Raffle Kata
----------------------

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

*   有最多 50 個參加者，每人身上有 3 個彩色標籤。
    
*   標籤有 5 種顏色，每種顏色一開始各準備 30 個（總共 150 個標籤）。
    
*   遊戲過程中玩家互相交換標籤，最後每個人的身上都有 3 個顏色（可以重複）。
    
*   主辦單位進行抽獎：
    
    *   統計每種「顏色組合」出現幾個人（例如「紅-藍-綠」有 5 人）。
        
    *   顯示所有組合及人數，並依人數排序。
        
    *   再統計一次五種顏色目前各自出現幾次，確認總數仍是 150 個。
        

這個 Kata 的重點不是「模擬交換過程」，而是練習幾個關鍵技能 ： [developer.mozilla](https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Guide/Data_structures)

*   用 Array / Object / Map / Set 表達不同型態的資料。
    
*   用 Map 做聚合統計（aggregation）。
    
*   用 `Array.prototype.sort` 做多條件排序 。 [cs.unb](https://www.cs.unb.ca/~bremner/teaching/cs2613/books/mdn/Reference/Global_Objects/Array/sort/)
    
*   實作 Fisher-Yates 洗牌演算法，生成隨機分布 。 [en.wikipedia](https://en.wikipedia.org/wiki/Fisher%E2%80%93Yates_shuffle)
    
*   用 TDD 把大問題拆成小函式。
    

* * *

整體架構：拆成五個函式
-----------

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

1.  `generateDancers(dancerCount)`  
    產生隨機參加者及其顏色組合（不寫測試，因為帶隨機性）。
    
2.  `countColorTotals(dancers)`  
    統計五種顏色各自出現幾次（TDD）。
    
3.  `countCombinations(dancers)`  
    統計每種顏色組合出現幾個人（TDD）。
    
4.  `sortCombinations(combinationsMap)`  
    將組合 Map 轉成排序後的陣列，方便輸出（TDD）。 [developer.mozilla](https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Global_Objects/Map)
    
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](https://en.wikipedia.org/wiki/Fisher%E2%80%93Yates_shuffle)
        
    3.  每 3 個標籤分配給一個參加者。
        

### Fisher-Yates 洗牌（shuffle）

Fisher-Yates 是一個經典的、**無偏差**（unbiased）的洗牌演算法，適合用來隨機排列有限長度陣列 。 [stackoverflow](https://stackoverflow.com/questions/2450954/how-to-randomize-shuffle-a-javascript-array)

實作（原地修改陣列）：

    /**
     * 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](https://en.wikipedia.org/wiki/Fisher%E2%80%93Yates_shuffle)
    
*   `Math.random() * (i + 1)` 產生 `[0, i+1)` 範圍的浮點數，再用 `Math.floor` 轉成 `[0, i]` 的整數 。 [tc39](https://tc39.es/ecma262/multipage/numbers-and-dates.html)
    
*   這是 in-place 演算法：不會回傳新陣列，而是直接修改傳入的陣列 。 [en.wikipedia](https://en.wikipedia.org/wiki/In-place_algorithm)
    

### 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](https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Guide/Data_structures)
    
*   查找 `counts[color]` 平均是 O(1) 。 [tc39](https://tc39.es/ecma262/2021/)
    

* * *

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 的語意更清楚：

*   `map.has(key)`、`map.get(key)`、`map.set(key, value)` 。 [developer.mozilla](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map)
    
*   `map.size` 直接給出 key 的個數 。 [developer.mozilla](https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Global_Objects/Map)
    

### 標準化 key 的關鍵（normalize）

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

*   `['red', 'blue', 'green']`
    
*   `['green', 'red', 'blue']`
    

都應該被當成同一 key。

標準化策略：

1.  複製顏色陣列（避免修改原資料）。
    
2.  排序（使用 `Array.prototype.sort()`，依字串的 Unicode/UTF-16 編碼排序）。 [developer.mozilla](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Array/sort)
    
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](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array)
    
*   用 `Map.prototype.has`、`get`、`set` 來維護計數 。 [developer.mozilla](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map)
    

* * *

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](https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Global_Objects/Array/sort)

### 步驟 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](https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Global_Objects/Array/sort)

### 步驟 2：用 Array.sort 排序

`Array.prototype.sort(compareFn)` 的比較函式規則 ： [cs.unb](https://www.cs.unb.ca/~bremner/teaching/cs2613/books/mdn/Reference/Global_Objects/Array/sort/)

*   回傳值 `< 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](https://www.geeksforgeeks.org/dsa/in-place-algorithm/)

*   `shuffle(array)`：直接修改傳入的陣列。
    
*   `Array.prototype.sort()`：也是 in-place，會修改原陣列 。 [cs.unb](https://www.cs.unb.ca/~bremner/teaching/cs2613/books/mdn/Reference/Global_Objects/Array/sort/)
    

設計時可以注意：

1.  **命名要誠實**  
    例如 `shuffle` 或 `shuffleInPlace`，清楚表達它會修改原陣列。
    
2.  **內部變數可以 in-place**  
    像 `generateDancers` 裡的 `pool` 是函式內部變數，外部看不到原始值，對它 in-place 洗牌是安全的。
    
3.  **對輸入資料要小心**  
    像 `countCombinations` 中不能直接 sort 呼叫方傳入的 `dancer`，要用 `dancer.slice().sort()` 先複製再操作 。 [developer.mozilla](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array)
    

* * *

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

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

*   用 Array 存 list，用 Object 當固定 key 的計數器，用 Map 當動態 key 的 map，用 Set 處理唯一值集合 。 [developer.mozilla](https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Global_Objects/Set)
    
*   用 `Map.prototype.forEach`、`entries()` + 展開運算子、`Object.entries` / `Object.values` 在資料結構之間切換 。 [alexop](https://alexop.dev/posts/custom-tdd-workflow-claude-code-vue/)
    
*   用 `Array.prototype.sort` 的比較函式做降序排序與多條件排序 。 [developer.mozilla](https://developer.mozilla.org/zh-TW/docs/Web/JavaScript/Reference/Global_Objects/Array/sort)
    
*   用 Fisher-Yates 洗牌做公平隨機排列，而不是 `sort(() => Math.random() - 0.5)` 這類有偏差的寫法 。 [ronluo.coderbridge](https://ronluo.coderbridge.io/2023/08/01/javascript-array-shuffle/)
    
*   意識到哪些操作是 in-place，避免不小心修改呼叫方傳入的資料 。 [en.wikipedia](https://en.wikipedia.org/wiki/In-place_algorithm)
    
*   用 TDD 把功能拆小：先設計輸入輸出，再實作最小邏輯，最後才組合成 CLI。
    

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

---

*Originally published on [雞蛋糕的前端修煉屋](https://paragraph.com/@gcake/day-39-40)*
