資料整理: jserv
〈解讀計算機編碼〉提到「計算機以模算數 (modular arithmetic) 進行加法和乘法運算…由於電腦的組成是離散系統,可儲存和操作的位元數量有限,因此能夠表達的數值也會有限制」,而模算數在若干應用場景中,可能會導致非預期的偏差,本文藉此出發,談亂數分布的議題。
rand 是個偽隨機數生成器 (PRNG),會在 0 到 RAND_MAX
(定義於 stdlib.h
)之間選取一個自然數。倘若你想產生一個介於 0 到 2 的隨機數,假設 RAND_MAX
是 10,那麼你可能會使用 rand() % 3
,但這無法產生機率相等的 0, 1, 2 數值。乍聽之下,似乎很不直覺,我們考慮以下表格:
rand() 的輸出值 |
rand() % 3 |
---|---|
0 | 0 |
1 | 1 |
2 | 2 |
3 | 0 |
4 | 1 |
5 | 2 |
6 | 0 |
7 | 1 |
8 | 2 |
9 | 0 |
10 | 1 |
可見 0
出現 4 次(機率 ),1
也出現 4 次(機率 ),但 2 只出現 3 次(機率 )。
這樣就產生偏差,因為較小的數字出現的機率比較高。不過,這種偏差只有在你要取模的數字接近 RAND_MAX
時才會非常明顯。例如當 RAND_MAX = 32767
且 N = 3
時,出現機率如下:
當 N
很小時,這種偏差幾乎不會明顯出現。
值得留意的是,在 Microsoft Windows 平台上,RAND_MAX
定義為 0x7FFF
,而在 GNU/Linux 一般定義為 0x7FFFFFFF
。若我們希望產生 區間內均勻分布的隨機整數,若在 Windows 平台上,以 rand()
函式產生範圍在 之間的隨機數,並統計每個整數在 區間內出現的頻率(即每個數字出現的次數除以總數),再將此頻率繪製成圖表,如下:
我們不難發現,儘管使用 rand() % 10000 + 0
可取得此區間內的隨機整數,但這種方式產生的結果實際上並非均勻分布。
為何如此?
集合 是 rand 函式產生一次隨機數的樣本空間,記為 。於是集合 則是使用 rand() % 10000 + 0
後所產生的隨機數樣本空間,記為 。
由此可建立一個從 到 的映射 ,對任意 而言,都有 。
但這個映射 並非從集合 到 的一對一映射,而是存在如下情況:
例如:
但並非所有情況都存在上述四對一的映射,例如對於 而言,僅存在:
這表示,對於不同的 和 而言, 所對應的取值個數會多於 。
由於
因此有
也因此,若只能產生 範圍內隨機數的函式,使用 rand() % 10000 + 0
產生的隨機數必定不是均勻分布。
你或許想問:限制範圍在 不就可解決問題嗎?
若樣本空間 的元素數量無法整除原始均勻分布樣本空間 的元素數量,則 的分布一定不會均勻。
舉例來說,當你想產生範圍在 的隨機數,若滿足以下條件:
此處 表示無法整除
則使用以下方法產生的隨機數必定不是均勻分布:
於是,只要使用模運算把一組隨機輸出壓縮到一個較小集合時,若該集合大小無法整除原集合大小,就會導致機率分佈不均。例如:
% 6
,也就是 110 6 % 6 = 0,111 7 % 6 = 1,也就是說,0 和 1 出現機率高。何時 rand() % n
會產生均勻分佈?當 RAND_MAX % n == n - 1
時。例如 RAND_MAX = 8
、n = 3
,此時可保證模除結果(0
, 1
, 2
)機率相等。
rand()
直到產生落在範圍內的數值:缺點是,若 n
很小,成功的機率低,可能要重試很多次。
n
的最大範圍,再限制 rand()
的回傳值落在此範圍內:這能確保每個結果機率相等,效率較好。
arc4random
使用 arc4random 是處理模運算偏差的標準解法,例如 OpenBSD 提供類似以下實作:
這樣保證結果均勻分佈,且幾乎不需要多次重試。
延伸閱讀:
rand()
潛藏的問題rand 和 srand
的問題在於:
rand
所用的亂數產生器演算法srand
來初始化該演算法,使隨機序列可重現以上二點限制實作層級在改進 rand
演算法上的自由度,亦即無法輕易改用密碼學等級的隨機數生成器(RNG),或其他更優秀的 PRNG。相對來說,JavaScript 的 Math.random
與 FreeBSD 的 arc4random
沒有這個問題,因為它們不允許應用程式提供種子以達成「可重現的隨機性」。
正因如此,V8 JavaScript 引擎將 Math.random
的實作換成 xorshift128+
的變體,同時仍維持向下相容性。
不僅如此,尚有以下幾點問題:
rand
與 srand
的演算法及其種子初始化方式皆未被 C 標準明確定義,亦即就算用相同的亂數種子,也無法保證在不同實作、標準庫版本或作業系統間得到相同的隨機序列。rand
呼叫前先呼叫 srand
,則 rand
的行為類似於預設呼叫了 srand(1)
。換言之,rand
只能被實作為 PRNG,而非真正的 RNG,且該 PRNG 的演算法在相同實作中,無論有無呼叫 srand
都必須一致,因此無法達成真隨機性。srand
所接受的種子值型別為 unsigned
,其空間通常僅為 16 或 32 位元(即使在採用 64 位元架構的 C 實作中,unsigned
也不一定是 64 位元,例如在 LP64 中,unsigned
僅用 32 位元)。這意味著透過 srand
所能選擇的隨機序列最多只有 種(其中 n 為 unsigned
的位元數),即使底層的 rand
演算法本身能產生更多種序列(例如 C++ 的 mt19937
可提供 種)。另一缺陷是,C 語言標準中,未規定 rand
所產生的數值需符合特定的分佈,包括「均勻分佈」甚至是「近似均勻分佈」。這點與 C++ 的 std::uniform_int_distribution 和 std::uniform_real_distribution 形成鮮明對比,C++ 明確指定具體的偽隨機數產生演算法,例如 std::linear_congruential_engine 和 mt19937。
這些限制使得 rand
和 srand
難以用於嚴格產生隨機數場景。
假設存在均勻隨機位元串流 (bitstream) 的資料來源,我們希望能用它來產生介於 0 到 n (含) 之間的隨機整數,並用最少的位元數,也就是說,不超過 個位元數,例如當 時,期望的演算法最多只用 3 個位元。該如何達成?
以下討論在平均位元使用數上,開發最佳 (optimal) 的隨機整數產生器,其前提是我們擁有一個「真正」的隨機位元產生器,能夠產生獨立、無偏差的隨機位元。
在 1976 年,Donald Knuth 和姚期智在〈The complexity of nonuniform random number generation〉證明:
任何透過隨機位元產生整數(依指定機率分佈)的演算法,都可表述為一棵二元樹:
他們進一步指出:任何最優的二元樹演算法,用來產生範圍 的整數,其平均所需位元數會介於 和 之間。 亦即,即使是最佳的演算法,在平均意義上仍可能會「浪費」一些位元。
Donald Knuth 和姚期智也指出:任何最優且無偏差的整數產生器,在最壞情況下都可能執行無限次,因為當 的二進位展開為無限不循環小數(例如 n 不是 2 的冪時),則對應的二元樹:
因此,即便平均只用很少的隨機位元,但仍可能在最壞情況下執行無限次。
而當 n
是 2 的冪時,對應的樹剛好就是滿二元樹,不需要拒絕節點,也不會浪費任何位元。例如:
這個最優演算法由 Jérémie Lumbroso 在 2013 年發表於〈Optimal Discrete Uniform Generation from Coin Flips, and Applications〉,它用「重試事件」來保證無偏差。 以下是用 C 語言改寫的 Fast Dice Roller 程式碼範例:
該演算法每次擴展一個位元,然後檢查產生的值是否在可接受的範圍內,若不在,就進行捨棄並重新嘗試。雖然理論上可能無限迴圈,但平均效率接近最小可用位元數,亦即 。
上述 Fast Dice Roller 屬於最佳演算法,其平均使用位元數不會低於 ,且在最壞情況下最多也只會超出 2 個位元。
若要進一步降低位元浪費,該考慮:
延伸閱讀:
至此,我們知道,不存在一個同時能在固定時間內且又無偏差且最省位元數的解法 —— 這是產生隨機數的權衡。上述演算法對於密碼學或機率模擬等需要高品質隨機數的應用尤為重要。