程式抽籤被質疑黑箱該如何處理
· 更新於 · tech
📑 目錄
前言
前陣子幫目前社區實作了一個簡單的車位抽籤頁面,後來其他管理委員們認為可能會被住戶質疑這程式不可信任,可能有黑箱疑慮,藉此研究了一下如何證明這個抽籤流程沒有黑箱。
黑箱定義
- 複雜的說是抽籤的過程可受某方控制達到特定人士的定義
- 簡單說就是有利的我讓我自己必中籤,不利的我不會被抽中
黑箱條件
要達到黑箱必定滿足以下條件之一
- 抽籤過程不明確
- 抽籤結果可受控制
要證明流程沒有黑箱疑慮必須保證上面兩點不滿足
- 程式碼公開 -> 演算流程明確
- 抽籤流程可重現 -> 結果可被驗證
因此程式設計就必須以「可重現的隨機算法」為核心。
可重現的隨機算法
一般常見的 Math.random() 雖然可以產生隨機數字,但無法重現,也無法驗證是否被操控。因此我們使用 帶 seed 的隨機演算法(例如線性同餘生成器 LCG 搭配 Fisher–Yates 洗牌法)。
seed:隨機數的起始值,只要相同 seed 就會得到相同結果。
演算法公開:任何人都能檢查程式碼,確認不會有偏袒。
結果可驗證:抽籤後公布 seed,任何人都能重算並驗證結果。
LCG(線性同餘法, Linear Congruential Generator)
原理
LCG 是一種 偽隨機數產生器,它的數列由下面的公式決定:
其中:
- = 產生的隨機數
- = 常數參數
- seed = 初始值 只要 seed 和參數固定,每次產生的隨機數列就會 完全一樣。
特點
- 優點:簡單、快速、可重現。
- 缺點:隨機性有限(數列週期短),如果參數選不好,可能會有規律性。
- 用途:適合需要「透明且可驗證」的場合(例如抽籤),但不適合安全要求高的場合(像密碼學)。
範例程式碼(JavaScript)
function lcg(seed) {
let s = seed;
return function() {
s = (s * 9301 + 49297) % 233280; // a=9301, c=49297, m=233280
return s / 233280; // 轉成 0~1 之間
}
}
// 使用範例
let randomFunc = lcg(12345);
console.log(randomFunc());
console.log(randomFunc());
console.log(randomFunc());
洗牌法(Fisher–Yates Shuffle)
原理
Fisher–Yates 洗牌演算法能夠把一個陣列「均勻隨機打亂」,保證每個排列出現的機率一樣。
Steps:
- 從最後一個元素開始,隨機挑一個位置,兩者交換。
- 再往前一個元素,重複步驟,直到所有元素都被處理。
特點
- 優點:隨機性好,每個排列出現機率一致。
- 缺點:需要依賴一個隨機數生成器(例如 LCG)。
用途:適合抽籤、亂數排序、遊戲洗牌等場景。
範例程式碼(JavaScript)
function shuffleArray(array, seed) {
const result = array.slice(); // 複製陣列
// 使用 LCG 當作隨機數生成器
let randomFunc = lcg(seed);
for (let i = result.length - 1; i > 0; i--) {
const j = Math.floor(randomFunc() * (i + 1));
[result[i], result[j]] = [result[j], result[i]];
}
return result;
}
// 使用範例
console.log(shuffleArray([1, 2, 3, 4, 5], 12345));
從 LCG 升級到 Mulberry32
上面的 LCG 雖然簡單、可重現,但實際把它用在車位抽籤時,我們發現幾個弱點:
- 假獨立(最關鍵):我們需要分別洗牌「一般戶、優先戶、一般車位、優先車位、中籤者」等多份清單。原本的做法是用
seed + 小偏移量(如seed + 46、seed + 87)當作各自的種子,但 LCG 有個已知性質:相鄰 seed 產生的序列高度相關,所以這幾份「看似獨立」的洗牌其實統計上彼此牽連。 - 首筆可預測:實際的抽籤程式在 seed 欄留空時會用
Date.now()當種子,而 LCG 的輸出是種子的純函式,知道抽籤的大概時間就能預測首批結果。 - 低位元隨機性差:若模數取 2 的次方(我們實際程式裡用的版本就是 mod 2³²),最低的幾個位元週期極短(第 k 個低位元週期只有 2^k),規律性明顯。
因此我們把亂數引擎換成 Mulberry32(統計品質明顯較好、且一樣可重現),並改用「把 seed 和一個有意義的標籤一起雜湊」來分流,徹底解掉假獨立的問題:
// 字串雜湊:把 "seed:label" 攪成一個 32-bit 種子
function xmur3(str) {
let h = 1779033703 ^ str.length;
for (let i = 0; i < str.length; i++) {
h = Math.imul(h ^ str.charCodeAt(i), 3432918353);
h = (h << 13) | (h >>> 19);
}
return (h ^= h >>> 16) >>> 0;
}
// Mulberry32:統計品質良好的有種子 PRNG,回傳 0~1 之間的浮點
function mulberry32(a) {
return function () {
a |= 0; a = (a + 0x6D2B79F5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 用 seed + 標籤產生「一條獨立的亂數流」
function makeRng(seed, label) {
return mulberry32(xmur3(`${seed}:${label}`));
}
// 新版洗牌:改吃一個 rng 函數(取代前面吃 seed 整數的版本),不同清單用不同標籤就互相獨立
function shuffleArray(array, rng) {
const result = array.slice();
for (let i = result.length - 1; i > 0; i--) {
const j = Math.floor(rng() * (i + 1));
[result[i], result[j]] = [result[j], result[i]];
}
return result;
}
// 使用範例:每份清單一條獨立的流
shuffleArray(households, makeRng(seed, 'households'));
shuffleArray(parkings, makeRng(seed, 'parkings'));
重點是:換引擎後「可重現」的性質完全不變 —— 只要 seed 和程式碼相同,任何人重跑都會得到一模一樣的結果,黑箱論證依然成立;我們只是讓隨機品質更好、各份洗牌真正獨立。
seed 的來源
為了避免「主辦方多次嘗試直到抽到想要的結果」,seed 的選取必須透明且不可操控。常見做法有:
- 抽籤時間戳記 → 按下抽籤按鈕的瞬間毫秒值
- 事前公布的日期時間 → 例如「活動當天 20:15 的時間字串」
- 外部公信數據 → 例如區塊鏈最新區塊 hash、彩券開獎號碼
- 多方共同提供數字 → 住戶代表與管理委員各自提供一個數字組合
這些方式都能避免單方控制抽籤結果。
名單順序也是輸入的一部分
實際跑過一場之後,才發現一個容易被忽略的細節:洗牌是照名單貼上去的順序做的,所以同一份名單換個順序貼,結果就不一樣。也就是說「seed + 程式碼」還不足以重現結果,名單的順序也是輸入。
一開始我以為這是個要修掉的脆弱點——在程式裡先把名單排序,讓「名單內容相同就結果相同」。後來想通不該修:我們的名單順序本來就是紙本登記順序,那是抽籤前就寫好、有實體憑證、誰都改不了的東西。改成程式內部排序,反而是把一個有紙本佐證的輸入,換成一個看不見的規則,而且舊 seed 的歷史結果會全部對不上。
所以順序留著,但要把它當成正式輸入來保存。匯出的 Excel 裡每份原始名單都多一欄「登記序號」:
| 登記序號 | 一般戶名 |
|---|---|
| 1 | 51-11F |
| 2 | 53-07F |
| 3 | 61-09F |
看起來很囉嗦,但少了它就只剩「列的位置」在保存順序——委員打開檔案、順手點一下欄位排序、存檔,順序就沒了,而且之後誰都重跑不出同一份結果,還看不出哪裡壞掉。
沒中籤的人怎麼辦:候補順位
第一版只輸出「未中籤名單」,而且是照門牌排序的。管委會很自然會拿那張表當遞補順序——那就等於小門牌永遠是第一順位,正好是抽籤想避免的事。
但公平的候補順位其實早就抽出來了,只是被丟掉:抽籤時是把全體住戶洗成一個完整排列,再取前 N 名(N = 車位數)。後段不是廢料,那就是天然的候補順位。
這樣做有三個好處:
- 公平:Fisher–Yates 是均勻排列,每一戶排到任一候補名次的機率相同。我跑了 60000 次模擬,每戶排到候補第一順位的機率落在 3.43% ~ 3.69%(期望 3.57%),卡方檢定看不出偏差。
- 不必再抽一次:順位在直播那一刻就跟著結果一起定了。這點比想像中重要——如果遞補要另外找時間再抽,「按到滿意為止」那個風險就整個回來了,而且那次通常沒有直播。
- 一樣可驗證:順位由同一個 seed 決定,住戶自己重跑就能對。
至於空出來的車位怎麼給,我們的規則是候補第 1 順位遞補,不分優先車位或一般車位。因為在我們的設計裡,「優先」從來不影響中不中籤——優先戶(例如孕婦)和一般戶的中籤機率完全一樣,優先只決定中籤之後停哪裡(比較好停的位子)。如果讓優先戶在遞補時插隊,等於偷偷把「優先」變成中籤加權,那就違反當初講好的規則了。
讓住戶真的驗得動
「公布 seed 就能重跑驗證」這句話,寫在公告上很好看,但我後來意識到它一直只是理論:住戶要驗,得把四份名單照原順序手動貼回頁面,少一行、順序錯一點,結果就完全不同。實際上不會有人做——可稽核性等於沒兌現。
所以補了兩條路,把「同一場抽籤」直接交到住戶手上:
驗證連結——把 seed 和四份名單編碼進網址的 hash,點開就用連結裡的資料重跑,順序由連結帶著走。放 hash 而不是 query,名單不會送到伺服器。連結壞掉就顯示錯誤並退回範例,不會拿半套資料算出一份看起來像真的結果——那比沒有驗證更糟。
匯入 Excel 對帳——把公告的 .xlsx 丟回頁面,依登記序號還原名單與 seed 重跑,再和檔案裡的「分配結果」逐筆比對,直接給一句話:
✅ 與檔案一致(9 筆)
seed 1789360267486(2026/09/14 12:31:07.486)重跑的分配結果,
與檔案裡的「分配結果」逐筆相同。
❌ 與檔案不一致(比對 9 筆)
車位不同:51-08F(檔案 天上一號 / 重跑 8);
常見原因是名單被改過或順序被排序過 —— 順序也是抽籤的輸入。
住戶不必自己比對 22 行文字,也不必懂 seed 是什麼,拖一個檔案進去就有答案。可驗證這件事,要做到「懶得驗的人也驗得動」才算數。
另外把抽籤邏輯抽成純函式之後,補了一組黃金檔回歸測試:鎖住幾組固定 seed 與名單的完整輸出。這個工具對住戶的承諾是「公布 seed 後結果永遠重現得出來」,所以結果變了本身就是 bug,即使新規則看起來更合理。測試紅燈時要問的不是「怎麼讓測試過」,而是「舊 seed 的歷史結果還能重現嗎?住戶手上的公告會不會對不上?」
抽籤流程
- 開直播錄影
- 展示程式碼以及UI
- 展示所有的輸入(含名單順序,那也是輸入的一部分)
- 抽籤只能按一次——順帶把鏡頭帶到 seed 旁邊的「對應時間」,那行必須等於當下時間。螢幕上「自動產生的 seed」和「事先算好再打進欄位的 seed」長得一模一樣,能分辨的只有那行時間
- 將程式碼、輸入資料、隨機seed、抽籤結果保存
- 公開所有資料以供檢視,並附上驗證連結與候補順位
結論
只用程式很難直接達到無黑箱的可能性,實際上還是要靠流程設計避免主辦方的可控性
- 靠直播避免主辦方抽很多次選想要的結果
- 抽籤結果可以透過程式計算重現(保存輸入以及seed),透過驗算避免人為調整結果
- seed為毫秒等級,避免人為操控在特定的seed達到特定的結果
- 公開程式碼並直播抽籤維持公開透明原則
- 遞補順位跟著抽籤一起定案,不另外找時間重抽——否則「按到滿意為止」的風險會在沒有直播的場合整個回來
- 驗證要做到住戶真的按得下去(一個連結、一個拖檔案),不然「可重現」只是寫在公告上的一句話
實際跑完一場之後我的體會是:這件事的難點從來不在演算法。Mulberry32 加 Fisher–Yates 一個下午就寫完了,剩下的時間全花在「怎麼讓一個不懂程式的住戶相信它」——名單順序要留憑證、遞補要事前定案、驗證要一鍵完成。程式碼負責讓結果可重現,流程設計負責讓可重現這件事被看見。 5. 抽籤程式:原昕吾境機車車位抽籤