洗牌演算法及 random 中 shuffle 方法和 sample 方法淺析

丹楓無跡發表於2019-06-18

對於演算法書買了一本又一本卻沒一本讀完超過 10%,Leetcode 刷題從來沒堅持超過 3 天的我來說,演算法能力真的是渣渣。但是,今天決定寫一篇跟演算法有關的文章。起因是讀了吳師兄的文章《掃雷與演算法:如何隨機化的佈雷(二)之洗牌演算法》。因為掃雷這個遊戲我是寫過的,具體見:《Python:遊戲:掃雷》

遊戲開始的時候需要隨機佈雷。掃雷的高階是 16 × 30 的網格,一共有 99 個雷。如果從 0 開始給所有網格做標記,那麼佈雷的問題就成了從 480 個數中隨機選取 99 個數。
第一反應自然是記錄已選項

import random

mines = set()
for i in range(99):
    j = random.randint(0480)
    while j in mines:
        j = random.randint(0480)
    mines.add(j)
print(mines)

不過這演算法看著似乎有點 low 啊。

其實從 480 個數中隨機抽取 99 個數,那麼只要將這 480 個數打亂,取前 99 個數就好了。這就引出了:高納德置亂演算法(洗牌演算法)

這個演算法很牛逼卻很好理解,通俗的解釋就是:將最後一個數和前面任意 n-1 個數中的一個數進行交換,然後倒數第二個數和前面任意 n-2 個數中的一個數進行交換……以此類推。

這個原理很好理解,通俗得不能再通俗,稍微想一下就會明白,確實如此。

洗牌演算法的 Python 實現如下:

import random

lst = list(range(10))
for i in reversed(range(len(lst))):
    j = random.randint(0, i)
    lst[i], lst[j] = lst[j], lst[i]
print(lst)

看了吳師兄的文章,我立馬去翻了我的掃雷程式碼,我覺得,我一定是用的那個很 “low” 的演算法。翻出程式碼一看,我用的是 Python 提供了隨機取樣演算法:random.sample,感嘆 python 的強大,這都有。然後我就想到了,隨機打亂一個序列,random.shuffle 不就是幹這事的嗎?那麼 random.shuffle 會是用的洗牌演算法嗎?

翻看 random.shuffle 的原始碼,發現正是洗牌演算法。

def shuffle(self, x, random=None):
    if random is None:
        randbelow = self._randbelow
        for i in reversed(range(1, len(x))):
            j = randbelow(i + 1)
            x[i], x[j] = x[j], x[i]
    else:
        _int = int
        for i in reversed(range(1, len(x))):
            j = _int(random() * (i + 1))
            x[i], x[j] = x[j], x[i]

一切都是如此的自然而美好,然後我又去瞄了一眼 random.sample 的原始碼,然後就一頭霧水了。我截了部分原始碼:

n = len(population)
result = [None] * k
setsize = 21        # size of a small set minus size of an empty list
if k > 5:
    setsize += 4 ** _ceil(_log(k * 34)) # table size for big sets
if n <= setsize:
    # An n-length list is smaller than a k-length set
    pool = list(population)
    for i in range(k):         # invariant:  non-selected at [0,n-i)
        j = randbelow(n-i)
        result[i] = pool[j]
        pool[j] = pool[n-i-1]   # move non-selected item into vacancy
else:
    selected = set()
    selected_add = selected.add
    for i in range(k):
        j = randbelow(n)
        while j in selected:
            j = randbelow(n)
        selected_add(j)
        result[i] = population[j]
return result

setsize 變數雖然看得一頭霧水,但是下面的 ifelse 部分還是能看懂的。if 裡是洗牌演算法,而 else 裡是那個卻是我看著很 “low” 記錄已選項演算法。

這是怎麼回事?為了弄明白其中的道理,我去搜了很多文章檢視,最有價值的是下面這篇:https://blog.csdn.net/harry_128/article/details/81011739

隨機取樣有兩種實現方式,一是隨機抽取且不放回,就是洗牌演算法;二是隨機抽取且放回,就是我想到的記錄已選項演算法。random.sample 根據條件選擇其中之一執行。那麼就是說,洗牌演算法和記錄已選項演算法之間是各有優劣的。這讓我有點驚訝,不明擺著洗牌演算法更優嗎?

首先,這個抽樣演算法肯定不能改變原序列的順序,而洗牌演算法是會改變序列順序的,所以只能使用序列的副本,程式碼中也是這麼做的 pool = list(population) 建立副本,而記錄已選項演算法是不會改變原序列順序的,所以無需建立副本。建立副本也需要消耗時間和空間,演算法自然也是要把這考慮進去的。當需要取的樣本數量 K 相較於樣本總體數量 N 較小時,隨機取到重複值的概率也就相對較小。

sample 是依據什麼來判斷應該用哪個演算法的呢?原始碼中的判斷基於 setsize 變數,其中還有一段讓人看不懂的公式。其實這是在計算 set 所需的記憶體開銷,演算法的實現主要考慮的是額外使用的記憶體,如果 list 拷貝原序列記憶體佔用少,那麼用洗牌演算法;如果 set 佔用記憶體少,那麼使用記錄已選項演算法。

What?居然是根據額外佔用記憶體多少來判斷?這有點太不可思議了。Why?

我們來看一下演算法的時間複雜度。對於演算法很渣渣的小夥伴(例如我)來說,計算演算法的時間複雜度也是件挺困難的事,為了簡單起見,我用一種簡單的方式來說明。

先說洗牌演算法,時間複雜度是 O(K),這個比較好理解。那麼,對於記錄已選項演算法,時間複雜度是 O(NlogN)。這個別問我是怎麼算出來的,我沒算,抄的。有興趣的小夥伴可以自行去計算一下。

我們來想一個簡單的,對於記錄已選項演算法,如果每次選取的值恰好都沒有重複,那麼時間複雜度是多少呢?很顯然是 O(K)。那麼當 K 遠小於 N 的時候,我們可以認為時間複雜度就是 O(K)。

sample 演算法的思想就是,當 K 較 N 相對較小時,兩種演算法的時間複雜度都是 O(K),則選用佔用記憶體較小的;當 K 較 N 相對較接近時,記錄已選項演算法的時間複雜度就會高於 O(K),這時就選用洗牌演算法。

只得感嘆演算法真的博大精深。

相關文章