喔!別誤會,這邊不是在問你的程式碼是否亂七八糟。經常地,程式需要一些隨機性,而用來產生隨機的方式之一,就是利用亂數,在程式語言本身的標準程式庫,多半會附上某個亂數API,而不夠「亂」的話,還可以找找看其他亂數程式庫,或從其他裝置取得亂數,只是,為什麼會不夠亂呢?
你用的隨機亂數是假的!
今天若要寫個撲克牌程式,為了要能公正地洗牌,你會需要亂數,像是隨機地產生兩個不大於52且不重複的正整數,然後調換兩個牌的位置,重複進行這個動作n次,以便完全地洗牌。
亂數另一個比較有趣的應用,是求圓周率。隨意在正方形範圍,產生一個點,如果夠隨機,這些點有一定的機率,會落在以正方形邊長為半徑的四分之一圓內。假設產生n個點,有c點在圓內,只要n選得夠大,那麼4*c/n就會越接近圓周率,這種像是以賭博來求某個值的方式,有個酷名字,稱為蒙地卡羅法。
只不過,你產生的隨機數真的夠隨機嗎?這可不能像賭博般猜測「夠」或「不夠」。若使用C語言,可使用rand()來產生隨機數,不過,單用rand()時,你會發現每次重新執行程式,產生的隨機數列竟然都相同?而在API文件會指出,rand()預設的亂數種子是0,可使用srand()來設定亂數種子,一個常見做法是在使用rand()前,先執行srand(time(NULL)),也就是利用時間來提供亂數種子。
每次跑程式的時間並不一定,因此能利用時間來做亂數種子。亂數種子本身似乎也夠隨機了,然而,鄉民之間總會有些傳說,在某遊戲中,哪個時段去打怪,最容易掉寶。基本上,遊戲中掉不掉寶,方式是產生亂數、看看是否落在某個範圍中,然而,若某個時段去玩,產生的亂數容易落在某個範圍,這就暗示了本應不該有規律性的亂數,卻存在著某種規律。
基本上,兩個亂數之間應該有沒有任何的關聯,但實際上,只要設定相同的亂數種子,接下來產生的亂數序列就會相同,許多隨機函式都是如此。例如,在Java中,若new Random(System.currentTimeMillis())產生兩個Random實例,而且,若執行時兩次產生實例的時間差,低於毫秒,此時,這兩個Random實例接下來產生的亂數序列,會是一模一樣。而在早期版本的Java,建立Random實例時,如果不指定亂數種子,在建構式中,就是採取this(System.currentTimeMillis())的方式。
線性同餘法
標準程式庫附的隨機函式,基本上,都是偽隨機性(Pseudorandomness)的亂數,也就是產生的亂數看似隨機,其實,是使用一個確定性的演算計算而來,簡單講就是一個數學函數,但以相同資料餵給函數,會產生相同結果,而亂數需要的是不同的結果,所以,有一個想法就是把先前產生的結果,再拿來餵給函式,這也許會讓你想到費式數列fn = fn-1 + fn-2,下個費式數就是從前兩個費式數的加總而來。
因為費式數會不斷增大,若只打算取得M以內的數字,可以取費式數除以M的餘數,因此,從2這個費式數開始取10的餘數,結果就會是2、3、5、8、3、1、4、5、9……乍看是有點像亂數了;如果從13這個費式數開始,那麼數列就會是3、1、4、5、9、4、3、7……從2或從13開始,這樣的數就被稱為種子,自然地,種子相同,後面的數列也就相同了。
當然,可能會有個知道費式數的程式設計師,很快地觀察出數字的規則。因此,為了讓人不容易看出規律,也許你可以讓fn = fn-3 + fn-8之類,或許也可以讓+變成*,以增加數列的變化性,只不過為了公平,必須讓數字是均勻分布。以Java的Random實作來說,它是採取線性同餘算式(linear congruential formula):Nj+1 = (A * Nj + B) mod M,其中A、B、M是個常數。
你可以試著用這個公式,設定不同的A、B、M,以及第一個N0(也就是種子),看看呈現出來的數列會是如何。而在某些組合下,會看出數列開始重複出現了(例如,試試N0為7、B為3、M為16,並使用7作為種子),因為是亂數,因此並不希望被看出這種周期性的重複。在維基百科〈線性同餘方法〉條目中,也提到讓此方法的周期最大的條件,基本上是跟質數有關——這大致上不難想像,因為會有質數之間沒有規則性、而且越大的質數之間越稀疏等特性。
種子的挑選,也有影響。在Java 7中,若不指定種子,會使用隨機挑選的數8682522807148012(因為一些不明的歷史性),乘上181783497276652981,然後,再與System.nanoTime()作XOR運算得到。而根據註解中列出的文件來源指出,1181783497276652981會產生不錯的效益,只是很神奇地,原始碼開頭少了一個1,有人指出這應該是誤植,不過,在Java 8的原始碼,也還是如此。
隨機的程度
使用線性同餘法建立的隨機數是偽隨機數,在維基百科〈偽亂數〉條目中,指出:「優點是它的計算比較簡單,而且,只使用少數數值很難推算出計算它的算法」。因此,這用於統計學相關的機率計算,基本上是足夠的,然而,因為基於確定性的演算,實際存在著規則,所以,不能用於密碼學等有著安全需求的場合。
若想要讓程式更具有隨機性,可以收集真正的隨機事件,例如建立一個隨機池,並不斷地收集系統中物理性的隨機資訊,像是鍵盤、滑鼠、網路訊號等輸入輸出裝置、系統時間、程序ID、中斷時間……,當程式要求隨機數時,可從隨機池取得資訊,並經過計算後傳回,這樣的隨機數可符合密碼學上真隨機性的要求,也就是隨機樣本不可重現。
還有介於偽隨機數與真隨機數之間的密碼學安全偽隨機數。像是在隨機池中收集的資訊不夠時,重複使用隨機池中的資訊,因為重複使用了,隨機程度就不如上述的真隨機性,不過,理論上,這就符合維基百科〈隨機數〉條目中對於密碼學安全偽隨機數的說明:「在給定隨機樣本的一部分和隨機算法,不能有效地演算出隨機樣本的剩餘部分」。
為了追求真正的隨機,也有著向自然界的物理現象尋求的方式。例如,random.org就提供線上的隨機數產生服務,它是利用大氣中的噪音(Atmospheric Noise)來得到隨機數;而在〈Statistical Analysis〉(https://goo.gl/svJAjy)中,有個偽隨機數與random.org產生的隨機數對照圖,可以看出使用PHP基於偽隨機演算的rand()所產生的點陣圖,當中具有某種圖案規律。
寫好洗牌程式沒那麼簡單!
隨機數還有許多值得探討之處,有些礙於時間,以及我目前有限的能力,還沒能全盤摸清。會想要去探究這些,來自於最近在試著認識資料科學的過程中,經常會接觸到使用隨機數進行模擬,某天心中想到「那麼……這些隨機數又是怎麼來的?」,就這麼探討下去,才發現想寫好一個洗牌程式,也不是那麼簡單的事。
實際上,還真的有個關於洗牌的故事,發生在1999年。這在〈When Random Isn't Random Enough: Lessons from an Online Poker Exploit〉(https://goo.gl/9bL2Bg)中談到,正常來說,洗牌後可能的結果,應該有52種,然而,如果駭客們知道要與伺服器時間同步,就能將可能性降到200,000種,這樣也就能透過搜尋,快速地猜測出會來什麼牌、玩家的牌等。
現實中,你也許不會真的要寫個洗牌程式上線,不過,使用隨機函式(或者其衍生物,像是shuffle之類的函式)的機會應該不少,呼叫這些現成的函式,通常不會太難,然而也不只是隨機這類函式,還有其他一些不起眼的小函式,有機會也可以好好探索,會發現它們往往沒你想像得那麼簡單。