我知道你們會各類花式排序算法,可是若是叫你打亂一個數組,你是否能作到成竹在胸?即使你拍腦殼想出一個算法,怎麼證實你的算法就是正確的呢?亂序算法不像排序算法,結果惟一能夠很容易檢驗,由於「亂」能夠有不少種,你怎麼能證實你的算法是「真的亂」呢?git
因此咱們面臨兩個問題:算法
這種算法稱爲「隨機亂置算法」或者「洗牌算法」。數組
本文分兩部分,第一部分詳解最經常使用的洗牌算法。由於該算法的細節容易出錯,且存在好幾種變體,雖有細微差別但都是正確的,因此本文要介紹一種簡單的通用思想保證你寫出正確的洗牌算法。第二部分講解使用「蒙特卡羅方法」來檢驗咱們的打亂結果是否是真的亂。蒙特卡羅方法的思想不難,可是實現方式也各有特色的。app
PS:我認真寫了 100 多篇原創,手把手刷 200 道力扣題目,所有發佈在 labuladong的算法小抄,持續更新。建議收藏,按照個人文章順序刷題,掌握各類算法套路後投再入題海就如魚得水了。ide
此類算法都是靠隨機選取元素交換來獲取隨機性,直接看代碼(僞碼),該算法有 4 種形式,都是正確的:測試
// 獲得一個在閉區間 [min, max] 內的隨機整數 int randInt(int min, int max); // 第一種寫法 void shuffle(int[] arr) { int n = arr.length(); /******** 區別只有這兩行 ********/ for (int i = 0 ; i < n; i++) { // 從 i 到最後隨機選一個元素 int rand = randInt(i, n - 1); /*************************/ swap(arr[i], arr[rand]); } } // 第二種寫法 for (int i = 0 ; i < n - 1; i++) int rand = randInt(i, n - 1); // 第三種寫法 for (int i = n - 1 ; i >= 0; i--) int rand = randInt(0, i); // 第四種寫法 for (int i = n - 1 ; i > 0; i--) int rand = randInt(0, i);
分析洗牌算法正確性的準則:產生的結果必須有 n! 種可能,不然就是錯誤的。這個很好解釋,由於一個長度爲 n 的數組的全排列就有 n! 種,也就是說打亂結果總共有 n! 種。算法必須可以反映這個事實,纔是正確的。spa
咱們先用這個準則分析一下第一種寫法的正確性:設計
// 假設傳入這樣一個 arr int[] arr = {1,3,5,7,9}; void shuffle(int[] arr) { int n = arr.length(); // 5 for (int i = 0 ; i < n; i++) { int rand = randInt(i, n - 1); swap(arr[i], arr[rand]); } }
for 循環第一輪迭代時,i = 0
,rand
的取值範圍是 [0, 4]
,有 5 個可能的取值。3d
for 循環第二輪迭代時,i = 1
,rand
的取值範圍是 [1, 4]
,有 4 個可能的取值。code
後面以此類推,直到最後一次迭代,i = 4
,rand
的取值範圍是 [4, 4]
,只有 1 個可能的取值。
能夠看到,整個過程產生的全部可能結果有 n! = 5! = 5*4*3*2*1
種,因此這個算法是正確的。
分析第二種寫法,前面的迭代都是同樣的,少了一次迭代而已。因此最後一次迭代時 i = 3
,rand
的取值範圍是 [3, 4]
,有 2 個可能的取值。
// 第二種寫法 // arr = {1,3,5,7,9}, n = 5 for (int i = 0 ; i < n - 1; i++) int rand = randInt(i, n - 1);
因此整個過程產生的全部可能結果仍然有 5*4*3*2 = 5! = n!
種,由於乘以 1 無關緊要嘛。因此這種寫法也是正確的。
若是以上內容你都能理解,那麼你就能發現第三種寫法就是第一種寫法,只是將數組從後往前迭代而已;第四種寫法是第二種寫法從後往前來。因此它們都是正確的。
若是讀者思考過洗牌算法,可能會想出以下的算法,可是這種寫法是錯誤的:
void shuffle(int[] arr) { int n = arr.length(); for (int i = 0 ; i < n; i++) { // 每次都從閉區間 [0, n-1] // 中隨機選取元素進行交換 int rand = randInt(0, n - 1); swap(arr[i], arr[rand]); } }
如今你應該明白這種寫法爲何會錯誤了。由於這種寫法獲得的全部可能結果有 n^n
種,而不是 n!
種,並且 n^n
不多是 n!
的整數倍。
好比說 arr = {1,2,3}
,正確的結果應該有 3!= 6
種可能,而這種寫法總共有 3^3 = 27
種可能結果。由於 27 不能被 6 整除,因此必定有某些狀況被「偏袒」了,也就是說某些狀況出現的機率會大一些,因此這種打亂結果不算「真的亂」。
上面咱們從直覺上簡單解釋了洗牌算法正確的準則,沒有數學證實,我想你們也懶得證實。對於機率問題咱們可使用「蒙特卡羅方法」進行簡單驗證。
洗牌算法,或者說隨機亂置算法的正確性衡量標準是:對於每種可能的結果出現的機率必須相等,也就是說要足夠隨機。
若是不用數學嚴格證實機率相等,能夠用蒙特卡羅方法近似地估計出機率是否相等,結果是否足夠隨機。
記得高中有道數學題:往一個正方形裏面隨機打點,這個正方形裏緊貼着一個圓,告訴你打點的總數和落在圓裏的點的數量,讓你計算圓周率。
這其實就是利用了蒙特卡羅方法:當打的點足夠多的時候,點的數量就能夠近似表明圖形的面積。經過面積公式,由正方形和圓的面積比值是能夠很容易推出圓周率的。固然打的點越多,算出的圓周率越準確,充分體現了大力出奇跡的真理。
相似的,咱們能夠對同一個數組進行一百萬次洗牌,統計各類結果出現的次數,把頻率做爲機率,能夠很容易看出洗牌算法是否正確。總體思想很簡單,不過實現起來也有些技巧的,下面簡單分析幾種實現思路。
第一種思路,咱們把數組 arr 的全部排列組合都列舉出來,作成一個直方圖(假設 arr = {1,2,3}):
每次進行洗牌算法後,就把獲得的打亂結果對應的頻數加一,重複進行 100 萬次,若是每種結果出現的總次數差很少,那就說明每種結果出現的機率應該是相等的。寫一下這個思路的僞代碼:
void shuffle(int[] arr); // 蒙特卡羅 int N = 1000000; HashMap count; // 做爲直方圖 for (i = 0; i < N; i++) { int[] arr = {1,2,3}; shuffle(arr); // 此時 arr 已被打亂 count[arr] += 1; } for (int feq : count.values()) print(feq / N + " "); // 頻率
這種檢驗方案是可行的,不過可能有讀者會問,arr 的所有排列有 n! 種(n 爲 arr 的長度),若是 n 比較大,那豈不是空間複雜度爆炸了?
是的,不過做爲一種驗證方法,咱們不須要 n 太大,通常用長度爲 5 或 6 的 arr 試下就差很少了吧,由於咱們只想比較機率驗證一下正確性而已。
PS:我認真寫了 100 多篇原創,手把手刷 200 道力扣題目,所有發佈在 labuladong的算法小抄,持續更新。建議收藏,按照個人文章順序刷題,掌握各類算法套路後投再入題海就如魚得水了。
第二種思路,能夠這樣想,arr 數組中全都是 0,只有一個 1。咱們對 arr 進行 100 萬次打亂,記錄每一個索引位置出現 1 的次數,若是每一個索引出現的次數差很少,也能夠說明每種打亂結果的機率是相等的。
void shuffle(int[] arr); // 蒙特卡羅方法 int N = 1000000; int[] arr = {1,0,0,0,0}; int[] count = new int[arr.length]; for (int i = 0; i < N; i++) { shuffle(arr); // 打亂 arr for (int j = 0; j < arr.length; j++) if (arr[j] == 1) { count[j]++; break; } } for (int feq : count) print(feq / N + " "); // 頻率
這種思路也是可行的,並且避免了階乘級的空間複雜度,可是多了嵌套 for 循環,時間複雜度高一點。不過因爲咱們的測試數據量不會有多大,這些問題均可以忽略。
另外,細心的讀者可能發現一個問題,上述兩種思路聲明 arr 的位置不一樣,一個在 for 循環裏,一個在 for 循環以外。其實效果都是同樣的,由於咱們的算法總要打亂 arr,因此 arr 的順序並不重要,只要元素不變就行。
本文第一部分介紹了洗牌算法(隨機亂置算法),經過一個簡單的分析技巧證實了該算法的四種正確形式,而且分析了一種常見的錯誤寫法,相信你必定可以寫出正確的洗牌算法了。
第二部分寫了洗牌算法正確性的衡量標準,即每種隨機結果出現的機率必須相等。若是咱們不用嚴格的數學證實,能夠經過蒙特卡羅方法大力出奇跡,粗略驗證算法的正確性。蒙特卡羅方法也有不一樣的思路,不過要求沒必要太嚴格,由於咱們只是尋求一個簡單的驗證。