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


對於算法書買了一本又一本卻沒一本讀完超過 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),這時就選用洗牌算法。

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


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM