leetcode(7)回溯系列题目
回溯法也可以叫做回溯搜索法,它是一种搜索的方式。
回溯是递归的副产品,只要有递归就会有回溯。所以回溯函数也就是递归函数,指的都是一个函数
因为回溯的本质是穷举,穷举所有可能,然后选出我们想要的答案,如果想让回溯法高效一些,可以加一些剪枝的操作,但也改不了回溯法就是穷举的本质。
回溯法解决的问题都可以抽象为树形结构
回溯法一般是在集合中递归搜索,集合的大小构成了树的宽度,递归的深度构成的树的深度。
for循环可以理解是横向遍历,backtracking(递归)就是纵向遍历,这样就把这棵树全遍历完了。
- 搜索叶子节点就是找到组合或排列(需要所有元素)。
- 搜索所有节点就是找到子集(只需要部分元素)。
组合问题
(1)77. 组合
可以剪枝的地方就在递归中每一层的for循环所选择的起始位置。
如果for循环选择的起始位置之后的元素个数 已经不足 我们需要的元素个数了,那么就没有必要搜索了。
优化过程如下:
已经选择的元素个数:path.size();
还需要的元素个数为: k - path.size();
在集合n中至多可以从该起始位置 : n - (k - path.size()) + 1,开始遍历
为什么有个+1呢,因为包括起始位置和终止位置的n,是左闭右闭的区间。(python中是+2,因为右边是开区间)
举个例子,n = 5,k = 3, 目前已经选取的元素为0(path.size为0),n - (k - 0) + 1 即 5 - ( 3 - 0) + 1 = 3。
从3开始搜索都是合理的,可以是组合[3, 4, 5]。
res = [] #存放符合条件结果的集合
path = [] #用来存放符合条件结果
def backtrack(n, k, startIdx):
if len(path) == k:
res.append(path[:])
return
for i in range(startIdx, n - (k - len(path)) + 2): # 可选数字的个数少于k就剪枝
path.append(i) #处理节点
backtrack(n, k, i + 1) #递归
path.pop() #回溯,撤销处理的节点
backtrack(n, k, 1)
return res
(2)216. 组合总和 III
注意:与77. 组合 区别之一是本题集合固定的就是9个数[1,...,9],所以for循环固定i<=9
剪枝:
- 已选元素总和如果已经大于n(图中数值为4)了,那么往后遍历就没有意义了,直接剪掉。
- for循环的范围也可以剪枝,i <= 9 - (k - path.size()) + 1就可以了。
class Solution:
def combinationSum3(self, k: int, n: int) -> List[List[int]]:
res = []
path = []
def backtrack(k, n, sum_, startIndx):
if len(path) == k: # len(path)==k时不管sum是否等于n都会返回
if sum_ == n:
res.append(path[:])
return
for i in range(startIndx, 9 - (k - len(path)) + 2): # 可选数字的个数少于k就剪枝
sum_ += i
if sum_ > n: # 当前的和已经超出也剪枝
return
path.append(i)
backtrack(k, n, sum_, i + 1)
path.pop()
sum_ -= i
backtrack(k, n, 0, 1)
return res
(3)17. 电话号码的字母组合
注意:本题每一个数字代表的是不同集合,也就是求不同集合之间的组合,而77. 组合 和216.组合总和III 都是是求同一个集合中的组合!
参数指定是有题目中给的string digits,然后还要有一个参数就是int型的index。
注意这个index可不是 77.组合和216.组合总和III 中的startIndx了。
而是记录遍历第几个数字了,就是用来遍历digits的(题目中给出数字字符串),同时index也表示树的深度。
class Solution:
def __init__(self):
self.res = []
self.path = ''
self.letter_map = {
'2': 'abc',
'3': 'def',
'4': 'ghi',
'5': 'jkl',
'6': 'mno',
'7': 'pqrs',
'8': 'tuv',
'9': 'wxyz',
}
def letterCombinations(self, digits: str) -> List[str]:
self.res.clear()
if not digits:
return []
self.backtrack(digits, 0)
return self.res
def backtrack(self, digits, idx):
if idx == len(digits): # 当遍历穷尽后的下一层时
self.res.append(self.path)
return
# 单层递归逻辑
letters = self.letter_map[digits[idx]]
for letter in letters:
self.path += letter #处理节点
self.backtrack(digits, idx + 1) #递归
self.path = self.path[: -1] #回溯,撤销处理的节点
(4)39. 组合总和
注意:与 77.组合,216.组合总和III 的区别是:本题没有数量要求,可以无限重复,但是有总和的限制,所以间接的也是有个数的限制。
class Solution:
def combinationSum(self, candidates: List[int], target: int) -> List[List[int]]:
res = []
path = []
n = len(candidates)
def backtrack(candidates, target, sum_, startIndx):
if sum_ == target:
res.append(path[:])
return
# if sum_ > target:
# return
for i in range(startIndx, n):
sum_ += candidates[i]
if sum_ > target: # 剪枝
return
path.append(candidates[i])
backtrack(candidates, target, sum_, i) # 因为无限制重复选取,所以不是i+1
path.pop()
sum_ -= candidates[i]
candidates.sort() #剪枝需要先排序
backtrack(candidates, target, 0, 0)
return res
(5)40. 组合总和 II
和39.组合总和 如下区别:
- 本题candidates 中的每个数字在每个组合中只能使用一次。
- 本题数组candidates的元素是有重复的,而39.组合总和 是无重复元素的数组candidates
本题的难点在于区别2中:集合(数组candidates)有重复元素,但输出的结果却不能有重复的组合。
如果把所有组合求出来,再用set或者map去重,这么做很容易超时!
所以要在搜索的过程中就去掉重复组合。
class Solution:
def combinationSum2(self, candidates: List[int], target: int) -> List[List[int]]:
res = []
path = []
n = len(candidates)
def backtrack(candidates, target, sum_, startIndx):
if sum_ == target:
res.append(path[:])
return
for i in range(startIndx, n):
if i > startIndx and candidates[i] == candidates[i - 1]:
continue # 去重
sum_ += candidates[i]
if sum_ > target:
return
path.append(candidates[i])
# 每个数字在每个组合中只能使用 一次 ,所以是i + 1
backtrack(candidates, target, sum_, i + 1)
path.pop()
sum_ -= candidates[i]
candidates.sort()
backtrack(candidates, target, 0, 0)
return res
分割
(6)131. 分割回文串
切割问题类似组合问题。
例如对于字符串abcdef:
- 组合问题:选取一个a之后,在bcdef中再去选取第二个,选取b之后在cdef中选取第三个.....。
- 切割问题:切割一个a之后,在bcdef中再去切割第二段,切割b之后在cdef中切割第三段.....。
在处理组合问题的时候,递归参数需要传入startIndex,表示下一轮递归遍历的起始位置,这个startIndex就是切割线。
注意切割过的位置,不能重复切割,所以,backtracking(s, i + 1); 传入下一层的起始位置为i + 1。
递归用于纵向遍历,for循环用于横向遍历,当切割线迭代至字符串末尾,说明找到一种方法
class Solution:
def partition(self, s: str) -> List[List[str]]:
res = []
path = []
n = len(s)
def backtrack(s, startIndx):
if startIndx == n: # 当遍历完字符串中所有的字符
res.append(path[:])
return
for i in range(startIndx, n):
tmp = s[startIndx: i + 1]
if tmp == tmp[::-1]: # 判断是否为回文串
path.append(tmp)
backtrack(s, i + 1)
path.pop()
#else:
# continue
backtrack(s, 0)
return res
(7)93. 复原 IP 地址
注意:终止条件和131.分割回文串 的区别是本题明确要求分成4段,所以终止条件包括分割的段数和切割线切到最后。
还要对不合法的情况都剪枝
大范围:[0,255]
- 如果是0,则只能是一个0
- 如果不是0,开头不能是0
class Solution:
def restoreIpAddresses(self, s: str) -> List[str]:
res = []
path = []
n = len(s)
def backtrack(s, startIndx):
if len(path) > 4: # 如果搜索路径大于4,剪枝
return
if len(path) == 4 and startIndx == n: # 终止条件包括分割的段数和切割线切到最后
res.append('.'.join(path)) # 用.连接列表里的数字串
return
for i in range(startIndx, n):
tmp = int(s[startIndx: i + 1])
if 0 <= tmp <= 255: # 不合法的情况都剪枝
if tmp != 0 and s[startIndx] == '0': # 不能以0开头
continue
if tmp == 0 and startIndx != i: # 不能出现多个0
continue
path.append(s[startIndx: i + 1])
backtrack(s, i + 1)
path.pop()
backtrack(s, 0)
return res
子集
(8)78. 子集
组合问题和分割问题都是收集树的叶子节点,而子集问题是找树的所有节点!
其实子集也是一种组合问题,因为它的集合是无序的,子集{1,2} 和 子集{2,1}是一样的。
那么既然是无序,取过的元素不会重复取,写回溯算法的时候,for循环就要从startIndex开始,而不是从0开始!
而求排列问题的时候,就要从0开始,因为集合是有序的,{1, 2} 和{2, 1}是两个集合。
class Solution:
def subsets(self, nums: List[int]) -> List[List[int]]:
res = [[]] # 开始就存入空集
path = []
n = len(nums)
def backtrack(nums, startIndx):
for i in range(startIndx, n):
path.append(nums[i])
res.append(path[:]) # 存入树的每个节点而不是叶子节点
backtrack(nums, i + 1) # 解集 不能 包含重复的子集,所以是i + 1
path.pop()
backtrack(nums, 0)
return res
(9)90. 子集 II
注意:与 78. 子集 的区别是给出的数组中会有重复值,但输出的结果却不能有重复的组合。
所以与 40. 组合总和 II 类似要在搜索的过程中就去掉重复组合。
class Solution:
def subsetsWithDup(self, nums: List[int]) -> List[List[int]]:
res = [[]]
path = []
n = len(nums)
def backtrack(nums, startIndx):
for i in range(startIndx, n):
if i > startIndx and nums[i] == nums[i - 1]: #遍历到重复元素时剪枝
continue
path.append(nums[i])
res.append(path[:])
backtrack(nums, i + 1)
path.pop()
nums.sort() # 要先排序
backtrack(nums, 0)
return res
(10)491. 递增子序列
注意:与90. 子集 II 的区别是不能先排序,要进行分别进行“树枝去重”和“树层去重”
递归的参数是path, startIndx
class Solution:
def findSubsequences(self, nums: List[int]) -> List[List[int]]:
res = []
path = []
n = len(nums)
def backtrack(path, startIndx): # 这里传的是path
if len(path) >= 2:
if path[-1] >= path[-2]: # “树枝去重”
res.append(path[:])
else:
return
for i in range(startIndx, n):
# if i > startIndx and nums[i] <= nums[i - 1]:
if nums[i] in nums[startIndx:i]: # “树层去重”
continue
path.append(nums[i])
backtrack(path, i + 1)
path.pop()
backtrack(path, 0)
return res
排列
(11)46. 全排列
注意:排列是有序的,也就是说 [1,2] 和 [2,1] 是两个集合,这和之前分析的子集以及组合所不同的地方。
可以看出元素1在[1,2]中已经使用过了,但是在[2,1]中还要在使用一次1,所以处理排列问题for循环就要从0开始,而不是从startIndex开始!。
class Solution:
def permute(self, nums: List[int]) -> List[List[int]]:
res = []
path = []
n = len(nums)
def backtrack(nums, startIndx):
if len(path) == n:
res.append(path[:])
return
for i in range(n):
if nums[i] not in path: # 树层去重,之前取过的元素就不能再取
path.append(nums[i])
backtrack(nums, i + 1)
path.pop()
backtrack(nums, 0)
return res
(12)47. 全排列 II
注意:与 46. 全排列 的区别是给定数组会有重复元素
递归的参数是nums, path
backtrack(nums[: i] + nums[i + 1:], path + [nums[i]])
取下一个元素时只能在去掉当前元素的数里面遍历
展开的回溯写法:
class Solution:
def permuteUnique(self, nums: List[int]) -> List[List[int]]:
res = []
path = []
n = len(nums)
def backtrack(nums, path):
if len(path) == n:
res.append(path[:])
return
for i in range(len(nums)):
if i > 0 and nums[i] == nums[i - 1]:
continue
path.append(nums[i])
backtrack(nums[:i] + nums[i + 1:], path)
path.pop()
nums.sort()
backtrack(nums, [])
return res
隐藏的回溯写法:
class Solution:
def permuteUnique(self, nums: List[int]) -> List[List[int]]:
res = []
path = []
n = len(nums)
def backtrack(nums, path):
# if len(path) == n: 都可以
if not nums:
res.append(path[:])
return
for i in range(len(nums)): # 改为当前数组的长度
# if nums[i] in path:
if i > 0 and nums[i] == nums[i - 1]:
continue
# path.append(nums[i])
# backtrack(nums[: i] + nums[i + 1:], path ) # 展开的回溯
backtrack(nums[: i] + nums[i + 1:], path + [nums[i]]) # 隐藏的回溯
# path.pop()
nums.sort()
backtrack(nums, [])
return res
(12)332. 重新安排行程
class Solution:
def findItinerary(self, tickets: List[List[str]]) -> List[str]:
tickets_dic = defaultdict(list)
for item in tickets:
tickets_dic[item[0]].append(item[1])
'''
tickets_dict里面的内容是这样的
{'JFK': ['SFO', 'ATL'], 'SFO': ['ATL'], 'ATL': ['JFK', 'SFO']})
'''
path = ['JFK']
def backtrack(startPoint): # 返回值是bool类型
if len(path) == len(tickets) + 1:
return True
tickets_dic[startPoint].sort()
# print(tickets_dic)
for _ in tickets_dic[startPoint]:
#必须及时删除,避免出现死循环
endPoint = tickets_dic[startPoint].pop(0)
path.append(endPoint)
# 只要找到一个就可以返回了
if backtrack(endPoint):
return True
path.pop()
tickets_dic[startPoint].append(endPoint) # 恢复当前节点
backtrack('JFK')
return path
(13)51. N 皇后
class Solution:
def solveNQueens(self, n: int) -> List[List[str]]:
# 回溯法
res = []
s = '.' * n
def backtrack(path=[], i=0, col_selected=[], z_diag=set(), f_diag=set()):
if i == n:
res.append(path)
return
for j in range(n):
if j not in col_selected and i-j not in z_diag and i+j not in f_diag:
backtrack(path+[s[:j]+'Q'+s[j+1:]], i+1, col_selected+[j], z_diag|{i-j}, f_diag|{i+j})
backtrack()
return res