Day22 第七章 回溯演算法part01

haohaoscnblogs發表於2024-08-07

目錄
  • 任務
    • 77. 組合
      • 思路
        • 遞迴思路
        • 剪枝
        • 為啥不能用迴圈
    • 216. 組合總和 III
      • 思路
    • 17. 電話號碼的字母組合
      • 思路
  • 心得體會

任務

77. 組合

給定兩個整數 n 和 k,返回範圍 [1, n] 中所有可能的 k 個數的組合。

思路

遞迴思路

對於組合問題,是在一個集合中取不同的數,構成各種組合,本質上是一個多叉樹的路徑問題,即遞迴序遍歷多叉樹並收集資訊,並且帶一些條件,比如本題是收集K個元素,即收集所有K個節點的路徑。把根節點當作虛擬頭節點(假設為第一層),每次取一個數,橫向看,每個節點就是每次選擇的數的所有可能,縱向看,某條路徑最終收集到K個就是其中的一個結果。

如圖所示就是一個從4個數中找所有兩個數的多叉樹示意圖(3層,未畫出的部分不可能向下尋找了,已經找夠了),終止條件就是路徑的長度達到要求K個。想要用回溯或者說遞迴去收集節點的值,需要思考,什麼驅動了遞迴的進行,這道題中,是透過start,即每次遞迴的開始索引來驅動的。如,第一次選1,在第二次選取中,就得從2開始選。同理,第一次選2,第二次選取的時候,就得從3開始選。邏輯就是利用遞迴序遍歷這顆多叉樹,每次到達最底層後,收集到結果中即可。注意返回上層時的回溯。

剪枝

此外,可以剪枝的操作來讓程式不用遍歷整棵樹,比如還是以上圖中n=4,k=2舉例,那麼第一層最後4這個節點就可以不遍歷,因為需要組成路徑的節點已經不夠了。如果是k=3,那麼這裡第一層的3,4兩棵子樹都不需要遍歷了,同樣因為需要組成路徑的剩餘節點已經不夠了。具體和推導過程如下:需要保證[x,n+1)的陣列長度起碼要滿足
n+1-x = k-len(self.path) => x 至多 == n+1-(k-len(self.path)) ,又由於開區間,為了取到這個值,再加1,具體看程式碼

為啥不能用迴圈

因為迴圈的層數是不定的,k個數的組合,就需要k層迴圈。因此想到用遞迴的深度控制這個k,路徑長度達到k後,或者說樹向下遍歷到k層後,就可以記錄其中一條結果,按照遞迴序依次遍歷,將所有k層的路徑全部記錄,就完成了題意所需的k個數的組合。

class Solution:
    def __init__ (self):
        self.path = []
        self.paths = []
    def combine(self, n: int, k: int) -> List[List[int]]:
        self.dfs(n,k,1)
        return self.paths
    def dfs(self,n,k,start):
        if len(self.path) == k: # 已經收集了k個數,到了統計結果的時候
            self.paths.append(self.path[:])
            return
        
        # for i in range(start,n+1):  #遞迴序收集節點值
        for i in range(start,n+2-(k-len(self.path))):  # [x,n+1)的陣列長度起碼要滿足 n+1-x = k-len(self.path) => x 至多 == n+1-(k-len(self.path)) ,又由於開區間,為了取到這個值,再加1
            self.path.append(i)
            self.dfs(n,k,i+1)
            self.path.pop()

216. 組合總和 III

找出所有相加之和為 n 的 k 個數的組合,且滿足下列條件:
只使用數字1到9
每個數字 最多使用一次
返回 所有可能的有效組合的列表 。該列表不能包含相同的組合兩次,組合可以以任何順序返回。

思路

這道題與上一題中的組合相比,就新增了一個條件,即和為n,那麼我們在寫終止條件的時候,只要加上這個條件,以及在遞迴中,修改和的值即可。
另外可以剪枝,橫向上就是減少迴圈的次數,縱向上,如果已經和大於n(因為節點值全是正數,向下找不可能在累加和為n的節點),則直接返回。

class Solution:
    def __init__(self):
        self.path = []
        self.paths = []
        self.sum = 0
    def combinationSum3(self, k: int, n: int) -> List[List[int]]:
        self.dfs(n,k,1)
        return self.paths
    def dfs(self,n,k,start):
        if self.sum > n: # 豎向剪枝
            return 
        if len(self.path) == k: 
            if self.sum == n:
                self.paths.append(self.path[:])
            return
        
        #for i in range(start,10):
        for i in range(start,11-(k-len(self.path))): #[x,10)的長度至少滿足 10-x = k-len(self.path) ,x 至多== 10-k-len(self.path) ,開區間的情況下,再加1 (橫向剪枝)
            self.path.append(i)
            self.sum += i
            self.dfs(n,k,i+1)
            self.sum -= i
            self.path.pop()

17. 電話號碼的字母組合

思路

首先,迴圈是不行的,因為你不知道由多少個數字(有一個迴圈1次,有n個迴圈n次,即迴圈次數補丁)
思考遞迴,如何構建這個問題的多叉樹,遞迴深度來控制數字個數,考慮用index(表示當前處理到的digits的元素的索引)來驅動遞迴函式。
比如"23",則第一次在abc中選一個,第二次在def中選一個。注意,縱向的遞迴序遍歷驅動是由index的增加驅動的,表示處理digits中的每個元素,橫向的是由每次的for迴圈驅動的,表示遍歷每個字串(如'abc','def')

class Solution:
    def __init__(self) -> None:
        self.path = ""
        self.paths = []
        
        self.digitMap = {2:'abc',3:'def',4:'ghi',5:'jkl',6:'mno',7:'pqrs',8:'tuv',9:'wxyz'}
    def letterCombinations(self, digits: str) -> List[str]:
        if not digits:
            return []
        self.dfs(digits,0)
        return self.paths
    
    def dfs(self,digits,index):
        if len(self.path) == len(digits):
            self.paths.append(self.path[:])
            return
        
        digit = int(digits[index])
        str = self.digitMap[digit] #digit代表的字元組成的字串
        for c in str:
            self.path += c
            self.dfs(digits,index+1)
            self.path = self.path[:-1]

心得體會

個人在回溯問題相關的內容中的思路與程式碼隨想錄有略微不同,我是將值放在節點上(隨想錄是放在邊上),然後按照多叉樹的遞迴序遍歷的邏輯去思考。這個思路是收到二叉樹章節左右子樹的遞迴序遍歷的啟發,感覺相對更好理解。
注意for迴圈的最外層(最上層的遞迴函式)就是樹的第二層,邏輯上它是由第二層開始的,或者說是由迴圈開始的,然後每個節點進行遞迴呼叫往下深入,這與二叉樹的從根開始由一點細微的區別,注意體會。

其次在本節中學習了剪枝的技術,以避免暴力遞迴造成的較高的時間成本。剪枝分為橫向剪枝和縱向剪枝。
橫向一般就是修改迴圈的size,避免遍歷不可能成為結果的子樹(比如組合中剩餘節點數量不滿足的那些),而縱向剪枝是根據條件,如果當前樹繼續遍歷下去是不可能得到結果的,則直接返回。(比如組合總和中,遍歷並累加到某節點的和已經大於sum,繼續向下遍歷沒有意義,則直接剪枝返回)

相關文章