全體起立!給我講講洗牌算法和它的應用場景吧!

點擊上方“五分鐘學算法”,選擇“星標”公衆號

重磅乾貨,第一時間送達


有一次參加面試,面試官問我:“會玩牌吧?

內心:“咋滴,這是要玩德州撲克(或者炸金花),贏了他就能通過面試麼?”

結果……

沒想到面試官的下一句話:“給我講講洗牌算法以及它的應用場景吧!”

哈哈,以上內容純屬虛構

背景

本文產生背景是看到了 一枝花算不算浪漫 同學的這篇 Eureka註冊中心集羣如何實現客戶端請求負載及故障轉移?文章想到的。其實本人覺得那篇文中提到的負責均衡的重點就是本文要說的洗牌算法。

好了,回到題目上來。

這確實也是一道面試題,我曾經多次面試中都有遇到這個題目或者這個題目的變種。

你不妨花 1 秒,想想?

什麼是洗牌算法

從名字上來看,就是給你一副牌讓你洗唄,用怎樣的方法才能洗得均勻呢?請大佬表演一下。

不好意思,翻車了

其實洗牌算法就是一種隨機算法,你在鬥地主的時候,隨機把牌的順序打亂就行。一個足夠好的洗牌算法最終結果應該是可以讓牌的順序足夠隨機。好像有點繞~

這麼來說吧,一副牌大家鬥地主的話用 54 張(不考慮你們打配配牌的情形哈),那麼這 54 張牌的順序的話,按照排列組合算法,應該是有 54! 這麼多種,然後你的洗牌算法就是從這 54! 種排列中,隨機選 1 種。

無聊的石頭算了一下,54 的階乘有多大呢?大概就是這麼大一長串數字,2308436973392413804720927448683027581083278564571807941132288000000000000L,準確答案看下圖:

54的階乘計算結果

我們還是以 4 張牌作爲例子吧。

4 張牌,JQKA,所有的排列有 4!=4*3*2*1=24 種,分別如下:

('J', 'Q', 'K', 'A')
('J', 'Q', 'A', 'K')
('J', 'K', 'Q', 'A')
('J', 'K', 'A', 'Q')
('J', 'A', 'Q', 'K')
('J', 'A', 'K', 'Q')
('Q', 'J', 'K', 'A')
('Q', 'J', 'A', 'K')
('Q', 'K', 'J', 'A')
('Q', 'K', 'A', 'J')
('Q', 'A', 'J', 'K')
('Q', 'A', 'K', 'J')
('K', 'J', 'Q', 'A')
('K', 'J', 'A', 'Q')
('K', 'Q', 'J', 'A')
('K', 'Q', 'A', 'J')
('K', 'A', 'J', 'Q')
('K', 'A', 'Q', 'J')
('A', 'J', 'Q', 'K')
('A', 'J', 'K', 'Q')
('A', 'Q', 'J', 'K')
('A', 'Q', 'K', 'J')
('A', 'K', 'J', 'Q')
('A', 'K', 'Q', 'J')

那麼,一個均勻的洗牌算法,就是每次洗牌完後,獲得上面每種順序的概率是相等的,都等於1/24。感覺已經出來了一種算法了,那就是先像前文所述把所有的排列情況都枚舉出來,分別標上號 1-24 號,然後從 24 中隨機取一個數字(先不考慮如何能做到隨機取了,這個話題好像也沒那麼容易),獲取其中這個數字對應的號的排列。

這個算法複雜度是多少?假設爲 N 張牌的話,應該就是 1/N!(注意是階乘,這裏可不是感嘆號),顯然複雜度太高了。

有沒有更好的方法呢?答案當然是肯定的。

經典的洗牌算法

洗牌算法實際上是一個很經典的算法,在經典書籍《算法導論》裏面很靠前的部分就有講解和分析。

我們把這個洗牌過程用更加“程序員”的語言描述一下,就是假設有一個 n 個元素的數組 Array[n],通過某種方式,隨機產生一個另外一個序列Array'[n]讓數組的每個元素 Array[i] 在數組中的每個位置出現的概率都是1/n

其實方法可以這樣,依次從 Array 中隨機選擇 1 個,依次放到 Array' 中即可。證明一下:

  • Array[0],在新數組的第 0 個位置處的概率爲:1/n,因爲隨機選,只能是1/n的概率能選中;

  • Array[1],在新數組的第 1 個位置處的概率爲:1/n,因爲 第一次沒選中 Array[1]的概率爲 n-1/n,再結合第二次(只剩下n-1個了,所以分母爲n-1)選中的概率爲:1/n-1,因此概率爲: 。

  • 依此類推,可以證明前述問題。

其實,我們也可以不用另外找個數組來存結果,Array'也可以理解爲還是前面的這個 Array,只不過裏面元素的順序變化了。

這其實可以理解爲一個 “排序”(其實是亂序) 過程,算法如下:

void shuffle(List list) {
  int n = list.size();
  for (int i = 0; i < n; i++) {
    int j = random(i, n); // 隨機產生 [i, n) 中的一個數,每個概率一致
    // list 中第 i 個元素和 第 j 個元素互換位置 
    swap(list[i], list[j]);
  }
}

接下來是如何證明呢?不能你說隨機就隨機吧,你說等概率就等概率吧。下面還是跟着石頭哥一起來看看如何證明吧(這也是面試中的考察點)。

我們假設經過排序後,某個元素 Array[x] 恰好排在位置 x 處的概率爲 , 則該元素恰好排在第 x 處的概率是前 x-1 次時都沒有被隨機到,並且第 x 次時,恰好 random(x, n) = x了。

還是在循環中列舉幾項,更好理解一些(寫完,才發現跟前面的解釋差不多):

  • i = 0random(0, n) 沒有返回 x,共 n 個數,肯定返回了其他 n-1 箇中的一個,因此概率爲

  • i = 1ramdom(1, n) 沒有返回 x,共 n - 1 個數,肯定返回了其他 n-2 箇中的一個,即該爲

  • 依此類推……

  • i = x-1random(x-1, n) 沒有返回 x,共 n - (x-1) 個數,肯定返回了其他n-(x-1)-1 箇中的一個,即爲

  • i = xrandom(x, n) 恰好返回了 x,共 n-x 個數,概率爲

因此,到這算是簡單證明了任何元素出現在任何位置的概率是相等的。

注意說明一下,這是理論上的值,概率類的問題在量不大的情況下很有可能有隨機性的。就像翻硬幣,正反面理論上的值都是一半一半的,但你不能說一定是正反面按照次序輪着來。

看看 JDK 中的實現

我們還是來看看 JDK 中的實現。JDK 中 Collections 中有如下的實現方法 shuffle

public static void shuffle(List<?> list, Random rnd) {
    int size = list.size();
    // 石頭備註:本機特定版本中的常量 SHUFFLE_THRESHOLD=5
    if (size < SHUFFLE_THRESHOLD || list instanceof RandomAccess) {
        for (int i=size; i>1; i--)
            swap(list, i-1, rnd.nextInt(i));
    } else {
        Object arr[] = list.toArray();
        // Shuffle array
        for (int i=size; i>1; i--)
            swap(arr, i-1, rnd.nextInt(i));
        ListIterator it = list.listIterator();
        for (int i=0; i<arr.length; i++) {
            it.next();
            it.set(arr[i]);
        }
    }
}

基本上能看懂大概,不過是不是看看源碼還是能獲得新技能的。

上面條件分支大概分兩類:

  • 如果是數組類型,就是可以 O(1)隨機訪問的List;或者傳入的 list 小於 SHUFFLE_THRESHOLD

  • 否則的話不能隨機訪問的鏈表類型,則花 O(n) 轉成數組,再 shuffle,最後又回滾回鏈表。轉成數組的目的很簡單,可以快速定位某個下標的元素。

第一步的這個 SHUFFLE_THRESHOLD 其實就是一個經驗調優值,即便假設不能通過快速下標定位某個元素(即需要遍歷的方式定位),當輸入的 size 比較小的時候,和先花 O(n)轉成數組最後又轉回成鏈表 相比,也能有更快的速度。

另外多說一句,其實這種參數化調優方式在各種語言實現的時候很常見的,比如你去看排序算法的實現中,比如 Java 中 Arrays.sort 就是用的 DualPivotQuicksort(源碼在java.util.DualPivotQuicksort中),裏面實現邏輯中,當數組大小較小時也是用的其他如  的插入排序,如下圖所示。

洗牌算法的應用

肝到 凌晨 2 點了,明天繼續寫吧....

第二天繼續肝

回到本篇標題說的應用場景上來,比如開篇提到的 Eureka 註冊中心的 Client 就是通過把server 的 IPList 打亂順序,然後挨個取來實現理論上的均勻的負載均衡。

代碼(在 github: Netflix/eureka 中,公衆號就不單獨貼出來了)在這裏com.netflix.discovery.shared.resolver.ResolverUtils。看代碼如下,是不是跟前文的算法差不多?(具體寫法不一樣而已)

public static <T extends EurekaEndpoint> List<T> randomize(List<T> list) {
    List<T> randomList = new ArrayList<>(list);
    if (randomList.size() < 2) {
        return randomList;
    }
    Random random = new Random(LOCAL_IPV4_ADDRESS.hashCode());
    int last = randomList.size() - 1;
    for (int i = 0; i < last; i++) {
        int pos = random.nextInt(randomList.size() - i);
        if (pos != i) {
            Collections.swap(randomList, i, pos);
        }
    }
    return randomList;
}

其實,在任何需要打亂順序的場景裏面都可以用這個算法,舉個例子,播放器一般都有隨機播放的功能,比如你自己有個歌單 list,但想隨機播放裏面的歌曲,就也可以用這個方法來實現。

還有,就比如名字中的“洗牌”,那些棋牌類的遊戲,當然會用到名副其實的“洗牌”算法了。其實在各種遊戲的隨機場景中應該都可以用這個算法的。

擴展一下,留道作業題

跟這個問題類似的,還有一些常見的面試題,本人之前印象中也被問到過(石頭特地去翻了翻當年校招等找工作的時候收集和積累的面試題集)。

以下題目來源於網絡,因爲是當初準備面試時候收集的,具體來源不詳了。

動動腦筋,思考一下

題目 1

給你一個文本文件,設計一個算法隨機從文本文件中抽取一行,要保證每行被抽取到的概率一樣。

最簡單的思路其實就是:先把文件每一行讀取出來,假設有 n 行,這個時候隨機從 1-n生成一個數,讀取對應的行即可。

這種方法當然可以解決,咱們加深一下難度,假設文件很大很大很大呢,或者直接要求只能遍歷該文件內容一遍,怎麼做到呢?

題目 2

其實題目 1 還可以擴展一下,不是選擇 1 行了,是選擇 k 行,又應該怎麼做呢?

好,文章結束了。本人才疏學淺,如果有不對的地方,還望大家指出。

歡迎大家留言討論文末的兩個小問題的解決思路和方法。


推薦閱讀

•   吳師兄實名吐槽 LeetCode 上的一道題目。。。•   面試字節跳動時,我竟然遇到了原題……•   Leetcode 驚現馬化騰每天刷題 ?爲啥大佬都這麼努力!•   爲什麼 MySQL 使用 B+ 樹•   一道簡簡單單的字節跳動算法面試題•   新手使用 GitHub 必備的兩個神器•   臥槽!紅警代碼竟然開源了!!!


歡迎關注我的公衆號“五分鐘學算法”,如果喜歡,麻煩點一下“在看”~

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