# Day 39-40:用 Map、Array.sort 和 Fisher-Yates 洗牌,拆解一個「顏色抽獎」Kata **Published by:** [雞蛋糕的前端修煉屋](https://paragraph.com/@gcake/) **Published on:** 2026-01-16 **URL:** https://paragraph.com/@gcake/day-39-40 ## Content 這篇筆記整理的是: 如何用 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 把大問題拆成小函式。整體架構:拆成五個函式為了讓程式容易測試與維護,可以先用自然語言拆出五個函式:generateDancers(dancerCount) 產生隨機參加者及其顏色組合(不寫測試,因為帶隨機性)。countColorTotals(dancers) 統計五種顏色各自出現幾次(TDD)。countCombinations(dancers) 統計每種顏色組合出現幾個人(TDD)。sortCombinations(combinationsMap) 將組合 Map 轉成排序後的陣列,方便輸出(TDD)。 developer.mozillamain() 串起整個流程,作為 CLI 入口(不寫測試)。檔案結構可以這樣規劃:color-raffle-kata/ ├── core.js // 核心邏輯 ├── core.test.js // TDD 測試 ├── main.js // CLI 入口 └── package.json generateDancers:建顏色池+洗牌+分配目標輸入:dancerCount: number,例如 50。輸出:Array>,每個元素代表一位參加者的三個顏色,例如:[ ['red', 'blue', 'green'], ['red', 'red', 'yellow'], // ... ] 顏色池與分配規則顏色種類:['red', 'blue', 'green', 'yellow', 'purple']。每種顏色放入 30 個標籤。總標籤數:dancerCount * 3(預設 50 人 → 150 個)。流程:建立顏色池 pool(150 個元素)。使用 Fisher-Yates 演算法洗牌 pool 。 en.wikipedia每 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.wikipediaMath.random() * (i + 1) 產生 [0, i+1) 範圍的浮點數,再用 Math.floor 轉成 [0, i] 的整數 。 tc39這是 in-place 演算法:不會回傳新陣列,而是直接修改傳入的陣列 。 en.wikipediagenerateDancers 設計草稿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>。輸出:一個物件,紀錄每個顏色出現次數:{ red: number, blue: number, green: number, yellow: number, purple: number, } TDD 測試案例(語意)空陣列:輸入:[]輸出:所有顏色 0。單一參加者、三個不同顏色:輸入:[['red', 'blue', 'green']]輸出:red/blue/green 各 1,yellow/purple 為 0。多個參加者顏色重複:輸入:[ ['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) 。 tc39countCombinations:用 Map 統計顏色「組合」目標統計每種顏色組合有幾個人,順序不同但組合相同要算在一起。輸入:dancers: Array>const dancers = [ ['red', 'blue', 'green'], ['green', 'red', 'blue'], ['red', 'red', 'red'], ]; 輸出:MapMap { '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.mozillamap.size 直接給出 key 的個數 。 developer.mozilla標準化 key 的關鍵(normalize)同一種組合即使順序不同:['red', 'blue', 'green']['green', 'red', 'blue']都應該被當成同一 key。 標準化策略:複製顏色陣列(避免修改原資料)。排序(使用 Array.prototype.sort(),依字串的 Unicode/UTF-16 編碼排序)。 developer.mozilla用 '-' join 成字串當作 key。TDD 測試案例(語意)空陣列 → 回傳空 Map。單一參加者 → Map.size === 1,該 key 的 count 為 1。兩個參加者顏色順序不同但組合相同 → Map.size === 1,count 為 2。多個參加者、組合都不同 → 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.mozillasortCombinations:Map → 陣列 + 排序目標輸入:Map。輸出:排好序的陣列,每個元素形如 { 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 測試可以檢查:空 Map → 回傳空陣列。單一 entry → 陣列長度 1,內容正確。多個 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)操作」 : geeksforgeeksshuffle(array):直接修改傳入的陣列。Array.prototype.sort():也是 in-place,會修改原陣列 。 cs.unb設計時可以注意:命名要誠實 例如 shuffle 或 shuffleInPlace,清楚表達它會修改原陣列。內部變數可以 in-place 像 generateDancers 裡的 pool 是函式內部變數,外部看不到原始值,對它 in-place 洗牌是安全的。對輸入資料要小心 像 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。這樣整理完之後,下次要重用這個模式(例如票券組合統計、產品選配組合統計)時,只要換掉顏色和情境,整套邏輯幾乎可以原封帶走。 ## Publication Information - [雞蛋糕的前端修煉屋](https://paragraph.com/@gcake/): Publication homepage - [All Posts](https://paragraph.com/@gcake/): More posts from this publication - [RSS Feed](https://api.paragraph.com/blogs/rss/@gcake): Subscribe to updates