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的長度,可用於機器學習的數據集的划分,等概率隨機抽樣分為測試集和訓練集。
Leetcode 例題:
3.1. Linked List Random Node
Given a singly linked list, return a random node's value from the linked list. Each node must have the same probability of being chosen.
Follow up:
What if the linked list is extremely large and its length is unknown to you? Could you solve this efficiently without using extra space?
Example:
// Init a singly linked list [1,2,3]. ListNode head = new ListNode(1); head.next = new ListNode(2); head.next.next = new ListNode(3); Solution solution = new Solution(head); // getRandom() should return either 1, 2, or 3 randomly. Each element should have equal probability of returning. solution.getRandom();
利用蓄水池采樣原理,無需事先計算list長度即可求解,具體代碼如下:
public class Solution { Random r; ListNode head; public Solution(ListNode head) { this.r = new Random(); this.head = head; } public int getRandom() { int count = 1; ListNode nodeVal = head; ListNode curr = head; while (curr != null) { if (r.nextInt(count++) == 0) { nodeVal = curr; } curr = curr.next; } return nodeVal.val; } }
3.2. Random Pick Index
Given an array of integers with possible duplicates, randomly output the index of a given target number. You can assume that the given target number must exist in the array.
Note:
The array size can be very large. Solution that uses too much extra space will not pass the judge.
Example:
int[] nums = new int[] {1,2,3,3,3}; Solution solution = new Solution(nums); // pick(3) should return either index 2, 3, or 4 randomly. Each index should have equal probability of returning. solution.pick(3); // pick(1) should return 0. Since in the array only nums[0] is equal to 1. solution.pick(1);
若采用常規算法,我們需要用HashMap把對應的數值及所處的位置一一關聯,這樣則違背了題目的空間限制。使用蓄水池采樣則避免了這個問題。具體代碼如下:
public class Solution { int[] nums; public Solution(int[] nums) { this.nums = nums; } public int pick(int target) { int index = -1; int count = 1; Random random = new Random(); for (int i = 0; i < nums.length; i++) { if (nums[i] == target) { if (random.nextInt(count++) == 0) { index = i; } } } return index; } }
首先我們取到第一個數(暫時取的最后要不要還不一定呢),然后對第二個數以1/2的概率來確定是否 用第二個數來替換他,然后對第二個數以1/3的概率來確定是否用第三個數來替換他。。。。一直這樣下去直到第n個數。經過上面的這個過程我們發現每個數取到的概率都變成了(1/n)。證明如下:![]()
總結起來就是一句話每個數取到的概率等於取到該數且取不到該數后面所有數的概率。如:取到第10個數的概率等於取到第十個數且取不到第11到第n個數的概率現在我們回到較復雜的情況,也就是如何在一個N個數(開始不知道N是幾)中隨機取M個數。其實思想是一樣的,就是先取出前M個,然后對后面的開始每個以(k/(i))的概率進行替換,這樣我們得到的就是所要的結果,證明如下:![]()
import random import copy def reservoirSampling(seq, k): localSeq = copy.deepcopy(seq) N = len(localSeq) for i in xrange(k, N): M = int(random.uniform(0, i)) if M < k : temp = copy.deepcopy(localSeq[M]) localSeq[M] = copy.deepcopy(localSeq[i]) localSeq[i] = temp return localSeq[0:k] def main(): a = [4,5,6,3,4,7,7,4,3,3,2,4,5,5,6,9,5,4,3,45,3,23,44,55,33,5,8] k = 5 print reservoirSampling(a, k) if __name__ == '__main__': main()
代碼總結:
package Random; import java.util.*; public class RandomMethods { /**問題一*/ // 給你一個數組,設計一個既高效又公平的方法隨機打亂這個數組(此題和洗牌算法的思想一致) void swap(int[] arr, int i, int j ){ int t = arr[i]; arr[i] = arr[j]; arr[j] = t; } void shuffle_dfs(int[] arr, int n){ if(n <= 1 ){ return; } Random random = new Random(); int t = random.nextInt(n); swap(arr,n-1, t); shuffle_dfs(arr, n-1); } void shuffle(int[] arr , int n){ while(n>1){ Random random = new Random(); int t = random.nextInt(n); swap(arr, n-1, t); n--; } } /**問題2**/ // n已知 //快速生成10億個不重復的18位隨機數的算法(從n個數中生成m個不重復的隨機數) //假設從-n這n個數中生成m個不重復的數,且n小於int的表示范圍 //總體思想是一開始每個數被選中的概率是m/n,於是隨機一個數模n如果余數小於m則輸出該數,同時m減 //否則繼續掃描,以后的每個數被選中的概率都是m/(n-i) /*遍歷第1個數字時有m/n的概率進行選擇,如果選擇了第1個數字, 則第2個數字被選擇的概率調整為(m-1)/(n-1),如果沒選擇第1個數字, 則第2個數字被選擇的概率為m/(n-1)。即遍歷到第i個數字的時候, 如果此時已經選擇了k個,則以(m-k)/(n-i+1)的概率決定是否要選擇當前的第i個數字。 這樣可以保證每次都能夠保證在剩下的數字中能選擇適當的數使得總體選擇的數字是m個。 比如,如果前面已經隨機了m個,則后面隨機的概率就變為0。如果前面一直都沒隨機到數字, 則后面隨機到的概率就會接近1。最終得到的結果始終精確地是m個數字。 */ void random_generate(int n,int m){ int i =1; while(n-i > m ){ Random random = new Random(); int t = random.nextInt(n-i); if(t < m){ System.out.println(i); m -- ; } i++; } while( ++i <= n) System.out.println(i); } void swap(ArrayList<Integer> arr, int i, int j ){ int t = arr.get(i); arr.set(i, arr.get(j)); arr.set(j,t); } // 蓄水池抽樣算法 void Reservoir_Sampling(ArrayList<Integer> arr ,int K) { for (int i=K+1;i<arr.size();++i) { Random random = new Random(); int M = random.nextInt(i+1); if (M < K) swap(arr,M, i); } for(int i=0; i<K;i++){ System.out.print(arr.get(i)+" "); } System.out.println(); } }
參考鏈接: