程式抽籤被質疑黑箱該如何處理

· 更新於 · tech

#side-project#concept

📑 目錄

前言

前陣子幫目前社區實作了一個簡單的車位抽籤頁面,後來其他管理委員們認為可能會被住戶質疑這程式不可信任,可能有黑箱疑慮,藉此研究了一下如何證明這個抽籤流程沒有黑箱。

黑箱定義

  • 複雜的說是抽籤的過程可受某方控制達到特定人士的定義
  • 簡單說就是有利的我讓我自己必中籤,不利的我不會被抽中

黑箱條件

要達到黑箱必定滿足以下條件之一

  1. 抽籤過程不明確
  2. 抽籤結果可受控制

要證明流程沒有黑箱疑慮必須保證上面兩點不滿足

  1. 程式碼公開 -> 演算流程明確
  2. 抽籤流程可重現 -> 結果可被驗證

因此程式設計就必須以「可重現的隨機算法」為核心。

可重現的隨機算法

一般常見的 Math.random() 雖然可以產生隨機數字,但無法重現,也無法驗證是否被操控。因此我們使用 帶 seed 的隨機演算法(例如線性同餘生成器 LCG 搭配 Fisher–Yates 洗牌法)。 seed:隨機數的起始值,只要相同 seed 就會得到相同結果。 演算法公開:任何人都能檢查程式碼,確認不會有偏袒。 結果可驗證:抽籤後公布 seed,任何人都能重算並驗證結果。

LCG(線性同餘法, Linear Congruential Generator)

原理

LCG 是一種 偽隨機數產生器,它的數列由下面的公式決定:

Xn+1=(a×Xn+c)mod  mX_{n+1} = (a \times X_n + c) \mod m

其中:

  • XX = 產生的隨機數
  • a,c,ma,c,m = 常數參數
  • seed = 初始值 X0X_{0} 只要 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:

  1. 從最後一個元素開始,隨機挑一個位置,兩者交換。
  2. 再往前一個元素,重複步驟,直到所有元素都被處理。

特點

  • 優點:隨機性好,每個排列出現機率一致。
  • 缺點:需要依賴一個隨機數生成器(例如 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 雖然簡單、可重現,但實際把它用在車位抽籤時,我們發現幾個弱點:

  1. 假獨立(最關鍵):我們需要分別洗牌「一般戶、優先戶、一般車位、優先車位、中籤者」等多份清單。原本的做法是用 seed + 小偏移量(如 seed + 46、seed + 87)當作各自的種子,但 LCG 有個已知性質:相鄰 seed 產生的序列高度相關,所以這幾份「看似獨立」的洗牌其實統計上彼此牽連。
  2. 首筆可預測:實際的抽籤程式在 seed 欄留空時會用 Date.now() 當種子,而 LCG 的輸出是種子的純函式,知道抽籤的大概時間就能預測首批結果。
  3. 低位元隨機性差:若模數取 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 的選取必須透明且不可操控。常見做法有:

  1. 抽籤時間戳記 → 按下抽籤按鈕的瞬間毫秒值
  2. 事前公布的日期時間 → 例如「活動當天 20:15 的時間字串」
  3. 外部公信數據 → 例如區塊鏈最新區塊 hash、彩券開獎號碼
  4. 多方共同提供數字 → 住戶代表與管理委員各自提供一個數字組合

這些方式都能避免單方控制抽籤結果。

名單順序也是輸入的一部分

實際跑過一場之後,才發現一個容易被忽略的細節:洗牌是照名單貼上去的順序做的,所以同一份名單換個順序貼,結果就不一樣。也就是說「seed + 程式碼」還不足以重現結果,名單的順序也是輸入。

一開始我以為這是個要修掉的脆弱點——在程式裡先把名單排序,讓「名單內容相同就結果相同」。後來想通不該修:我們的名單順序本來就是紙本登記順序,那是抽籤前就寫好、有實體憑證、誰都改不了的東西。改成程式內部排序,反而是把一個有紙本佐證的輸入,換成一個看不見的規則,而且舊 seed 的歷史結果會全部對不上。

所以順序留著,但要把它當成正式輸入來保存。匯出的 Excel 裡每份原始名單都多一欄「登記序號」:

登記序號一般戶名
151-11F
253-07F
361-09F

看起來很囉嗦,但少了它就只剩「列的位置」在保存順序——委員打開檔案、順手點一下欄位排序、存檔,順序就沒了,而且之後誰都重跑不出同一份結果,還看不出哪裡壞掉。

沒中籤的人怎麼辦:候補順位

第一版只輸出「未中籤名單」,而且是照門牌排序的。管委會很自然會拿那張表當遞補順序——那就等於小門牌永遠是第一順位,正好是抽籤想避免的事。

但公平的候補順位其實早就抽出來了,只是被丟掉:抽籤時是把全體住戶洗成一個完整排列,再取前 N 名(N = 車位數)。後段不是廢料,那就是天然的候補順位。

全體住戶洗成一個排列(Fisher–Yates,由 seed 決定) 前 9 名 —— 中籤 後 4 名 —— 候補 1 2 3 4 5 6 7 8 9 10 11 12 13 依序配 9 個車位 候補 1 2 3 4 同一次洗牌、同一個 seed —— 遞補不必再抽一次
13 戶搶 9 位:前 9 名中籤,第 10 名之後依序就是候補順位。

這樣做有三個好處:

  1. 公平:Fisher–Yates 是均勻排列,每一戶排到任一候補名次的機率相同。我跑了 60000 次模擬,每戶排到候補第一順位的機率落在 3.43% ~ 3.69%(期望 3.57%),卡方檢定看不出偏差。
  2. 不必再抽一次:順位在直播那一刻就跟著結果一起定了。這點比想像中重要——如果遞補要另外找時間再抽,「按到滿意為止」那個風險就整個回來了,而且那次通常沒有直播。
  3. 一樣可驗證:順位由同一個 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 的歷史結果還能重現嗎?住戶手上的公告會不會對不上?」

抽籤流程

  1. 開直播錄影
  2. 展示程式碼以及UI
  3. 展示所有的輸入(含名單順序,那也是輸入的一部分)
  4. 抽籤只能按一次——順帶把鏡頭帶到 seed 旁邊的「對應時間」,那行必須等於當下時間。螢幕上「自動產生的 seed」和「事先算好再打進欄位的 seed」長得一模一樣,能分辨的只有那行時間
  5. 將程式碼、輸入資料、隨機seed、抽籤結果保存
  6. 公開所有資料以供檢視,並附上驗證連結與候補順位

結論

只用程式很難直接達到無黑箱的可能性,實際上還是要靠流程設計避免主辦方的可控性

  1. 靠直播避免主辦方抽很多次選想要的結果
  2. 抽籤結果可以透過程式計算重現(保存輸入以及seed),透過驗算避免人為調整結果
  3. seed為毫秒等級,避免人為操控在特定的seed達到特定的結果
  4. 公開程式碼並直播抽籤維持公開透明原則
  5. 遞補順位跟著抽籤一起定案,不另外找時間重抽——否則「按到滿意為止」的風險會在沒有直播的場合整個回來
  6. 驗證要做到住戶真的按得下去(一個連結、一個拖檔案),不然「可重現」只是寫在公告上的一句話

實際跑完一場之後我的體會是:這件事的難點從來不在演算法。Mulberry32 加 Fisher–Yates 一個下午就寫完了,剩下的時間全花在「怎麼讓一個不懂程式的住戶相信它」——名單順序要留憑證、遞補要事前定案、驗證要一鍵完成。程式碼負責讓結果可重現,流程設計負責讓可重現這件事被看見。 5. 抽籤程式:原昕吾境機車車位抽籤