本文始發於我的公衆號:TechFlow,原創不易,求個關注web
今天是LeetCode專題第20篇文章,今天討論的是數字組合問題。算法
給定一個int類型的候選集,和一個int類型的target,要求返回全部的數字組合,使得組合內全部數字的和恰好等於target。數組
注意:數據結構
樣例 1:app
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個數字的全部狀態,應該怎麼辦?spa
咱們固然能夠用遞歸來實現,在每層遞歸當中作決策當前元素選或者不選,分別遞歸。可是能夠不用這麼麻煩,咱們能夠用二進制簡化這個過程。這個原理很是簡單,咱們都知道在計算機二進制當中每個二進制位只有兩個狀態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 排版