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
2. 生成一個從 0 到 n - 1 的隨機數 x;
3. 輸出 arr 下標爲 x 的數值,即爲第一個隨機數;
4. 將 arr 的尾元素和下標爲 x 的元素互換;
5. 同2,生成一個從 0 到 n - 2 的隨機數 x;
6. 輸出 arr 下標爲 x 的數值,爲第二個隨機數;
7. 將 arr 的倒數第二個元素和下標爲 x 的元素互換;
……
如上,直到輸出 m 個數爲止
在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
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 蓄水池抽樣
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是元素的序號。
每次都是以 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的長度,可用於機器學習的數據集的劃分,等概率隨機抽樣分爲測試集和訓練集。