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

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

遊戲開始的時候須要隨機佈雷。掃雷的高級是 16 × 30 的網格,一共有 99 個雷。若是從 0 開始給全部網格作標記,那麼佈雷的問題就成了從 480 個數中隨機選取 99 個數。 第一反應天然是記錄已選項算法

import random

mines = set()
for i in range(99):
    j = random.randint(0, 480)
    while j in mines:
        j = random.randint(0, 480)
    mines.add(j)
print(mines)
複製代碼

不過這算法看着彷佛有點 low 啊。dom

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

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

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

洗牌算法的 Python 實現以下:cdn

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 會是用的洗牌算法嗎?blog

翻看 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 * 3, 4)) # 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」 記錄已選項算法。

這是怎麼回事?爲了弄明白其中的道理,我去搜了不少文章查看,最有價值的是下面這篇:blog.csdn.net/harry_128/a…

隨機取樣有兩種實現方式,一是隨機抽取且不放回,就是洗牌算法;二是隨機抽取且放回,就是我想到的記錄已選項算法。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),這時就選用洗牌算法。

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


掃碼關注個人公衆號:大齡碼農的Python之路

相關文章
相關標籤/搜索