Java 資料結構和演算法 - 隨機
Java 資料結構和演算法 - 隨機
很多時候,需要使用隨機數做計算。比如現代加密和模擬系統,甚至搜尋和排序演算法也依賴隨機數生成器。實現一個好的隨機數生成器還是比較困難的。
隨機數生成器
隨機數怎麼生成呢?真正的隨機性是無法用計算機實現的,因為獲取任何數所依賴的演算法不可能是隨機的。通常,可以生成偽隨機數,或者數看上去是隨機的,因為它滿足了隨機數的很多屬性。
假如我們需要模擬拋硬幣。一種辦法是檢查系統時鐘。系統時鐘維護秒數,作為當前時間的一部分。如果是偶數,我們返回0;如果是奇數,我們返回1。如果我們需要一系列隨機數,這樣做效果不好。一秒時間太長,程式執行的時候,時鐘還來不及改變,生成的總是0或者總是1,這樣很難生成隨機數序列。甚至時間的記錄單位是毫秒(或者更小)的時候,生成的數字序列還是不夠隨機,因為程式呼叫的時間基本上還是相同。
我們需要的是一系列偽隨機數,和隨機數序列的屬性相同。假如想得到0-999之間的均勻分佈(其他分佈也很常用,大多數分佈源自均勻分佈)的隨機數,在指定範圍內,不同數字的出現機會是相等的。
真正的0-999的均勻分佈的序列應該有下列屬性:
- 第一個數同樣可能是0-999
- 第n個數同樣可能是0-999
- 所有生成的數的預期的平均值是499.5
這些屬性不是特別嚴謹。比如,我們根據系統時鐘生成的第一個數是1,然後的每個數都加1。生成1000個數以後,所有的屬性都滿足了。但是沒有滿足更強的屬性。
均勻分佈的隨機數序列還應該滿足:
- 兩個連續隨機數的和同樣可能是偶數或者奇數
- 隨機生成的1000個數,應該有一些是重複的(大概368個數不會出現)
我們的數不滿足這些屬性。連續數的和總是奇數,數字也沒重複。所以,上面的隨機數生成器沒有通過統計測試。
線性同餘生成器是生成均勻分佈的好的演算法。它生成的X1
Xi+1 = AXi(mod M)
改成Java就是:
x[i + 1] = A * x[i] % M
其中A和M是常量。注意,生成的數會小於M。需要給出X0來開始序列,這個初始化值就是隨機數生成器的種子。如果
X0=0,序列就不是隨機的,因為它生成的全都是0。如果小心選擇了A和M,任何滿足1 ≤ X0 < M條件的種子都是同等有效的。
如果M是素數,Xi肯定不是0。比如,如果M=11、A=7、種子X0=1,生成的數字序列是:
7, 5, 2, 3, 10, 4, 6, 9, 8, 1, 7, 5, 2, ...
生成的是一個重複序列。我們的例子,在M-1=10個數字後重復(這個長度叫週期)。
如果M是素數,A是某些值的時候,週期是M-1,這樣的隨機數生成器叫做全週期線性同餘生成器。A選其他值,得到的不是全週期。比如,
如果A=5、種子X0
5, 3, 4, 9, 1, 5, 3, 4, ...
如果我們選擇M是一個31位的素數,對很多程式來說,週期就足夠大了。31位素數M可以選231-1=2,147,483,647。對於這個素數,A一般選48,271,這樣可以得到全週期線性同餘生成器。
不幸的是,使用32位整數計算,乘法會溢位。如果堅持使用32位整數,返回就是隨機性的一部分。所以,溢位是不可接受的,因為不能保證全週期。
如果Q和R是M/A的商和餘數,可以這樣寫等式
Xi+1 = A(Xi(mod Q)) – R(Xi/Q) + Mδ(Xi)
並且以下條件成立:
- 第一部分保證不溢位
- 第二部分如果R<Q,也不溢位
- 如果前兩項之差為正,δ(Xi)是0;如果差為負,δ(Xi)是1
對於M和A的值,我們有Q = 44,488和R = 3,399。所以,可以生成隨機數。
public class Random31 {
private static final int A = 48271;
private static final int M = 2147483647;
private static final int Q = M / A;
private static final int R = M % A;
public Random31() {
this((int) (System.nanoTime() % Integer.MAX_VALUE));
}
public Random31(int initialValue) {
if (initialValue < 0) {
initialValue += M;
initialValue++;
}
state = initialValue;
if (state <= 0)
state = 1;
}
public int nextInt() {
int tmpState = A * (state % Q) - R * (state / Q);
if (tmpState >= 0)
state = tmpState;
else
state = tmpState + M;
return state;
}
public int nextInt(int low, int high) {
double partitionSize = M / (double) (high - low + 1);
return (int) (nextInt() / partitionSize) + low;
}
public long nextLong() {
return ((((long) nextInt()) - 1) << 31) + nextInt();
}
}
最後,隨機類也需要提供一個非均勻分佈的隨機數,後面會實現nextPoisson和nextNegExp。
看起來,我們在等式中新增常量可以生成更好的隨機數生成器。比如
Xi+1 = (48,271Xi + 1) mod (231-1)
好像更隨機。但是,當我們使用該等式
(48,271 ⋅ 179,424,105 + 1) mod (231-1) = 179,424,105
於是,如果種子是179,424,105,週期就是1,生成器多麼脆弱。
你可能覺得所有機器的隨機數生成器都是全週期的線性同餘生成器,那你就錯了。很多庫的生成器基於下面的函式
Xi+1 = (AXi + C) mod 2B
其中B和機器的整數位數匹配,C是奇數。這些庫,生成的Xi總是在奇數和偶數之間交替。實際上,最低k位的最好迴圈週期是2k,很多生成器的週期都比前面程式碼裡的要小。下面的程式也是這樣的形式,不過,它使用了48位線性同餘生成器,然後返回高32位,這樣避免了低位的迴圈問題。它的常量是A = 25,214,903,917, B = 48, 和C = 11。
public class Random48 {
private static final long A = 25214903917L;
private static final long B = 48;
private static final long C = 11;
private static final long M = (1L << B);
private static final long MASK = M - 1;
public Random48() {
this(System.nanoTime());
}
public Random48(long initialValue) {
state = initialValue & MASK;
}
public int nextInt() {
return next(32);
}
public int nextInt(int N) {
return (int) (Math.abs(nextLong()) % N);
}
public double nextDouble() {
return (((long) (next(26)) << 27) + next(27)) / (double) (1L << 53);
}
public long nextLong() {
return ((long) (next(32)) << 32) + next(32);
}
private int next(int bits) {
if (bits <= 0 || bits > 32)
throw new IllegalArgumentException();
state = (A * state + C) & MASK;
return (int) (state >>> (B - bits));
}
private long state;
}
因為M是2的冪,所以可以使用位操作計算(位移動計算M = 2B,替換模運算%)。這是因為,MASK=M-1的低48位全都是1,和MASK按位與操作不影響產生的48位結果。
next方法返回一個特定的數(最多32位),使用的高位更隨機。先使用前一個state計算當前的值,然後是位移動(高位使用0,避免負數)。不用引數的nextInt方法獲得323位;nextLong分兩次計算獲得64位;nextDouble分兩次計算獲得53位(代表尾數,其他11位代表指數);一個引數的nextInt使用模運算獲得一個一定範圍內的偽隨機數。。
48位的隨機數生成器(和31位生成器)適合許多應用。但是,加密或者模擬需要更大的不相關的隨機數。
非均勻隨機數
不是所有的程式都需要非均勻隨機數。比如,課程的成績一般不是均勻分佈的,而是呈高斯分佈的。均勻分佈的隨機生成器可以用來生成滿足其他分佈的生成器。
模擬的時候,一個重要的非均勻分佈是Poisson分佈,可以模擬罕見事件的發生次數。下面列表的情形滿足泊松分佈:
- 在小區域中出現一次的概率與該區域的大小成正比
- 在小區域中出現兩次的概率與該區域大小的平方成正比,通常都小到可以被忽略
- 在一個區域發生的k次事件和另一個區域發生的k次事件是不相干的(把兩個概念乘起來,就是他們同時發生的概率)
- 在一個區域內發生的平均值是可知的
如果發生的平均數是常量a,發生k次的概率是 ake-a/k!
泊松分佈通常用於單次發生概率比較低的事件。比如,考慮一下買彩票的事情,贏得累積獎金的機率是14000000:1。如果一個人買了100張,贏的機率成了140000:1,於是第一條成立了。一個人持有兩張中獎彩票的機率可以忽略不計,滿足了條件二。如果另一個人買了10張票,他的獲勝機率是1400000:1,他的獲勝和第一個人是不相關的,所以第三個條件也滿足了。假設賣了28000000張彩票,中獎機率就是2,滿足波送分佈。這樣,購買k張彩票,中獎機率就是 2ke-2/k!
要根據期望值為a的泊松分佈生成隨機無符號整數,需要使用下列策略:重複生成0-1之間的均勻分佈的隨機數,直到小於等於e-a。
public int nextPoisson(double expectedValue) {
double limit = -expectedValue;
double product = Math.log(nextDouble());
int count;
for (count = 0; product > limit; count++)
product += Math.log(nextDouble());
return count;
}
程式的邏輯是,一直加均勻分佈的隨機數的對數,直到小於或者等於-a。
另一個重要的非均勻分佈是負指數分佈,有相同的平均值和方差,用來模擬隨機事件之間的時間間隔。
public double nextNegExp(double expectedValue) {
return -expectedValue * Math.log(nextDouble());
}
還有很多常用的分佈。一般都可以由均勻分佈生成。
生成隨機排列
考慮模擬紙牌遊戲的問題。一共52張牌,我們從中抽取牌,不能重複。事實上,我們需要洗牌,然後迭代。洗牌應該是公平的,就是說,有52!中可能性。
這類問題叫隨機排列。要生成一個1、2、……、N的隨機排列,所有排列的機會是均等的。當然,排列的隨機性收到偽隨機發生器的限制。我們證明隨機排列可以線上性時間內生成,每一項使用一個隨機數。
private static final <AnyType> void swapReferences(AnyType[] a, int index1, int index2) {
AnyType tmp = a[index1];
a[index1] = a[index2];
a[index2] = tmp;
}
public static final void permute(Object[] a) {
Random r = new Random();
for (int j = 1; j < a.length; j++)
swapReferences(a, j, r.nextInt(0, j));
}
上面的程式碼,使用迴圈做隨機洗牌。很明顯,permute生成了隨機序列。但是,所有的排列都是這樣的嗎?答案是既是也不是。從演算法上看,是正確的。第一次呼叫,產生0或者1,所以有兩種結果。第二次呼叫,產生0、1或者2,有三種結果。第N-1次呼叫,有N種結果。所以是正確的。但是,隨機數生成器只有231-2個初始狀態,所以只能有231-2種排列。