轉載自:點擊打開鏈接
問題描述:程序的輸入包含兩個整數m和n,其中m<n。輸出是0~n-1範圍內的m個隨機整數,要求:每個數選擇出現的概率相等,且按序輸出。
學習過概率統計的同學應該都知道每一個數字被抽取的概率都應該爲m/n. 那麼我們怎麼構造出這樣的概率呢?在《編程珠璣》上面是這樣解析的:
依次考慮整數0,1,2,.....,n-1,並通過一個適當的隨機測試對每個整數進行選擇。通過按序訪問整數,我們可以保證輸出結果是有序的。 假如我們考慮m = 2,n = 5的情況,那麼選擇的每一個數字的概率都應該是2/5,我們怎麼樣才能做到呢?不慌張,慢慢來。
下面給出我的分析過程:在0,1,2,3,4這五個數字中,我們依次對每一個數進行分析,第一次遇到0時,它的選擇概率應該是2/5,如果選中了,我們開始測試第二個數1,這個時候因爲1選中了,所以1這個數字的選中概率就變小了,變成1/4了,有人說這似乎不對吧,因爲題目說讓每一個數字選中的概率是一樣大的,而現在?一個2/5,一個1/4,這怎麼行呢?其實不是這樣的,認真思考一下就知道了,數字1選中的概率等於什麼? 數字1選中的概率p(1) = 數字0選中的概率 * (1/4) + 數組0沒選中的概率*(2/4)這樣推算下 (2/5 * 1/4) + (3/5 * 2/4) = 8/20 = 2/5 。這不就一樣了嗎?呵呵!下面給出來自Knuth的《The Art of Computer Programming, Volume2:Seminumerical Algorithms》的僞代碼:
- select = m
- remaining = n
- for i = [0,n)
- if (rand() % remaining) < select
- print i
- select --
- remaining--
- int gen(int m,int n)
- {
- int i, select = m,remaining = n;
- for(i=0;i<n;i++) {
- if(rand() % remaining <select) {
- printf("%d\n",i);
- select--;
- }
- remaining--;
- }
- return 0;
- }
可以優化爲這樣:
- int genknuth(int m,int n)
- {
- int i;
- for(i=0;i<n;i++)
- if(rand()%(n -i) < m) {
- printf("%d\n",i);
- m--;
- }
- return 0;
- }
代碼很精簡,代碼遵守的規則應該是要從r個剩餘的整數中選出s個,我們以概率s/r選擇下一個數。這個概率的選擇方式和我們上面證明的是一樣的。所以在程序結束的時候一定會打印出m個數字,且每一個數字的被選擇概率相同,爲m/n。 首先是一個循環,這個循環確保了輸出的數是不重複的,因爲每次的i都不一樣
其次是m個數,在每次循環中都會用rand()%(n-i)<m來判斷這個數是否小於m,如果符合條件則m減1,直到爲0,說明已經取到m個數了
再次是如何保證這m個數是等概率取到的
在第一次循環中i=0, n-i=n, 則隨機數生成的是0-n-1之間的隨機數,那麼此刻0被取到的概率爲 m/n-1
在第二次循環中i=1,n-i=n-1,則隨機數生成的是0-n-2之間的隨機數,這時1被取到的概率就和上一次循環中0有沒有取到有關係了。假設在上一次循環中,沒有取,則這次取到的1的概率爲 m/n-2;假設上一次循環中,已經取到了,那麼這次取到1的概率爲m-1/n-2,所以總體上這次被取到的概率爲 (1-m/n-1)*(m/n-2)+(m/n-1)*(m-1/n-2),最後通分合並之後的結果爲m/n-1和第一次的概率一樣的
同理,在第i次循環中,i被取上的概率也爲m/n-1
2、等概率順序取數據的第二種方法,可以使用集合的思想
由於集合元素不重複,如果按等概率選擇一個隨機數,不在集合中就把它插入,反之直接拋棄,直到集合元素個數達到m個,同樣可以滿足要求,並且用C++的STL很容易實現:
- void gensets(int m,int n) {
- set<int> S;
- while(S.size() < m)
- S.insert(rand()%n);
- set<int>::iterator i;
- for(i = S.begin();i!=S.end();++i)
- cout<<*i<<"\n";
- }
這個算法的主要問題是,如果拋棄已存在的元素的次數過多,相當於多次產生隨機數並進行集合操作,性能將明顯下降。比如當n=100而m=99,取第99個元素時,算法“閉着眼睛亂猜整數,直到偶然碰上正確的那個爲止”(《編程珠璣(續)》,13.1節)。雖然這種情況會在“從一般到特殊”提供解決方案,但下面的Floyd算法明顯規避了產生隨機數超過m次的問題。
習題12.9提供了一種基於STL集合的隨機數取樣方法,可以在最壞情況下也只產生m個隨機數:限定當前從中取值的區間的大小,每當產生重複的隨機數,就把這一次迭代時不會產生的第一個隨機數拿來替換。
- int genfloyd(int m,int n){
- set<int> S;
- set<int>::iterator i;
- for(int j = n-m; j<n;j++) {
- int t = rand()%(j+1);
- if(S.find(t) == S.end())
- S.insert(t);
- else
- S.insert(j);
- }
- for(i=S.begin();i!=S.end();++i)
- cout<<*i<<"\n";
- }
從“打亂順序”出發
這是個來源於實際的想法:將所有n個元素打亂,取出前m個。更快的做法是,打亂前m個即可。對應的C++代碼如下:
- int genshuf(int m,int n)
- {
- int i,j;
- int *x = new int[n];
- for(i = 0;i<n;i++)
- x[i] = i;
- for(i = 0;i<m;i++) {
- j = randint(i,n-1);
- //randint產生i到n-1之間的隨機數
- int t = x[i];x[i] = x[j];x[j] = t;
- }
- //sort(x,x+m);
- //sort是爲了按序輸出
- for(i=0;i<m;i++)
- cout<<x[i]<<"\n";
- }
當然了,這個題目還有其他的解法,這是在網上看到的其他的解法。他們將這樣的問題抽象的定義爲蓄水池抽樣問題。其思路是這樣的,先把前k個數放入蓄水池中,對第k+1,我們以k/(k+1)的概率決定是否要把它換入蓄水池,換入時我們可以隨機挑選一個作爲替換位置,這樣一直到樣本空間N遍歷完,最後蓄水池中留下的就是結果。這樣的方法得到的結果也是正確的,且每一個數字被選擇的概率也是k/n。
這個問題其實還可以擴展一下:
如何從n個對象(可以以此看到這n個對象,但事先不知道n的值)中隨機選擇一個?比如在不知道一個文本中有多少行,在這樣的情況下要求你隨機選擇文件中一行,且要求文件的每一行被選擇的概率相同。 在知道n這個總對象個數的情況下,誰都知道概率是1/n. 但是我們現在不知道,怎麼辦呢?
考慮這樣是不是可以,我們總是以1/i的概率去選擇每一次遍歷的對象,比如從1,2,3,4,5,6,....,N, 每一次遍歷到x時,總是以1/x的概率去選擇它.
整體思路如下:
我們總選擇第一個數字(文本行),並以概率1/2選擇第二個(行),以1/3選擇第三行,也就是說設結果爲result,遍歷第一個時result = 1,第二個時以1/2的概率替讓result = 2,這樣一直遍歷概率性的替換下去,最終的result就是你的結果。他被選擇的概率就是1/n。
證明思路如下:
第x個數被選擇的概率等於x被選擇的概率 * (x+1沒被選擇的概率) * (x+2沒有被選擇的概率) *......*(N沒有被選擇的概率) 具體化一下
2被選擇的概率 = 1/2 * 2/3 * 3/4 * 4/5 .....* (n-1/n) 我想你知道答案了吧? 對! 是1/n.這樣就可以在不知道N的大小的情況下等概率的去選擇任意一個對象了!
參考僞代碼如下:
i = 0
while
more input lines
with probability 1.0/++i
choice =
this
input line
print choice
- Init : a reservoir with the size: k
- for i= k+1 to N
- M=random(1, i);
- if( M < k)
- SWAP the Mth value and ith value
- end for