等機率無重複的從n個數中選取m個數,個數
問題描述:程式的輸入包含兩個整數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 = mremaining = nfor 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
參考http://blog.csdn.net/hackbuteer1/article/details/7971328