目录
方法三:回溯优化,在路径总和>target时,不再进行下一次递归
39. 组合总和
- 题目链接:力扣题目链接
文章讲解:代码随想录
视频讲解:Leetcode:39. 组合总和讲解
思路
本题和77.组合,216.组合总和III的区别是:本题没有数量要求,可以无限重复,但是有总和的限制,所以间接的也是有个数的限制。
本题搜索的过程抽象成树形结构如下:
注意图中叶子节点的返回条件,因为本题没有组合数量要求,仅仅是总和的限制,所以递归没有层数的限制,只要选取的元素总和超过target,就返回!
而在77.组合 和216.组合总和III中都可以知道要递归K层,因为要取k个元素的组合。
回溯三部曲
- 递归函数参数
这里依然是定义两个全局变量,二维数组result存放结果集,数组path存放符合条件的结果。(这两个变量可以作为函数参数传入)
首先是题目中给出的参数,集合candidates, 和目标值target。
此外我还定义了int型的sum变量来统计单一结果path里的总和,其实这个sum也可以不用,用target做相应的减法就可以了,最后如何target==0就说明找到符合的结果了,但为了代码逻辑清晰,我依然用了sum。
本题还需要startIndex来控制for循环的起始位置,对于组合问题,什么时候需要startIndex呢?
我举过例子,如果是一个集合来求组合的话,就需要startIndex,例如:77.组合 ,216.组合总和III。
如果是多个集合取组合,各个集合之间相互不影响,那么就不用startIndex,例如:17.电话号码的字母组合
- 递归终止条件
在如下树形结构中:
从叶子节点可以清晰看到,终止只有两种情况,sum大于target和sum等于target。
sum等于target的时候,需要收集结果,代码如下:
if total > target:
return
if total == target:
result.append(path[:])
return
- 单层搜索的逻辑
单层for循环依然是从startIndex开始,搜索candidates集合。
注意本题和77.组合、216.组合总和III 的一个区别是:本题元素为可重复选取的。
如何重复选取呢,看代码,注释部分:
for i in range(startIndex, len(candidates)):
total += candidates[i]
path.append(candidates[i])
self.backtracking(candidates, target, total, i, path, result) # 不用i+1了,表示可以重复读取当前的数
total -= candidates[i]
path.pop()
剪枝优化
在这个树形结构中:
以及上面的版本一的代码大家可以看到,对于sum已经大于target的情况,其实是依然进入了下一层递归,只是下一层递归结束判断的时候,会判断sum > target的话就返回。
其实如果已经知道下一层的sum会大于target,就没有必要进入下一层递归了。
那么可以在for循环的搜索范围上做做文章了。
对总集合排序之后,如果下一层的sum(就是本层的 sum + candidates[i])已经大于target,就可以结束本轮for循环的遍历。
如图:
for循环剪枝代码如下:
for i in range(startIndex, len(candidates)):
if total + candidates[i] > target:
break
方法一:未使用total,在路径总和上没有体现回溯
class Solution:
def __init__(self) -> None:
self.path = []
self.result = []
def combinationSum(self, candidates: List[int], target: int) -> List[List[int]]:
if not candidates: return []
n = len(candidates)
candidates.sort()
self.backtracking(candidates,target,0,n)
return self.result
def backtracking(self,candidates:List[int],target:int,startIndex:int,n:int) -> None:
if sum(self.path) > target: return
if sum(self.path) == target:
self.result.append(self.path[:])
return
for i in range(startIndex,n):
self.path.append(candidates[i])
self.backtracking(candidates,target,i,n)
self.path.pop()
方法二:使用路径数值相加进行回溯
class Solution:
def backtracking(self, candidates, target, total, startIndex, path, result):
if total > target:
return
if total == target:
result.append(path[:])
return
for i in range(startIndex, len(candidates)):
total += candidates[i]
path.append(candidates[i])
self.backtracking(candidates, target, total, i, path, result) # 不用i+1了,表示可以重复读取当前的数
total -= candidates[i]
path.pop()
def combinationSum(self, candidates, target):
result = []
self.backtracking(candidates, target, 0, 0, [], result)
return result
方法三:回溯优化,在路径总和>target时,不再进行下一次递归
class Solution:
def backtracking(self, candidates, target, total, startIndex, path, result):
if total == target:
result.append(path[:])
return
for i in range(startIndex, len(candidates)):
if total + candidates[i] > target:
break
total += candidates[i]
path.append(candidates[i])
self.backtracking(candidates, target, total, i, path, result)
total -= candidates[i]
path.pop()
def combinationSum(self, candidates, target):
result = []
candidates.sort() # 需要排序
self.backtracking(candidates, target, 0, 0, [], result)
return result
方法四:回溯,使用target做减法,找到目标路径
class Solution:
def combinationSum(self, candidates: List[int], target: int) -> List[List[int]]:
result =[]
self.backtracking(candidates, target, 0, [], result)
return result
def backtracking(self, candidates, target, startIndex, path, result):
if target == 0:
result.append(path[:])
return
if target < 0:
return
for i in range(startIndex, len(candidates)):
path.append(candidates[i])
self.backtracking(candidates, target - candidates[i], i, path, result)
path.pop()
方法五:方法四的优化
class Solution:
def combinationSum(self, candidates: List[int], target: int) -> List[List[int]]:
result =[]
candidates.sort()
self.backtracking(candidates, target, 0, [], result)
return result
def backtracking(self, candidates, target, startIndex, path, result):
if target == 0:
result.append(path[:])
return
for i in range(startIndex, len(candidates)):
if target - candidates[i] < 0:
break
path.append(candidates[i])
self.backtracking(candidates, target - candidates[i], i, path, result)
path.pop()
心得收获
在做剪枝优化时,一定要把本层的 sum + candidates[i]与target的比较写在前面然后再写其他单层循环的逻辑。下图是把本层的sum 与target的比较逻辑写在了path.append(candidates[i])的后面,会出现的问题
40.组合总和II
思路
这道题目和39.组合总和如下区别:
- 本题candidates 中的每个数字在每个组合中只能使用一次。
- 本题数组candidates的元素是有重复的,而39.组合总和 是无重复元素的数组candidates
最后本题和39.组合总和要求一样,解集不能包含重复的组合。
本题的难点在于区别2中:集合(数组candidates)有重复元素,但还不能有重复的组合。
一些同学可能想了:我把所有组合求出来,再用set或者map去重,这么做很容易超时!
所以要在搜索的过程中就去掉重复组合。
很多同学在去重的问题上想不明白,其实很多题解也没有讲清楚,反正代码是能过的,感觉是那么回事,稀里糊涂的先把题目过了。
这个去重为什么很难理解呢,所谓去重,其实就是使用过的元素不能重复选取。 这么一说好像很简单!
都知道组合问题可以抽象为树形结构,那么“使用过”在这个树形结构上是有两个维度的,一个维度是同一树枝上使用过,一个维度是同一树层上使用过。没有理解这两个层面上的“使用过” 是造成大家没有彻底理解去重的根本原因。
那么问题来了,我们是要同一树层上使用过,还是同一树枝上使用过呢?
回看一下题目,元素在同一个组合内是可以重复的,怎么重复都没事,但两个组合不能相同。
所以我们要去重的是同一树层上的“使用过”,同一树枝上的都是一个组合里的元素,不用去重。
为了理解去重我们来举一个例子,candidates = [1, 1, 2], target = 3,(方便起见candidates已经排序了)
强调一下,树层去重的话,需要对数组排序!
选择过程树形结构如图所示:
可以看到图中,每个节点相对于 39.组合总和我多加了used数组,这个used数组下面会重点介绍。
回溯三部曲
- 递归函数参数
与39.组合总和 套路相同,此题还需要加一个bool型数组used,用来记录同一树枝上的元素是否使用过。
这个集合去重的重任就是used来完成的。
- 递归终止条件
与39.组合总和相同,终止条件为 sum > target
和 sum == target
。
代码如下:
if total > target:
return
if total == target:
result.append(path[:])
return
sum > target
这个条件其实可以省略,因为在递归单层遍历的时候,会有剪枝的操作,下面会介绍到。
- 单层搜索的逻辑
这里与39.组合总和最大的不同就是要去重了。
前面我们提到:要去重的是“同一树层上的使用过”,如何判断同一树层上元素(相同的元素)是否使用过了呢。
如果candidates[i] == candidates[i - 1]
并且 used[i - 1] == false
,就说明:前一个树枝,使用了candidates[i - 1],也就是说同一树层使用过candidates[i - 1]。
此时for循环里就应该做continue的操作。
这块比较抽象,如图:
我在图中将used的变化用橘黄色标注上,可以看出在candidates[i] == candidates[i - 1]相同的情况下:
- used[i - 1] == true,说明同一树枝candidates[i - 1]使用过
- used[i - 1] == false,说明同一树层candidates[i - 1]使用过
可能有的录友想,为什么 used[i - 1] == false 就是同一树层呢,因为同一树层,used[i - 1] == false 才能表示,当前取的 candidates[i] 是从 candidates[i - 1] 回溯而来的。
而 used[i - 1] == true,说明是进入下一层递归,去下一个数,所以是树枝上,如图所示:
这块去重的逻辑很抽象,网上搜的题解基本没有能讲清楚的,如果大家之前思考过这个问题或者刷过这道题目,看到这里一定会感觉通透了很多!
方法一:回溯-剪枝优化
class Solution:
def __init__(self) -> None:
self.path = []
self.result = []
def combinationSum2(self, candidates: List[int], target: int) -> List[List[int]]:
if not candidates: return self.result
candidates.sort()
self.backtracking(candidates,target,0)
return self.result
def backtracking(self,candidates:List[int],target:int,startindex:int) -> None:
if sum(self.path) == target:
self.result.append(self.path[:])
return
for i in range(startindex,len(candidates)):
# 当在同一层级相同的值要跳过
if i > startindex and candidates[i] == candidates[i-1]:
continue
if sum(self.path) + candidates[i] > target:
break
self.path.append(candidates[i])
self.backtracking(candidates,target,i+1)
self.path.pop()
方法二:回溯使用used
class Solution:
def __init__(self) -> None:
self.path = []
self.result = []
def combinationSum2(self, candidates: List[int], target: int) -> List[List[int]]:
if not candidates: return self.result
candidates.sort()
used = [0] * len(candidates)
self.backtracking(candidates,target,0,used)
return self.result
def backtracking(self,candidates:List[int],target:int,startindex:int,used:List[int]) -> None:
if sum(self.path) == target:
self.result.append(self.path[:])
return
for i in range(startindex,len(candidates)):
if i > startindex and candidates[i] == candidates[i-1] and used[i-1] == 0:
continue
if sum(self.path) + candidates[i] > target:
break
used[i] = 1
self.path.append(candidates[i])
self.backtracking(candidates,target,i+1,used)
used[i] = 0
self.path.pop()
心得收获
131.分割回文串
思路
本题这涉及到两个关键问题:
- 切割问题,有不同的切割方式
- 判断回文
相信这里不同的切割方式可以搞懵很多同学了。
这种题目,想用for循环暴力解法,可能都不那么容易写出来,所以要换一种暴力的方式,就是回溯。
一些同学可能想不清楚 回溯究竟是如何切割字符串呢?
我们来分析一下切割,其实切割问题类似组合问题。
例如对于字符串abcdef:
- 组合问题:选取一个a之后,在bcdef中再去选取第二个,选取b之后在cdef中再选取第三个.....。
- 切割问题:切割一个a之后,在bcdef中再去切割第二段,切割b之后在cdef中再切割第三段.....。
感受出来了不?
所以切割问题,也可以抽象为一棵树形结构,如图:
图片来源leetCode 131.分割回文串 + 回溯算法 + 图解 + 笔记_分割回文串 力扣-CSDN博客
递归用来纵向遍历,for循环用来横向遍历,切割线(就是图中的红线)切割到字符串的结尾位置,说明找到了一个切割方法。
此时可以发现,切割问题的回溯搜索的过程和组合问题的回溯搜索的过程是差不多的。
回溯三部曲
- 递归函数参数
全局变量数组path存放切割后回文的子串,二维数组result存放结果集。 (这两个参数可以放到函数参数里)
本题递归函数参数还需要startIndex,因为切割过的地方,不能重复切割,和组合问题也是保持一致的。
在回溯算法:求组合总和(二)中我们深入探讨了组合问题什么时候需要startIndex,什么时候不需要startIndex。
- 递归函数终止条件
从树形结构的图中可以看出:切割线切到了字符串最后面,说明找到了一种切割方法,此时就是本层递归的终止条件。
那么在代码里什么是切割线呢?
在处理组合问题的时候,递归参数需要传入startIndex,表示下一轮递归遍历的起始位置,这个startIndex就是切割线。
所以终止条件代码如下:
if start_index == len(s):
result.append(path[:])
return
- 单层搜索的逻辑
来看看在递归循环中如何截取子串呢?
在for (int i = startIndex; i < s.size(); i++)
循环中,我们 定义了起始位置startIndex,那么 [startIndex, i] 就是要截取的子串。
首先判断这个子串是不是回文,如果是回文,就加入在vector<string> path
中,path用来记录切割过的回文子串。
代码如下:
for i in range(start_index, len(s)):
# 此次比其他组合题目多了一步判断:
# 判断被截取的这一段子串([start_index, i])是否为回文串
if self.is_palindrome(s, start_index, i):
path.append(s[start_index:i+1])
self.backtracking(s, i+1, path, result) # 递归纵向遍历:从下一处进行切割,判断其余是否仍为回文串
path.pop() # 回溯
注意切割过的位置,不能重复切割,所以,backtracking(s, i + 1); 传入下一层的起始位置为i + 1。
判断回文子串
最后我们看一下回文子串要如何判断了,判断一个字符串是否是回文。
可以使用双指针法,一个指针从前向后,一个指针从后向前,如果前后指针所指向的元素是相等的,就是回文字符串了。
那么判断回文的代码如下:
def is_palindrome(self, s: str, start: int, end: int) -> bool:
i: int = start
j: int = end
while i < j:
if s[i] != s[j]:
return False
i += 1
j -= 1
return True
整个字符串切割及回溯流程
方法一:回溯基本版
class Solution:
def partition(self, s: str) -> List[List[str]]:
'''
递归用于纵向遍历
for循环用于横向遍历
当切割线迭代至字符串末尾,说明找到一种方法
类似组合问题,为了不重复切割同一位置,需要start_index来做标记下一轮递归的起始位置(切割线)
'''
result = []
self.backtracking(s, 0, [], result)
return result
def backtracking(self, s, start_index, path, result ):
# Base Case
if start_index == len(s):
result.append(path[:])
return
# 单层递归逻辑
for i in range(start_index, len(s)):
# 此次比其他组合题目多了一步判断:
# 判断被截取的这一段子串([start_index, i])是否为回文串
if self.is_palindrome(s, start_index, i):
path.append(s[start_index:i+1])
self.backtracking(s, i+1, path, result) # 递归纵向遍历:从下一处进行切割,判断其余是否仍为回文串
path.pop() # 回溯
def is_palindrome(self, s: str, start: int, end: int) -> bool:
i: int = start
j: int = end
while i < j:
if s[i] != s[j]:
return False
i += 1
j -= 1
return True
方法二:回溯+优化判定回文函数
class Solution:
def partition(self, s: str) -> List[List[str]]:
result = []
self.backtracking(s, 0, [], result)
return result
def backtracking(self, s, start_index, path, result ):
# Base Case
if start_index == len(s):
result.append(path[:])
return
# 单层递归逻辑
for i in range(start_index, len(s)):
# 若反序和正序相同,意味着这是回文串
if s[start_index: i + 1] == s[start_index: i + 1][::-1]:
path.append(s[start_index:i+1])
self.backtracking(s, i+1, path, result) # 递归纵向遍历:从下一处进行切割,判断其余是否仍为回文串
path.pop() # 回溯
方法三:回溯+高效判断回文子串
class Solution:
def partition(self, s: str) -> List[List[str]]:
result = []
isPalindrome = [[False] * len(s) for _ in range(len(s))] # 初始化isPalindrome矩阵
self.computePalindrome(s, isPalindrome)
self.backtracking(s, 0, [], result, isPalindrome)
return result
def backtracking(self, s, startIndex, path, result, isPalindrome):
if startIndex >= len(s):
result.append(path[:])
return
for i in range(startIndex, len(s)):
if isPalindrome[startIndex][i]: # 是回文子串
substring = s[startIndex:i + 1]
path.append(substring)
self.backtracking(s, i + 1, path, result, isPalindrome) # 寻找i+1为起始位置的子串
path.pop() # 回溯过程,弹出本次已经添加的子串
def computePalindrome(self, s, isPalindrome):
for i in range(len(s) - 1, -1, -1): # 需要倒序计算,保证在i行时,i+1行已经计算好了
for j in range(i, len(s)):
if j == i:
isPalindrome[i][j] = True
elif j - i == 1:
isPalindrome[i][j] = (s[i] == s[j])
else:
isPalindrome[i][j] = (s[i] == s[j] and isPalindrome[i+1][j-1])
方法四:回溯+使用all函数判断回文子串
class Solution:
def partition(self, s: str) -> List[List[str]]:
result = []
self.partition_helper(s, 0, [], result)
return result
def partition_helper(self, s, start_index, path, result):
if start_index == len(s):
result.append(path[:])
return
for i in range(start_index + 1, len(s) + 1):
sub = s[start_index:i]
if self.isPalindrome(sub):
path.append(sub)
self.partition_helper(s, i, path, result)
path.pop()
def isPalindrome(self, s):
return all(s[i] == s[len(s) - 1 - i] for i in range(len(s) // 2))