LeetCode專題——詳解搜索演算法中的搜索策略和剪枝

  • 2020 年 3 月 15 日
  • 筆記

本文始發於個人公眾號:TechFlow,原創不易,求個關注

今天是LeetCode專題第20篇文章,今天討論的是數字組合問題。

描述

給定一個int類型的候選集,和一個int類型的target,要求返回所有的數字組合,使得組合內所有數字的和剛好等於target。

注意:

  1. 所有的元素都是正數
  2. 所有元素沒有重複
  3. 答案不能有重複
  4. 每一個元素可以使用若干次

樣例 1:

Input: candidates = [2,3,6,7], target = 7,  A solution set is:  [    [7],    [2,2,3]  ]  

樣例 2:

Input: candidates = [2,3,5], target = 8,  A solution set is:  [     [2,2,2,2],    [2,3,3],    [3,5]  ]  

題解

我們拿到這道題還是按照老規矩來思考暴力的解法,但是仔細一想會發現好像沒有頭緒,沒有頭緒的原因也很簡單,因為題目當中的一個條件:一個元素可以隨意使用若干次

我們根本不知道一個元素可以使用多少次,這讓我們的暴力枚舉有一種無從下手的感覺。如果去掉這個條件就方便多了,因為每個元素只剩下了兩個狀態,要麼拿要麼不拿,我們可以用一個二進位的數來表示。這就引出了一個常用的表示狀態的方法——二進位表示法

二進位表示法

舉個例子,假如當下我們有3個數字,這3個數字都有兩個狀態選或者不選,我們想要枚舉這3個數字的所有狀態,應該怎麼辦?

我們當然可以用遞歸來實現,在每層遞歸當中做決策當前元素選或者不選,分別遞歸。但是可以不用這麼麻煩,我們可以用二進位簡化這個過程。這個原理非常簡單,我們都知道在電腦二進位當中每一個二進位位只有兩個狀態0或者1,那麼我們就用1表示拿,0表示不拿,那麼這三個數拿或者不拿的狀態其實就對應一個二進位的數字了。3位二進位,對應的數字是0到7,也就是說我們只需要遍歷0到7,就可以獲得這3位所有拿和不拿的狀態了。

比如說我們當下遍歷到的數字是5,5的二進位表示是101,我們再把1和0對應拿和不拿兩種狀態。那麼5就可以對應上第一和第三個拿,第二個不拿的狀態了。我們可以用位運算很方便地進行計算。比如我們要判斷第i位是否拿了,我們可以用(1 << i),<<的意思是左移,左移一位相當於乘2,左移n位就相當於乘上了2的n次方。1對應右邊起第0位,也就是最低位的二進位位,我們對它做左移i的操作就相當於乘上了,那麼就得到了第i位了。我們拿到了之後,只需要將它和狀態state做一個二進位中的與運算,就可以得到state中第i位究竟是0還是1了。

因為在二進位當中,and運算會將兩個數的每一位做與運算,運算的結果也是一個二進位數。由於我們用來進行與運算的數是(1 << i),它只有第i位為1,所以其他位進行與運算的結果必然是0,那麼它和state進行與運算之後,如果結果大於0,則說明state的第i位也是1,否則則是0。這樣我們就獲取了state當中第i位的狀態。

由於位運算是指令集的運算,在沒有指令集優化的一些語言當中,它的計算要比加減乘除更快。除了快以外它最大的好處是節省空間和計算方便,這兩個優點其實是一體的,我們一個一個來說。

首先來說節省空間,有了二進位表示之後,我們可以用一個32位的int來代表32個物體的0和1的所有狀態。如果我們用數組來存儲的話,顯然我們需要一個長度為32的數組,需要的空間要大得多。這一點在單個狀態下並不明顯,一旦數據量很大會非常顯著。尤其是在密集的IO當中,數據越輕量則傳輸效率越高

第二個優點是計算方便,計算方便的原因也很簡單,假如我們要遍歷所有的狀態,如果用數組或者其他數據結構的話免不了使用遞歸來遍歷,這樣會非常麻煩。而使用二進位之後就方便了,由於我們用二進位表示了所有元素0和1的狀態,我們只需要在一個整數範圍做循環就可以了。就像剛才例子當中,我們要枚舉3個元素的狀態,我們只需要從0遍歷到7即可。如果在多點元素也沒問題,如果是N個元素,我們只需要從0遍歷到(1 << N) – 1。

但是還有一個問題沒解決,你可能會說如果我們用int來表示狀態的話,最多只能表示32個物品的狀態,如果更多怎麼辦?一個方法是使用int64,即範圍更大的int,如果範圍更大的int還是解決不了問題也沒關係,還有一些基於同樣原理實現的第三方包可以支援。但是老實說我們基本上不會碰到超過64個物品讓我們枚舉所有狀態的情況,因為這個數字已經非常大了,幾乎可以說是天荒地老也算不完。

回到問題

我相信關於二進位表示法的使用和原理,大家應該都了解了,但是本題當中元素是可以多次出現的,二進位表示法看起來並不頂用,我們怎麼解決這個問題呢?難道這麼大的篇幅就白寫了?

當然不會白寫,針對這種情況也有辦法。其實很簡單,因為題目當中規定所有的元素都是正數,那麼對於每一個元素而言,我們最多取的數量是有限的。舉個例子,比如樣例當中[2, 3, 6, 7] target是7,對於元素2而言,target是7,即使可以多次使用,也最多能用上3個2。那麼我們可以拓充候選集,將1個2拓充成3個,同理,我們可以繼續拓充3,最後候選集變成這樣:[2, 2, 2, 3, 3, 6, 7],這樣我們就可以使用二進位表示法了。

但是顯然這個方法不靠譜,因為如果數據當中出現一個1,並且target稍微大一些,那肯定直接gg,顯然會複雜度爆炸。所以這個方法只是理論上可行,但是實際上並不具有可操作性,我之所以拿出來介紹,純粹是為了引出二進位表示法。

搜索解決一切

當一個問題明顯有很多種情況需要遍歷,但是我們又很難直接遍歷的時候,往往都是搜索問題,我們可以思考一下能否用搜索問題的方法來解決。

這題其實已經非常明顯了,搜索的條件已經有了,搜索的空間也明白了,剩下的就是制定搜索策略

我個人認為搜索策略其實就是搜索的順序和範圍,合適的搜索順序以及範圍可以大大降低編碼和計算的複雜度,再穿插合適的剪枝,就可以非常漂亮地完成一道搜索問題。

我們帶著思考來看這道題,如果我們用回溯法來寫這道題的話,程式碼其實並不複雜。很容易就可以寫出來:

def dfs(x, sequence, candidates, target):
if x == target:
ans.append(sequence)
return

for i in candidates:
if x + i > target:
continue
sequence.append(i)
dfs(x+i, sequence, candidates, target)
sequence.pop()

你看只有幾行,我們每次遍歷一個數加在當前的總和x上然後往下遞歸,並且我們還加上了對當前和判斷的剪枝。如果當前和已經超過了target,那麼顯然已經不可能構成正解了,我們直接跳過。

但是我們也都發現了,在上面這段程式碼里,我們搜索的區間就是所有的候選值,我們沒有對這些候選值進行任何的限制。這其實隱藏了一個很大的問題,還記得題目的要求當中有一條嗎,答案不能有重複。也就是說相同元素的不同順序會被認為是同一個解,我們需要去重。舉個例子,[3, 2, 2]和[2, 2, 3]會被認為是重複的,但是在上面的搜索策略當中,我們沒有對這個情況做任何的控制,這就導致了我們在找到所有答案之後還需要進行去重的工作。先找到包含重複的答案,再進行去重,這顯然會消耗大量計算資源,所以這個搜索策略雖然簡單,但遠遠不是最好的。

我們先來分析一下問題,究竟什麼時候會出現重複呢?

我想大家列舉一下應該都能發現,就是當我們順序錯亂的時候。比如說我們有兩個數3和4,我們先選擇3再選擇4和先選擇4再選擇3是一樣的。如果我們不對3和4的選擇做任何限制,那麼就會出現重複。換句話說如果我們對3和4的選擇確定順序就可以避免重複,如果從一開始就不會出現重複,那麼我們也就沒有必要去重了,這就可以節省下大量的時間。

所以我們要做的就是確定搜索的時候元素選擇的順序,在搜索的時候進行一定的限制,從而避免重複。落實在程式碼當中就體現在我們枚舉候選集的時候,我們之前沒有做任何限制,我們現在需要人為加上限制,我們只能選擇之前選過的元素後面的,只能往後拿不能往前拿。所以我們需要在dfs當中傳入一個下標,標記之前拿過的最大的下標,我們只能拿這個下標之後的,這樣搜索就有了順序,就避免了元素重複和複雜度過高的問題

這一點確定了之後,剩下的程式碼就很簡單了。

class Solution:
def dfs(self, ret, pos, sequence, now, target, candidates):
if now == target:
# 加入答案,需要.copy()一下
ret.append(sequence.copy())
return

for i in range(pos, len(candidates)):
# 如果過大則不遞歸
if now + candidates[i] > target:
continue
# 存入sequence,往下遞歸
sequence.append(candidates[i])
self.dfs(ret, i, sequence, now+candidates[i], target, candidates)
sequence.pop()

def combinationSum(self, candidates: List[int], target: int) -> List[List[int]]:
ret = []
self.dfs(ret, 0, [], 0, target, candidates)
return ret

從程式碼上來看,我們並沒有做太大的改動,所有的細節幾乎都體現在搜索和遍歷時的邊界以及控制條件上。和整個演算法以及程式碼邏輯比起來,這些是最無關緊要的,但是對於解決問題來說,這些才是實實在在的。

題目變形

今天的題目有一個變種,它就是LeetCode的第40題,大部分題意都一樣,只有兩個條件發生了變化。第一是40題當中去掉了候選集當中的元素沒有重複的限制,第二點是不再允許元素重複使用。其他的內容都和這題保持一致。

我們想一下就會發現,如果我們去掉重複使用的條件,好像沒什麼變化,我們是不是只要將遞歸遍歷的條件稍稍改動就好了呢?之前我們是從pos位置開始化後遍歷,現在由於不能重複,所以之前取過的pos不能再取,我們是不是只要將for循環改成從pos+1開始就行了?

如果候選集的元素中沒有重複,這當然是可行的。但是很遺憾,這個條件也被去掉了。所以候選集當中本身就可能出現重複,如果還按照剛才的思路會出現重複的答案。

原因也很簡單,舉個例子,比如說候選集是[1, 2, 3, 2, 2],target是5,如果還用剛才的方法搜索的話,我們的答案當中會出現兩個[2, 3]。雖然我們也是每個元素都只用了一次,但是仍然違背了答案不能重複的限制。

你可能會有很多想法,比如可以手動去重,比如我們可以在元素數量上做手腳,將重複的元素去重。很遺憾的是,兩者都不是最優解。第一種當然是可行的,找到所有可行解再去重,是一個很樸素的思路。通過優化,可以解決複雜度問題。第二種想法並不可行,因為如果我們把重複的元素去掉,可能會導致某些解丟失。比如[1, 2, 2],也是和等於5,但是如果我們把重複的2去掉了,那麼就無法得到這個解了。

要解決問題,我們還是要回到搜索策略上來。手動篩選、加工數據只是逼不得已的時候用的奇淫技巧,搜索策略才是解題的核心

我們整理一下思路,可以歸納出當前需要我們解決的問題有兩個,第一個是我們要找到所有解,意味著我們不能刪減元素,第二個是我們想要搜索的結果沒有重複。這看起來是矛盾的,我們既想要不出現重複,又想重複的元素可以出現,這可能嗎?

如果你仔細思考分析了,你會發現是可能的。不過從搜索策略的角度上來說,比較難想到。首先我們要保證元素的聚集性,也就是說相同的元素應該聚集在一起。要做到這點很簡單,我們只需要排序就行了。這麼做的原因也不難想到,就是為了避免重複。如果數據是分散的,我們是很難去重的,還用剛才的例子,當我們從2開始遞歸的時候,我們可以找到解[2, 3],當我們從3開始遞歸的時候,我們仍然可以找到解[3, 2],這兩者是一樣的。雖然我們限制了遍歷的順序嚴格地從前到後,但是由於元素分散會使得我們的限制失去作用。為了限制依舊有效,我們需要排序,讓相同的元素聚集,這樣我們每次搜索的內容其實是由大於等於當前元素的數字組成的答案,這就保證了不在重複。

但是這並沒有解決所有的問題,我們再來看一個例子,候選集是[2, 2, 2, 3, 4],target是7,顯然[2, 2, 3]是答案,但是我們怎麼保證[2, 2, 3]只出現一次呢?因為我們有3個2,但是要選出兩個2來,我們需要一個機制,使得只會找到一個答案。這點通過策略已經無能為力了,只能依靠剪枝。我們當然可以引入額外的數據結構解決問題,但會比較麻煩,而我們其實有更簡單的做法。

這個做法是一個非常精妙的剪枝,我們在遞歸當中加入一個判斷:當i > pos+1 and candidates[i] == candidates[i-1]的時候,則跳過。其中pos是上次選擇的位置,在遞歸的起始時,帶入的是-1,我想這個條件應該大家都能看明白,但是它為什麼有效可能會一頭霧水,翻譯成大白話,這個條件其實是在限制一點:在有多個相同元素出現的時候,必須選擇下標小的,也就是前面的。

我們分析一下可能觸發continue的條件,只有兩種情況,第一種:

其中pos是上次選擇的數字,我們假設它是1,我們當前的位置在pos+3。從上圖可以看出來,pos+1到pos+3全都相等。如果我們想要選擇pos+3而跳過pos+1和pos+2則會進入continue會跳過。原因也很簡單,因為前面遞歸的過程當中已經選過pos和pos+1的組合了,我們如果選了pos和pos+3的組合一定會構成重複。也就是說我們保證了在連續出現的元素當中,如果要枚舉的話,必須要從第一個開始。

另一種情況也類似:

也就是說從pos到pos+3都是2,都相等,這個時候我們跳過pos+1和pos+2直接選擇pos+3也會進入continue,原因也很簡單,我們現在枚舉的是獲取兩個2的情況,在之前的遞歸當中已經沒舉過pos和pos+1了,我們現在想要跳過pos+1和pos+2直接獲取pos+3,對應的情況是一樣的,所以需要跳過。

我們將排序和上述的剪枝方法一起使用就解出了本題,仔細觀察一下會發現這兩個方法根本是相輔相成,天作之合,單獨使用哪一個也不管用,但是一起作用就可以非常簡單地解出題目。理解了這兩點之後,程式碼就變得很簡單了:

class Solution:

def dfs(self, ret, sequence, now, pos, target, candidates):
if now == target:
ret.append(sequence.copy())
return

for i in range(pos+1, len(candidates)):
cur = now + candidates[i]
# 剪枝
# 如果多個相同的元素,必須保證先去最前面的
if cur > target or (i > pos+1 and candidates[i] == candidates[i-1]):
continue
sequence.append(candidates[i])
self.dfs(ret, sequence, cur, i, target, candidates)
sequence.pop()

def combinationSum2(self, candidates: List[int], target: int) -> List[List[int]]:
# 排序,保證相同的元素放在一起
candidates = sorted(candidates)
ret = []
self.dfs(ret, [], 0, -1, target, candidates)
return ret

不知道大家有沒有從這個變種當中感受到搜索策略以及剪枝的威力和巧妙,我個人還蠻喜歡今天的題目的,如果能夠把今天的兩道題目吃透,我想大家對於深度優先搜索和回溯演算法的理解一定可以更上一個台階,這也是我將這兩個問題合在一起介紹的原因。在明天的LeetCode專題當中我們會來看LeetCode41題,查找第一個沒有出現的自然數。

今天的文章就到這裡,如果覺得有所收穫,請順手點個關注吧,你們的舉手之勞對我來說很重要。

本文使用 mdnice 排版