淺談隨機數生成器及其應用


  再一次向YYF大神致敬。

  但有一個叫YYF的大神(它說自己是蒟蒻)叫我附上轉載地址:http://www.cnblogs.com/yyf0309/p/6853425.html


[導讀]

  相信來看的讀者一定知道在stdlib.h中的rand(),開始覺得它是一個很神奇的東西,絞盡腦汁都想不出它是如何做到的,於是查了下資料知道了如下幾點

  1. Windows隨機函數產生的隨機數不是真正意義下的隨機數,而是通過一個隨機數種子,然后一些公式,不斷地得到下一個"隨機數",然后將種子改成這個隨機數,然后再不斷這樣繼續。
  2. 通過單純的編程很難得到一個真正的隨機函數,UNIX系統下一個隨機函數是通過硬件的信息(例如硬件發出的噪音)等等來得到的隨機數

  所以經常會出現這樣的情況

  然后得到這樣的可怕的"隨機數"

  顯然for循環的速度比操作系統時鍾中斷(至少有個10ms,你應該知道,光標的閃爍等等都需要依賴於操作系統的時鍾中斷的計時)一次快很多,所以每次的seed是一樣的,公式也沒有變,所以生成出來的隨機數也沒有變。


[小介紹]

  如果你知道rand()和srand(unsigned int)兩個函數就可以跳過這一段了。

函數原型 作用
int __cdecl rand(void) 返回一個值在0~RANDMAX之間的整數
void __cdecl srand(unsigned int _Seed) 重置隨機數種子

  你也注意到了rand()返回的值不是任意一個整型,至少它返回的最大值是RAND_MAX(多數機子上是32767)等等。那么如何得到一個1 ~ 20之間的一個數,有如下三種方法

  1. 一直rand(),直到出現一個符合條件的數。顯然它是等概率的。如果我要生成一組100000個數的數組,顯然它太慢了。
  2. 用(double)rand() / RAND_MAX得到一個在[0, 1]之間的數,然后再乘20取整再加一。當然這個不是等概率的,因為20可能不是RAND_MAX的約數,但是這點概率通常可以忽略。但是最大的問題是double有精度誤差,所以如果想要的得到的數比較大,可能會出精度的問題,比如說產生比這個數還大的數。
  3. 模20再加1。這個比較常用。

  但是如果我希望得到一個在1 ~ 2e7之間的隨機數,好像有點痛苦了,上面好幾種方法都不行,如果你忽略第二種方法會有很多不會能出現的數,你還是可以繼續用它的。除此之外,還有以下兩種方法。

  1. 用rand()再乘上一個rand()再按上面的方法處理就好了。但是有個問題就是,你不能得到質數,但是總比除了再乘靠譜吧。作為懶人,我通常都這么干。
  2. 也是rand()兩次,用第一次rand()的低14位作為新數的低14位,用二次rand()的低14位作為新數后14位(說的是2進制),然后你就可以得到一個在[0, 229-1)之間的整數,然后就可以按上面的方法處理。

  暫時先就討論這么多吧,進入下一部分。


[手打隨機數生成器]

  不要以為隨便亂加一堆,乘一個亂七八糟的數,再加一個什么啊,然后再模一個什么啊,就能夠稱為"隨機數生成器",也許它連偽隨機數生成器都不算。(表示經常遇到生成不出偶數什么的情況或者生成出奇數的概率為偶數的幾倍)。

  所以說需要調參數,要多次測試。除了測試有沒有生成出來外還應該讓各種數刷出來的概率不說一樣至少得相差要小一點啊。

  比如說開始我們封裝一個"簡單"的隨機數生成器

復制代碼
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>

typedef bool boolean;

typedef class Random {
    public:
        unsigned int pre;
        unsigned int seed;
        
        Random():pre(0), seed((unsigned) time (NULL)) {    }
        Random(int seed):pre(0), seed(seed) {    }
        
        unsigned int rand() {
            unsigned int ret = (seed * 7361238 + seed % 20037 * 1244 + pre * 12342 + 378211) * (seed + 134543);
            pre = seed;
            seed = ret;
            return ret;
        }
}Random;
復制代碼

  測試一下發現效果還可以,還算過得去,效率也不錯,再運行一下stdlib.h中的rand(),發現最大的概率差大概和庫中的函數差不多,參差不齊。但是這樣生成的最大問題就是可能循環節會比較小(至少不搞天文數據還是算得過去的)。

  於是決定進入time()函數增加一點惡趣味。。

unsigned int ret = (seed * 7361238 + seed % 20037 * 1244 + pre * 12342 + 378211 + time(NULL) * pre) * (seed + 134543);

  可怕,一不均勻二很慢,於是決定將兩者組合一下,並調整一下參數。

unsigned int ret;
if(pre & 1)
    ret = (seed * 7361238 + seed % 20037 * 1244 + pre * 12342 + 378211 + ((time(NULL) * pre) & 7624)) * (seed + 134543);
else
    ret = (seed * 7361238 + seed % 20037 * 1244 + pre * 12342 + 378211) * (seed + 134543);

  這樣效果就還行,雖然慢了點,還是很棒的。最關鍵的是,因為unsigned int自帶溢出,所以這個隨機函數能夠生成的數的最大值可以達到(233-1)!

  然而測試一下1 ~ 2的隨機數生成,輸出出來,發現這是嚴格的奇偶交替。。於是重寫了一個rand()

復制代碼
unsigned int rand() {
    unsigned int ret;
    if(ret & 1)
        ret = (seed * 7361238 + seed % 20037 * 1245 + pre * 456451 + (time(NULL) * (pre * 5 + seed * 3 + 37)) + 156464);
    else
        ret = (seed * 7361238 + seed % 20037 * 1241 + pre * 456454 + (time(NULL) * (pre * 7 + seed * 3 + 18)) + 156464);
    pre = seed;
    seed = ret;
    return ret;
}
復制代碼

  效果在范圍較大的時候都還不錯。


[隨機數生成器的應用]

  為了簡單,所以決定再定義一個成員函數

unsigned int rand(int low, int high){
    if(low > high)  return 0;
    int len = high - low + 1;
    return rand() % len + low;
}

  生成在整數區間[low, high]之間的整數。

1.隨機生成一個數列

  這個很簡單不停地調用rand()函數就好了

void randArray(int* array, unsigned int low, unsigned int high, int length) {
    for(int i = 0; i < length; i++)
        array[i] = rand(low, high);
}

2.隨機生成一個沒有重復的數的數列

   可以想到的第一個方法,用樹狀數組維護已經使用過的數(用過的記為1,沒有用過的記為0),每次隨機產生一個在整數區間[0, r)中的整數,其中r為還沒有選的整數個數,然后二分答案找到這個數,總時間復雜度為O(nlog2k),其中k為可選數的個數。(有沒有更快找到第x個沒有被選的數的方法我就不知道了)。

  寫起來會比較"爽",所以就不寫了,樹狀數組至少20行,再加上7 ~ 8行的二分答案,然后最后幾行for啊,什么的,總共大概30行左右,只是為了造個數據,寫起來卻像是半道題。可以考慮另尋思路。

  開一個vis數組,記錄這個這個數有沒有被選。然后rand一個數,直到它沒有被選再把它的值付給數組中的元素,然后打上標記。看似最壞的情況是O(n2)的或者更糟糕,但是這種情況極其少。可以嘗試先寫一個,然后運行,事實證明它沒有那么糟糕的。

復制代碼
 1 boolean randNonRepeatArray(int* array, unsigned int low, unsigned int high, int length) {
 2     if((signed)(high - low + 1) < length) return false;
 3     boolean *vis = new boolean[(const int)(high - low + 1)];
 4     memset(vis, false, sizeof(boolean) * (high - low + 1));
 5     for(int i = 0, c; i < length; i++) {
 6         while(vis[(c = rand(low, high)) - low]);
 7         vis[c - low] = true;
 8         array[i] = c;
 9     }
10     return true;
11 }
復制代碼

  再運行一下106級的數據,並加一段這樣的統計

復制代碼
 1 inline void total() {
 2     memset(counter, 0, sizeof(counter));
 3     for(int i = 1; i <= L; i++) {
 4         counter[a[i]]++;
 5         if(counter[a[i]] > 1) {
 6             cout << "The algorithm migit have some problems because of " << i << "." << endl;
 7             return;
 8         }
 9     }
10 }
復制代碼

  發現沒有這段的輸出,說明算法是正確的,再看看效率

  以我家渣機的效率,可以估計它的期望時間復雜度大概是O(nlogn)級別的(只是這個級別,不是說就是O(nlogn))

  下面給出更詳細的估計(如果你不會一些高等數學的知識,完全可以跳過這一部分)

  (當然,下面所有分析的前提是,這個隨機數生成器生成出來的數是等概率的)

  對於我已經用了x個數,一次選下來的期望是,兩次選下來的期望是,三次選下來的期望是,,因此選取第(x + 1)個數的期望是

  不用害怕,對(n + 1)展開進行求和

  首先解決簡單點的,先把第二部分解決了,我們可以用極限的思想,配合上等比數列求和公式輕松地得到

 

  對於第一部分,我們首先設

  然后有

  然后得到

  然后可以得到

  然后再把之前的括號內拿來求和

 

  再乘上,得到它的期望是

  那么總的期望的次數就是

  然后我們假設k和x,n都很大,於是我們可以愉快地調用調和級數的"求和公式"(當n很大的時候就越接近這個下界),然后得到了

  對於通常情況下的估計,第二項直接估為,然后就可以粗略地計算耗時。不過通常出數據時哪會管這個時間復雜度,那個方法寫起簡單用哪個,我不知道為什么這個算法在網上人人都說它很慢,最壞的情況下趨於無限(顯然這基本上可以看做不可能事件),理論上還是蠻優秀的啊。下面將提供一種線性的方法。

3.隨機打亂一個數列

  仍然可以用過上面的方法,生成一個新的位置沒有重復的數組,然后按這個數組重新排列就好了。但是這里我想要介紹一種O(n)的方法。

  1. 設i = 1
  2. 我當前正在考慮位置i,執行一次rand(i, n),其中n為數組長度,設這個返回值為x,交換位置i和x(如果i = x則就不動就好了)
  3. 如果i = n,算法結束,否則i++,調回步驟2

  現在來考慮算法的正確性,還是和上面一樣,假設數據生成器十分完美,生成任何數都是等概率的。

  下面將使用數學歸納法來證明

  1. 對於位置1,顯然每個數在這個位置上的概率為

  2. 對於一個位置k(k > 1),假設每個數在這之前的(k - 1)個位置上的概率都為(歸納假設),則剩下的每個數留在位置k的概率為(第一個分數是因為留在后(n - k + 1)的概率為這么多,后面一個就很好理解)

  所以算法是滿足等概率。顯然這個算法不會發生什么沖突,一個把另一個覆蓋,數放到數組外邊去了之類的情況,所以它是正確的。

  寫起來也很短,而且還可以寫成模板

復制代碼
 1 template<typename T>
 2 void randomUpset(T* array, int length) {
 3     for(int i = 0; i < length; i++) {
 4         int idx = rand(i, length - 1);
 5         if(idx != i) {
 6             T t = array[idx];
 7             array[idx] = array[i];
 8             array[i] = t;
 9         }
10     }
11 }
復制代碼

  對於上面的那一種任務,可以想到用這個方法隨機打亂(1 ~ k)生成出1 ~ k的排列的前n個(當i >= n的時候break掉),顯然它是正確的,時間復雜度成功降為O(n)。

4.更小內存消耗的無重復隨機數列生成

  經常遇到數據結構的題,數據范圍是凶殘的,比如說109或者1018之類的,如果按照上面的生成方法那么內存會炸掉,但是顯然n不會那么大,那么考慮一下其他方法。

  首先來思考一個線性的做法

  1. 將i設為1,開始步驟2
  2. 如果i <= n,則將a[i]設為i,將i++然后繼續步驟2,否則跳轉步驟3
  3. 否則隨機一個在1 到 i之間的數r,如果r小於等於k,則將a[k]設為i,否則不管。然后i++,如果i > k就跳轉到步驟4。
  4. 隨機打亂a數組

  然后來考慮一下算法的正確性。這個用和上面的相似的方法就可證出來。

  這時只用存下a數組,所以空間復雜度僅為,但是時間復雜度變為了,只能另尋思路。

  考慮用更高效的數據結構(例如hash表或者平衡樹,當然如果您高興可以線段樹動態開節點)來優化上面的應用2的最后一種做法。

  如果用平衡樹或者線段樹,那么它的期望大概是,空間復雜度為

  但是如果是hash表,那么時間復雜度和空間復雜度都是O(玄學)(hash表本來就是玄學。。)

5.生成一棵樹

  如果你不知道樹在計算機里是指什么,我就水水地說一下,由n - 1條邊使得n個點互相聯通的一個無向圖。

  那么怎么生成一棵樹呢?

  我們假定父節點的編號比子節點小,是不是有思路了?

  然后每個節點(除了根節點外)去生成它的父節點就好了。

  那么可以想到生成一個無向連通圖,因為一個有n個點的無向連通圖的邊數是大於等於(n - 1)的,所以它是基於樹上加點邊。於是你就可以先生成一棵樹,然后不斷加邊就好了。

6.更多的應用

  上面只是講述了如何出數據之類的問題,下面列舉一點基於隨機數的一些算法 / 數據結構

  1. Treap的優先級,雖然可以出有序數據,但是通常都是用的隨機數
  2. 爬山 & 退火
  3. 模擬一些沒有規律的運動,或者說畫面的渲染(比如說吸收了多少的光,反射了多少,來確定物體的亮度)

(最后呢,歡迎提問或者指出問題(我覺得上面推一大堆東西的那一塊問題特別大))

提供一份模板 [RamdomV1.1.zip]

歷史版本一覽

要用的時候解壓放在當前工作目錄下,加入#include "Random"

[更新日志]

  • 2017.5.19 更新代碼一份,更正兩處筆誤,更新隨機函數。
  • 2017.5.20 更新3處有問題的地方


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM