這篇筆記整理的是:
如何用 JavaScript 原生資料結構(Array / Object / Map / Set)搭配 Array.prototype.sort、Fisher-Yates 洗牌,實作一個小型 CLI「顏色抽獎」模擬程式 。 developer.mozilla
過程中會刻意用 TDD 思維,把邏輯拆成小函式,一步步構出整體流程。
想像一個開源的 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
輸入:
dancerCount: number,例如 50。輸出:
Array<Array<string>>,每個元素代表一位參加者的三個顏色,例如:[ ['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 是一個經典的、無偏差(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.wikipedia
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;
}
這個函式因為帶有隨機性,可以用手動印出、簡單檢查總數與分布,而不一定用單元測試檢查具體值。
輸入:
dancers: Array<Array<string>>。輸出:一個物件,紀錄每個顏色出現次數:
{
red: number,
blue: number,
green: number,
yellow: number,
purple: number,
}
空陣列:
輸入:
[]輸出:所有顏色 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 }
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
統計每種顏色組合有幾個人,順序不同但組合相同要算在一起。
輸入:
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, }
這裡的 key 是「顏色陣列」轉成的字串,例如 'blue-green-red'。
可以用 Object 當作 map,但 Map API 的語意更清楚:
map.has(key)、map.get(key)、map.set(key, value)。 developer.mozillamap.size直接給出 key 的個數 。 developer.mozilla
同一種組合即使順序不同:
['red', 'blue', 'green']['green', 'red', 'blue']
都應該被當成同一 key。
標準化策略:
複製顏色陣列(避免修改原資料)。
排序(使用
Array.prototype.sort(),依字串的 Unicode/UTF-16 編碼排序)。 developer.mozilla用
'-'join 成字串當作 key。
空陣列 → 回傳空
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.mozilla
輸入:
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
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
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() 把整個流程串起來,當作 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!');
}
}
這個函式本身通常不寫單元測試,而是用手動執行檢查整體行為。
在這個 Kata 裡,有幾個地方會碰到「原地(in-place)操作」 : geeksforgeeks
shuffle(array):直接修改傳入的陣列。Array.prototype.sort():也是 in-place,會修改原陣列 。 cs.unb
設計時可以注意:
命名要誠實
例如shuffle或shuffleInPlace,清楚表達它會修改原陣列。內部變數可以 in-place
像generateDancers裡的pool是函式內部變數,外部看不到原始值,對它 in-place 洗牌是安全的。對輸入資料要小心
像countCombinations中不能直接 sort 呼叫方傳入的dancer,要用dancer.slice().sort()先複製再操作 。 developer.mozilla
這個 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。
這樣整理完之後,下次要重用這個模式(例如票券組合統計、產品選配組合統計)時,只要換掉顏色和情境,整套邏輯幾乎可以原封帶走。
