回溯框架
void backtracking(参数) {
if (终止条件) {
存放结果;
return;
}
for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) {
处理节点;
backtracking(路径,选择列表); // 递归
回溯,撤销处理结果
}
}
组合问题
组合
组合问题最基础的就是,给定两个整数n
和k
,求1...n
中所有可能的k
个数的组合。
使用如上框架即可写出来。
注意的是,组合需要使用startIndex
来保证不重复选取。
剪枝
剪枝精髓是:for循环在寻找起点的时候要有一个范围,如果这个起点到集合终止之间的元素已经不够题目要求的k个元素了,就没有必要搜索了。
for i in range(startIndex, n - (k - len(path)) + 2):
组合求和 i
这个是在组合基础上加了求和的一个要求。
反而更简单了,因为有求和可以方便剪枝。
已选元素总和如果已经大于n(题中要求的和)了,那么往后遍历就没有意义了,直接剪掉
组合求和 ii
这道题和组合求和i的区别是,此题的元素可以重复选取。但是这两题都不包含重复的元素。
本题还需要startIndex来控制for循环的起始位置,对于组合问题,什么时候需要startIndex呢?
如果是一个集合来求组合的话,就需要startIndex,例如组合求和i
如果是多个集合取组合,各个集合之间相互不影响,那么就不用startIndex,例如电话号码的字母组合
和组合求和i的区别在于,进入下一次递归的时候,传递的startIndex不需要+1,因为可以对自身重复选取。
本题的剪枝优化:先对candidates
进行排序,随后需满足sum + candidates[i] <= target
。
组合求和 iii
本题包含重复元素,但是不可重复选取。要求解集中不包含重复的组合。
树层去重
图中used的变化用橘黄色标注上,可以看出在candidates[i] == candidates[i - 1]
相同的情况下:
used[i - 1] == true
,说明同一树枝candidates[i - 1]
使用过
used[i - 1] == false
,说明同一树层candidates[i - 1]
使用过
子集问题
子集i
子集问题相当于求组合问题中的所有遍历节点。
不需要写终止条件,因为需要遍历全部。
不会无限递归,因为下一层的递归我们传递的是i + 1
子集ii
nums
中包含重复的元素,但是解集中不能出现重复的子集。
所有此题需要去重。先排序,然后可以使用used数组,或者指针去重。
树层去重
递增子序列
本题不能对nums
进行排序,所以在树层可以用set
来去重。
排列问题
排列i
此题排列我是用slicing写的,因为没有重复元素所以可以这样写,并没有用used数组,但是下一题去重是需要用到used数组的。
每层都是从0开始搜索而不是startIndex
需要used数组记录path里都放了哪些元素了
排列ii
这道题包含重复的元素,对其进行排列,但是解集不能含有重复的排列。
这道题我思考了很久。首先,因为要去重,所以nums
需要排序。
used数组在这道题有两个作用:
- 树层去重
- 保证每次循环的时候不重复地从集合中拿元素出来排序。(从根节点到叶节点的过程)
这道题和组合求和iii的区别:
组合求和iii在循环的时候使用startIndex
,下一次循环的时候不需要重新遍历之前的元素,所以不需要用到used数组。在组合求和iii的去重环节,使用
if i > index and candidates[i] == candidates[i-1]: continue
便可以完成去重。
但是排列ii是需要在每一次循环中遍历所有数组,故需要used数组来识别已经遍历过的元素。
该题是如何保证不包含重复排列的?
还是以上图为例子。假设最后得到的排列集为P = {p1, p2, p3…}。
-
每一层树层,对应的是所得到排列的某个位置。比如说第一层树层,对应的是p1[0](或者p2[0]…)。所以树层去重能够保证对于每个元素在一个位置的情况,不会重复考虑。比如说
1
在p1[0]
的位置上可以在树状图延伸出{1,1,2}
和{1,2,1}
,如果不做树层去重的话就会延伸出两份{1,1,2}
和{1,2,1}
。 -
used数组保证了在一个树枝的遍历上不会重复选取集合中的元素。
代码贴出来方便回忆:
class Solution:
def permuteUnique(self, nums: List[int]) -> List[List[int]]:
res = []
path = []
nums.sort()
used = [False] * len(nums)
def helper():
if len(path) == len(nums):
res.append(path.copy())
return
for i in range(len(nums)):
# used 作用 1
if i > 0 and nums[i] == nums[i-1] and used[i-1] == False:
continue
# used 作用 2
if used[i]:
continue
used[i] = True
path.append(nums[i])
helper()
path.pop()
used[i] = False
helper()
return res
复杂度分析
-
子集问题分析:
- 时间复杂度:
O(2^n)
,因为每一个元素的状态无外乎取与不取,所以时间复杂度为O(2^n)
- 空间复杂度:
O(n)
,递归深度为n,所以系统栈所用空间为O(n),每一层递归所用的空间都是常数级别,注意代码里的result和path都是全局变量,就算是放在参数里,传的也是引用,并不会新申请内存空间,最终空间复杂度为O(n)
- 时间复杂度:
-
排列问题分析:
- 时间复杂度:
O(n!)
,这个可以从排列的树形图中很明显发现,每一层节点为n,第二层每一个分支都延伸了n-1个分支,再往下又是n-2个分支,所以一直到叶子节点一共就是n * n-1 * n-2 * ..... 1 = n!
。 - 空间复杂度:
O(n)
,和子集问题同理。
- 时间复杂度:
-
组合问题分析:
- 时间复杂度:
O(2^n)
,组合问题其实就是一种子集的问题,所以组合问题最坏的情况,也不会超过子集问题的时间复杂度。 - 空间复杂度:
O(n)
,和子集问题同理。
- 时间复杂度:
-
N皇后问题分析:
- 时间复杂度:
O(n!)
,其实如果看树形图的话,直觉上是O(n^n)
,但皇后之间不能见面所以在搜索的过程中是有剪枝的,最差也就是O(n!)
,n!表示n * (n-1) * .... * 1
。 - 空间复杂度:
O(n)
,和子集问题同理。
- 时间复杂度:
-
解数独问题分析:
- 时间复杂度:
O(9^m)
,m
是'.'
的数目。 - 空间复杂度:
O(n^2)
,递归的深度是n^2
。
- 时间复杂度: