39. 组合总和
本题明显是一个集合内求组合,所以需要 start_idx
来记录递归的位置。
注意的是本题允许重复的元素,终止条件也是当前记录的子集和,所以对于递归的 start_idx
不需要 +1。
优化剪枝:在 for loop 中如果当前子集和大于 target 的话直接结束循环。这是一个通用的剪枝手段。
class Solution:
def __init__(self):
self.curr_record = []
self.results = []
def backtrack(self, candidates: List[int], target: int, start: int):
if sum(self.curr_record) >= target:
if sum(self.curr_record) == target:
print(start)
self.results.append(self.curr_record.copy())
return None
for i in range(start, len(candidates)):
self.curr_record.append(candidates[i])
self.backtrack(candidates, target, i)
self.curr_record.pop()
def combinationSum(self, candidates: List[int], target: int) -> List[List[int]]:
self.backtrack(candidates, target, 0)
return self.results
40. 组合总和II
这道题和之前所有回溯题最大的不同:可选集合中存在重复元素。这导致在答案中进行去重变得比之前复杂很多,因为依靠 start_idx
的单调性无法有效去重+获得正确答案了。例如 candidates = [10, 1, 2, 7, 6, 1, 5], target = 8
,答案中的 [1, 1, 6]
成立是因为有两个元素值均为1,不是重复使用;但是 [1, 7]
和 [7, 1]
就是重复答案了。
按照简单思路得到所有的解之后,以 set 的形式去重,这样很容易导致超时。正确的做法是在遍历中进行去重,即使用过的元素就不要再使用了。
- 路径去重:对于一条 path 来说,父节点用过的元素(元素,而非元素的值)显然不能再使用
- 这一点之前的题都有所体现,即
start_idx
+ 递归+回溯
- 这一点之前的题都有所体现,即
- 层级去重:同一层内,左侧节点已经使用过的元素值应该被直接跳过(去重)
- 例如:
candidates = [1, 1, 2], target = 3
。如果只依靠start_idx
进行去重,在访问第一个 1 的时候会得到[1, 2]
,在访问第二个 1 的时候同样会得到[1, 2]
,会得到重复的子集。解决方法是,遇到第二个 1 的时候应该直接跳过。 - 核心思想:遇到一个新的元素值的时候,当前的回溯+遍历已经可以得到所有包含该元素值的解,之后遇到的具有相同值的元素只能给出之前解的子集。因此,针对每个元素值,只需要第一次遇到时进行回溯+递归即可,重复值的元素直接跳过。
- 例如:
以上的思路需要对 candidates
提前排序。
class Solution:
def __init__(self):
self.curr_record = []
self.results = []
def backtrack(self, candidates: List[int], target: int, start: int):
if sum(self.curr_record) >= target:
if sum(self.curr_record) == target:
self.results.append(self.curr_record.copy())
return None
curr_idx = start
while (curr_idx < len(candidates)):
curr_candidate = candidates[curr_idx]
self.curr_record.append(curr_candidate)
self.backtrack(candidates, target, curr_idx + 1)
self.curr_record.pop()
curr_idx += 1
while (curr_idx < len(candidates) and candidates[curr_idx] == curr_candidate):
curr_idx += 1
def combinationSum2(self, candidates: List[int], target: int) -> List[List[int]]:
candidates.sort()
self.backtrack(candidates, target, 0)
return self.results
另外的方法:不要使用已使用过的元素,显然可以额外维持一个数组 used
来记录用过的元素。
这种方法通过额外数组来分辨当前发生的元素值重复是由于
- 路径内重复:两个重复元素的前一个 used=True,代表着当前路径内使用过前一个元素,则重复元素的后一个还是可以使用
- 层级内重复:两个重复元素的前一个 used=Flase,代表着前一个元素在同一层之前被用过了,则重复元素的后一个不能使用(会导致重复的子集解)
这个方法的思路有点复杂,很接近以前在二叉树删除节点中保持双指针记录父节点的方式。
131. 分割回文串
本题可以拆成两段:
- 回文串判断:简单的递归/双指针/ reverse 就能解决,注意不要忽略单一字母的情况
- 分割字符串:和组合类问题其实很相似,都是通过传递 index 的方式遍历所有的组合。不同之处在于,连续子字符串要求“分割的组合”具有连续性,所以单层搜索中要以结尾切割线的形式利用传入的 index。
回溯的组件:
- 参数和返回值:两个经典的全局变量;一个额外的
start_idx
传递起始点 - 终止条件:
start_idx = len(s)
,代表着分割线已经到了整个字符串的尾端,当前的分割路径已经结束了 - 单层搜索的逻辑:注意分割线的语义,这里我使用的是
[,]
左闭右闭的写法。
本题的难点在于分析出和组合的相似之处,从而能够以组合的思路解决字符串分割。同时,理解语义并且写出正确的单层搜索逻辑也很关键。
class Solution:
def __init__(self):
self.curr_record = []
self.results = []
def palindrome(self, s: str) -> bool:
if len(s) <= 1:
return True
if s[0] != s[-1]:
return False
return self.palindrome(s[1:-1])
def backtrack(self, s: str, start_idx: int):
if start_idx == len(s):
self.results.append(self.curr_record.copy())
return None
for curr_idx in range(start_idx, len(s)):
curr_s = s[start_idx: curr_idx + 1] # [,)
if self.palindrome(curr_s): # if not palindrome, skip the iteration
self.curr_record.append(curr_s)
self.backtrack(s, curr_idx + 1)
self.curr_record.pop()
def partition(self, s: str) -> List[List[str]]:
self.backtrack(s, 0)
return self.results
可以用 dp 优化判断回文串的方法,但不是重点,