三種洗牌算法shuffle

1. 背景

       筆試時,遇到一個算法題:差不多是 在n個不同的數中隨機取出不重複的m個數。洗牌算法是將原來的數組進行打散,使原數組的某個數在打散後的數組中的每個位置上等概率的出現,剛好可以解決該問題。

2. 洗牌算法

       由抽牌、換牌和插牌衍生出三種洗牌算法,其中抽牌和換牌分別對應Fisher-Yates Shuffle和Knuth-Durstenfeld Shhuffle算法。

   2.1 Fisher-Yates Shuffle算法

            

       最早提出這個洗牌方法的是 Ronald A. Fisher 和 Frank Yates,即 Fisher–Yates Shuffle,其基本思想就是從原始數組中隨機取一個之前沒取過的數字到新的數組中,具體如下:

        1. 初始化原始數組和新數組,原始數組長度爲n(已知);

        2. 從還沒處理的數組(假如還剩k個)中,隨機產生一個[0, k)之間的數字p(假設數組從0開始);

        3. 從剩下的k個數中把第p個數取出;

        4. 重複步驟2和3直到數字全部取完;

        5. 從步驟3取出的數字序列便是一個打亂了的數列。

        下面證明其隨機性,即每個元素被放置在新數組中的第i個位置是1/n(假設數組大小是n)。

        證明:一個元素m被放入第i個位置的概率P = 前i-1個位置選擇元素時沒有選中m的概率 * 第i個位置選中m的概率,即

                                                                  

#define N 10
#define M 5
void Fisher_Yates_Shuffle(vector<int>& arr,vector<int>& res)
{
     srand((unsigned)time(NULL));
     int k;
     for (int i=0;i<M;++i)
     {
     	k=rand()%arr.size();
     	res.push_back(arr[k]);
     	arr.erase(arr.begin()+k);
     }
}
       時間複雜度爲O(n*n),空間複雜度爲O(n).

    2.2 Knuth-Durstenfeld Shuffle  

           Knuth 和 Durstenfeld  在Fisher 等人的基礎上對算法進行了改進,在原始數組上對數字進行交互,省去了額外O(n)的空間。該算法的基本思想和 Fisher 類似,每次從未處理的數據中隨機取出一個數字,然後把該數字放在數組的尾部,即數組尾部存放的是已經處理過的數字。
               算法步驟爲:
                 1. 建立一個數組大小爲 n 的數組 arr,分別存放 1 到 n 的數值;
                 2. 生成一個從 0 到 n - 1 的隨機數 x;
                 3. 輸出 arr 下標爲 x 的數值,即爲第一個隨機數;
                 4. 將 arr 的尾元素和下標爲 x 的元素互換;
                 5. 同2,生成一個從 0 到 n - 2 的隨機數 x;
                 6. 輸出 arr 下標爲 x 的數值,爲第二個隨機數;
                 7. 將 arr 的倒數第二個元素和下標爲 x 的元素互換;
                    ……
                 如上,直到輸出 m 個數爲止
該算法是經典洗牌算法。它的proof如下:
對於arr[i],洗牌後在第n-1個位置的概率是1/n(第一次交換的隨機數爲i)
在n-2個位置概率是[(n-1)/n] * [1/(n-1)] = 1/n,(第一次交換的隨機數不爲i,第二次爲arr[i]所在的位置(注意,若i=n-1,第一交換arr[n-1]會被換到一個隨機的位置))
在第n-k個位置的概率是[(n-1)/n] * [(n-2)/(n-1)] *...* [(n-k+1)/(n-k+2)] *[1/(n-k+1)] = 1/n
(第一個隨機數不要爲i,第二次不爲arr[i]所在的位置(隨着交換有可能會變)……第n-k次爲arr[i]所在的位置).
void Knuth_Durstenfeld_Shuffle(vector<int>&arr)
{
	for (int i=arr.size()-1;i>=0;--i)
	{
		srand((unsigned)time(NULL));
		swap(arr[rand()%(i+1)],arr[i]);
	}
} 

    時間複雜度爲O(n),空間複雜度爲O(1),缺點必須知道數組長度n.

原始數組被修改了,這是一個原地打亂順序的算法,算法時間複雜度也從Fisher算法的 O(n2)提升到了O(n)。由於是從後往前掃描,無法處理不知道長度或動態增長的數組。

    2.3 Inside-Out Algorithm

       Knuth-Durstenfeld Shuffle 是一個內部打亂的算法,算法完成後原始數據被直接打亂,儘管這個方法可以節省空間,但在有些應用中可能需要保留原始數據,所以需要另外開闢一個數組來存儲生成的新序列。

        Inside-Out Algorithm 算法的基本思思是從前向後掃描數據,把位置i的數據隨機插入到前i個(包括第i個)位置中(假設爲k),這個操作是在新數組中進行,然後把原始數據中位置k的數字替換新數組位置i的數字。其實效果相當於新數組中位置k和位置i的數字進行交互。

       如果知道arr的lengh的話,可以改爲for循環,由於是從前往後遍歷,所以可以應對arr[]數目未知的情況,或者arr[]是一個動態增加的情況
證明如下:
原數組的第 i 個元素(隨機到的數)在新數組的前 i 個位置的概率都是:(1/i) * [i/(i+1)] * [(i+1)/(i+2)] *...* [(n-1)/n] = 1/n,(即第i次剛好隨機放到了該位置,在後面的n-i 次選擇中該數字不被選中)。
原數組的第 i 個元素(隨機到的數)在新數組的 i+1 (包括i + 1)以後的位置(假設是第k個位置)的概率是:(1/k) * [k/(k+1)] * [(k+1)/(k+2)] *...* [(n-1)/n] = 1/n(即第k次剛好隨機放到了該位置,在後面的n-k次選擇中該數字不被選中)。
         

void Inside_Out_Shuffle(const vector<int>&arr,vector<int>& res)
{
	res.assign(arr.size(),0);
	copy(arr.begin(),arr.end(),res.begin());
	int k;
	for (int i=0;i<arr.size();++i)
	{
		srand((unsigned)time(NULL));
		k=rand()%(i+1);
		res[i]=res[k];
		res[k]=arr[i];
	}
} 

    時間複雜度爲O(n),空間複雜度爲O(n). 

     2.4 蓄水池抽樣

             從N個元素中隨機等概率取出k個元素,N長度未知。它能夠在o(n)時間內對n個數據進行等概率隨機抽取。如果數據集合的量特別大或者還在增長(相當於未知數據集合總量),該算法依然可以等概率抽樣.
            僞代碼:
               
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 

上述僞代碼的意思是:先選中第1到k個元素,作爲被選中的元素。然後依次對第k+1至第N個元素做如下操作:
每個元素都有k/x的概率被選中,然後等概率的(1/k)替換掉被選中的元素。其中x是元素的序號。

proof:
每次都是以 k/i 的概率來選擇 
例: k=1000的話, 從1001開始作選擇,1001被選中的概率是1000/1001,1002被選中的概率是1000/1002,與我們直覺是相符的。 
接下來證明: 
假設當前是i+1, 按照我們的規定,i+1這個元素被選中的概率是k/i+1,也即第 i+1 這個元素在蓄水池中出現的概率是k/i+1 
此時考慮前i個元素,如果前i個元素出現在蓄水池中的概率都是k/i+1的話,說明我們的算法是沒有問題的。 

對這個問題可以用歸納法來證明:k < i <=N 
1.當i=k+1的時候,蓄水池的容量爲k,第k+1個元素被選擇的概率明顯爲k/(k+1), 此時前k個元素出現在蓄水池的概率爲 k/(k+1), 很明顯結論成立。 
2.假設當 j=i 的時候結論成立,此時以 k/i 的概率來選擇第i個元素,前i-1個元素出現在蓄水池的概率都爲k/i。 
證明當j=i+1的情況: 
即需要證明當以 k/i+1 的概率來選擇第i+1個元素的時候,此時任一前i個元素出現在蓄水池的概率都爲k/(i+1). 
前i個元素出現在蓄水池的概率有2部分組成, ①在第i+1次選擇前得出現在蓄水池中,②得保證第i+1次選擇的時候不被替換掉 
①.由2知道在第i+1次選擇前,任一前i個元素出現在蓄水池的概率都爲k/i 
②.考慮被替換的概率: 
首先要被替換得第 i+1 個元素被選中(不然不用替換了)概率爲 k/i+1,其次是因爲隨機替換的池子中k個元素中任意一個,所以不幸被替換的概率是 1/k,故 
前i個元素(池中元素)中任一被替換的概率 = k/(i+1) * 1/k = 1/i+1 
則(池中元素中)沒有被替換的概率爲: 1 - 1/(i+1) = i/i+1 
綜合① ②,通過乘法規則 
得到前i個元素出現在蓄水池的概率爲 k/i * i/(i+1) = k/i+1 
故證明成立
如果m被選中,則隨機替換水庫中的一個對象。最終每個對象被選中的概率均爲k/n,證明如下:

   證明:第m個對象被選中的概率=選擇m的概率*(其後元素不被選擇的概率+其後元素被選擇的概率*不替換第m個對象的概率),即

void Reservoir_Sampling(vector<int>& arr)
{
	int k;
	for (int i=M;i<arr.size();++i)
	{
		srand((unsigned)time(NULL));
		k=rand()%(i+1);
		if (k<M)
			swap(arr[k],arr[i]); 
	}
}
因此,蓄水池抽樣因爲不需知道n的長度,可用於機器學習的數據集的劃分,等概率隨機抽樣分爲測試集和訓練集。








發佈了111 篇原創文章 · 獲贊 113 · 訪問量 46萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章