對於演算法書買了一本又一本卻沒一本讀完超過 10%,Leetcode 刷題從來沒堅持超過 3 天的我來說,演算法能力真的是渣渣。但是,今天決定寫一篇跟演算法有關的文章。起因是讀了吳師兄的文章《掃雷與演算法:如何隨機化的佈雷(二)之洗牌演算法》。因為掃雷這個遊戲我是寫過的,具體見:《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 啊。
其實從 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 * 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
變數雖然看得一頭霧水,但是下面的 if
和 else
部分還是能看懂的。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),這時就選用洗牌演算法。
只得感嘆演算法真的博大精深。