隨機亂置算法詳解

我知道大家會各種花式排序算法,但是如果叫你打亂一個數組,你是否能做到胸有成竹?即便你拍腦袋想出一個算法,怎麼證明你的算法就是正確的呢?亂序算法不像排序算法,結果唯一可以很容易檢驗,因爲「亂」可以有很多種,你怎麼能證明你的算法是「真的亂」呢?

所以我們面臨兩個問題:

  1. 什麼叫做「真的亂」?
  2. 設計怎樣的算法來打亂數組才能做到「真的亂」?

這種算法稱爲「隨機亂置算法」或者「洗牌算法」。

本文分兩部分,第一部分詳解最常用的洗牌算法。因爲該算法的細節容易出錯,且存在好幾種變體,雖有細微差異但都是正確的,所以本文要介紹一種簡單的通用思想保證你寫出正確的洗牌算法。第二部分講解使用「蒙特卡羅方法」來檢驗我們的打亂結果是不是真的亂。蒙特卡羅方法的思想不難,但是實現方式也各有特點的。

PS:我認真寫了 100 多篇原創,手把手刷 200 道力扣題目,全部發布在 labuladong的算法小抄,持續更新。建議收藏,按照我的文章順序刷題,掌握各種算法套路後投再入題海就如魚得水了。

一、洗牌算法

此類算法都是靠隨機選取元素交換來獲取隨機性,直接看代碼(僞碼),該算法有 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! 種。算法必須能夠反映這個事實,纔是正確的。

我們先用這個準則分析一下第一種寫法的正確性:

// 假設傳入這樣一個 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 = 0rand 的取值範圍是 [0, 4],有 5 個可能的取值。

225d4ece427227e9fae5fdf85053166c.jpg

for 循環第二輪迭代時,i = 1rand 的取值範圍是 [1, 4],有 4 個可能的取值。

33010e6e7a26f7d2e553dbdfd0384cbb.jpg

後面以此類推,直到最後一次迭代,i = 4rand 的取值範圍是 [4, 4],只有 1 個可能的取值。

68637c4f3418cd09a5e42c7f8064c4e9.jpg

可以看到,整個過程產生的所有可能結果有 n! = 5! = 5*4*3*2*1 種,所以這個算法是正確的。

分析第二種寫法,前面的迭代都是一樣的,少了一次迭代而已。所以最後一次迭代時 i = 3rand 的取值範圍是 [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 整除,所以一定有某些情況被「偏袒」了,也就是說某些情況出現的概率會大一些,所以這種打亂結果不算「真的亂」。

上面我們從直覺上簡單解釋了洗牌算法正確的準則,沒有數學證明,我想大家也懶得證明。對於概率問題我們可以使用「蒙特卡羅方法」進行簡單驗證。

二、蒙特卡羅方法驗證正確性

洗牌算法,或者說隨機亂置算法的正確性衡量標準是:對於每種可能的結果出現的概率必須相等,也就是說要足夠隨機。

如果不用數學嚴格證明概率相等,可以用蒙特卡羅方法近似地估計出概率是否相等,結果是否足夠隨機。

記得高中有道數學題:往一個正方形裏面隨機打點,這個正方形裏緊貼着一個圓,告訴你打點的總數和落在圓裏的點的數量,讓你計算圓周率。

3b45ccd674f52fe1801847a3fbb37dbd.jpg

這其實就是利用了蒙特卡羅方法:當打的點足夠多的時候,點的數量就可以近似代表圖形的面積。通過面積公式,由正方形和圓的面積比值是可以很容易推出圓周率的。當然打的點越多,算出的圓周率越準確,充分體現了大力出奇跡的真理。

類似的,我們可以對同一個數組進行一百萬次洗牌,統計各種結果出現的次數,把頻率作爲概率,可以很容易看出洗牌算法是否正確。整體思想很簡單,不過實現起來也有些技巧的,下面簡單分析幾種實現思路。

第一種思路,我們把數組 arr 的所有排列組合都列舉出來,做成一個直方圖(假設 arr = {1,2,3}):

c4a0460cba761841bdd0e2817c741216.jpg

每次進行洗牌算法後,就把得到的打亂結果對應的頻數加一,重複進行 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 + " "); // 頻率

da713531ae68b62cabb518ab246cb82f.jpg

這種思路也是可行的,而且避免了階乘級的空間複雜度,但是多了嵌套 for 循環,時間複雜度高一點。不過由於我們的測試數據量不會有多大,這些問題都可以忽略。

另外,細心的讀者可能發現一個問題,上述兩種思路聲明 arr 的位置不同,一個在 for 循環裏,一個在 for 循環之外。其實效果都是一樣的,因爲我們的算法總要打亂 arr,所以 arr 的順序並不重要,只要元素不變就行。

三、最後總結

本文第一部分介紹了洗牌算法(隨機亂置算法),通過一個簡單的分析技巧證明了該算法的四種正確形式,並且分析了一種常見的錯誤寫法,相信你一定能夠寫出正確的洗牌算法了。

第二部分寫了洗牌算法正確性的衡量標準,即每種隨機結果出現的概率必須相等。如果我們不用嚴格的數學證明,可以通過蒙特卡羅方法大力出奇跡,粗略驗證算法的正確性。蒙特卡羅方法也有不同的思路,不過要求不必太嚴格,因爲我們只是尋求一個簡單的驗證。


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章