1. 程式人生 > >Java 資料結構和演算法 - 隨機

Java 資料結構和演算法 - 隨機

Java 資料結構和演算法 - 隨機

很多時候,需要使用隨機數做計算。比如現代加密和模擬系統,甚至搜尋和排序演算法也依賴隨機數生成器。實現一個好的隨機數生成器還是比較困難的。

隨機數生成器

隨機數怎麼生成呢?真正的隨機性是無法用計算機實現的,因為獲取任何數所依賴的演算法不可能是隨機的。通常,可以生成偽隨機數,或者數看上去是隨機的,因為它滿足了隨機數的很多屬性。
假如我們需要模擬拋硬幣。一種辦法是檢查系統時鐘。系統時鐘維護秒數,作為當前時間的一部分。如果是偶數,我們返回0;如果是奇數,我們返回1。如果我們需要一系列隨機數,這樣做效果不好。一秒時間太長,程式執行的時候,時鐘還來不及改變,生成的總是0或者總是1,這樣很難生成隨機數序列。甚至時間的記錄單位是毫秒(或者更小)的時候,生成的數字序列還是不夠隨機,因為程式呼叫的時間基本上還是相同。
我們需要的是一系列偽隨機數,和隨機數序列的屬性相同。假如想得到0-999之間的均勻分佈(其他分佈也很常用,大多數分佈源自均勻分佈)的隨機數,在指定範圍內,不同數字的出現機會是相等的。
真正的0-999的均勻分佈的序列應該有下列屬性:

  • 第一個數同樣可能是0-999
  • 第n個數同樣可能是0-999
  • 所有生成的數的預期的平均值是499.5

這些屬性不是特別嚴謹。比如,我們根據系統時鐘生成的第一個數是1,然後的每個數都加1。生成1000個數以後,所有的屬性都滿足了。但是沒有滿足更強的屬性。
均勻分佈的隨機數序列還應該滿足:

  • 兩個連續隨機數的和同樣可能是偶數或者奇數
  • 隨機生成的1000個數,應該有一些是重複的(大概368個數不會出現)

我們的數不滿足這些屬性。連續數的和總是奇數,數字也沒重複。所以,上面的隨機數生成器沒有通過統計測試。

線性同餘生成器是生成均勻分佈的好的演算法。它生成的X1

、X2滿足:
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

=1,序列的週期是5:

    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種排列。