那不是真正的洗牌
JavaScript 中最常見的隨機化錯誤:
const shuffled = items.sort(() => Math.random() - 0.5); // 錯誤
它看起來沒問題,而且隨處可見。但這不是洗牌。排序比較函數期望的是一致的,而這個不是,因此引擎的排序會產生偏斜的排列。
針對四個測驗選項的實測結果:
| 位置 | 正確答案落在該位置的機率 |
|---|---|
| 0 | 28.3% |
| 1 | 28.2% |
| 2 | 24.7% |
| 3 | 18.8% |
均勻分配應該是每個位置 25%。實際上,總是猜第一個選項比隨機猜測還要好——一個注意到這個問題的玩家就會佔有優勢,而這個測驗就在不知不覺中變得不再公平。
修正方法
Fisher–Yates 演算法,寫成一個輔助函式,只做一次洗牌:
function shuffle(a) {
const out = [...a];
for (let i = out.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[out[i], out[j]] = [out[j], out[i]];
}
return out;
}
然後替換所有出問題的地方。這個錯誤通常不只出現一次——某個程式碼庫甚至出現在十一個地方:洗牌測驗選項、挑選哪些題目出現、選擇提供哪些道具、排序菜單選項。每個地方都稍微偏斜了結果。
不要用模式匹配進行全面取代。 (Math.random() - 0.5) 也可能合理出現在物理模擬的雜訊和抖動中:
velocity += (Math.random() - 0.5) * 0.012; // 這個請留著
檢查每個匹配項,確定它真的是排序比較函數。
測試分佈
不要用肉眼判斷隨機性。用計數的:
const pos = [0,0,0,0], N = 200000;
for (let i = 0; i < N; i++) {
const order = shuffle(['a','b','c','d'].map((t,i) => [t,i]));
pos[order.findIndex(o => o[1] === 0)]++;
}
pos.forEach((c,i) => console.log(i, (c/N*100).toFixed(1) + '%'));
若各位置之間的分佈超過約 3 個百分點,表示有偏差。
使用一個正規的亂數產生器來做測試。 一個自製的線性同餘產生器(LCG)會產生 1-2 個百分點的「誤差」,看起來像真的錯誤,但其實是測試本身的問題。Math.random() 均勻度足夠用來檢查分佈——把自訂產生器留到你需要可重現的亂數種子時再用,並且要另外驗證它。
檢查你顯示給人們的機率
如果你在任何地方顯示機率——百分比、進度條、成功機率——請確認它們與程式實際擲骰的結果一致。用數十萬次模擬與實際擲骰比對:
cred 0 顯示 5.0%/40.0%/55.0% 實際 5.0%/40.0%/55.0%
cred 100 顯示 40.0%/40.0%/20.0% 實際 40.0%/40.1%/20.0%
最大誤差低於 0.5 個百分點,表示顯示沒有騙人。一個顯示的機率卻與實際結果不符,比什麼都不顯示更糟糕,因為人們會根據那個機率做決策。
順便檢查一下
確認一個聲稱的目標是否真的可以達到。一個計數器顯示「3 of 5」告訴玩家目標是 5;但如果 5 只有 0.17% 的機率出現,那麼這個介面就在承諾一個實際上幾乎永遠不會發生的事。為你顯示的每一個里程碑計算真實的機率。