理论基础
什么是回溯?
- 回溯法也叫回溯搜索法,就是一种搜索方法,而且还是暴力搜索!另外,回溯是递归的副产品,只要有递归就会有回溯,所以回溯法也经常和二叉树遍历,深度优先搜索混在一起,因为这两种方式都是用了递归。
可以解决什么问题?
- 组合问题:N个数里面按一定规则找出k个数的集合,这个结果集是无序的,而排列是有序的,比如1和2,2和1在组合里是算一组数据,在排列里算两组。
- 排列问题:N个数按一定规则全排列,有几种排列方式。
- 切割问题:一个字符串按一定规则有几种切割方式。
- 子集问题:一个N个数的集合里有多少符合条件的子集
- 棋盘问题 :N皇后,解数独等等。
如何理解回溯算法?
- 把回溯算法理解为抽象的树,一般情况下集合数目n为树的宽度,递归的深度为树的深度,本质还是递归,只是要一层一层的去遍历每个子节点,递归结束条件一般是碰到叶子节点结束。
- 效率问题:由于它属于暴力搜索,本质是穷举,所以它的效率很低,但是有些问题只能穷举,而且普通的暴力算法无法解决,只能使用回溯法,而且优化也最多就是剪枝一下。
- 回溯解题模版
- 回溯其搜索的过程:for循环横向遍历,递归纵向遍历,回溯不断调整结果集,这个理念贯穿整个回溯法系列。
void backtracking(参数) {
if (终止条件) {
存放结果;
return;
}
for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) {
处理节点;
backtracking(路径,选择列表); // 递归
回溯,撤销处理结果
}
}
组合问题
组合
- 力扣题目链接
- 这道题是给定两个数,一个是组合长度k,一个是数据的长度n,要求所有的长度为k的组合,直接套用回溯模版,for循环为1到n,然后递归是去找组合元素,递归深度就是k,回溯逻辑在path在递归前后的push和pop
- 优化:剪枝操作,在for循环里,当剩余的元素不满足k个元素时,直接跳出循环,退出即可。
组合总和
- 力扣题目链接
- 思路:这道题和第一题的组合问题类似,只不过把ke个元素的限制改为了组合元素之和为target限制,那么递归退出条件就是组合和为target时,别的步骤都一样,注意这个可以出现重复元素,所以,下一层递归的index还是 i 而不是 i + 1。
- 优化:还是在for循环的地方剪枝,当target - nums[i] >= 0时在进入循环,如果小于的话肯定超过target了,不符合情况。
组合总和2
- 力扣题目链接
- 思路:这道题是组合总和问题的变种,要求组合中每个元素只能出现一次,这就要涉及使用used数组到去重问题了。
- 这道题是可以纵向重复,而不能横向重复的。
- 什么是纵向重复?
- 就是假如数组有相同元素,那么是可以出现在一个组合里的,但是同一个元素只能在一个组合出现一次,且不能有重复数组出现。
- 什么是横向重复?
- 就是在for循环里,相同的两个元素不能重复使用。
- 什么是纵向重复?
- 解题思路:先对数组进行排序,这样便于去重,然后在递归函数参数里增加一个used数组,用来描述这个元素的上一个元素是否被使用过,比如1,1两个元素,第一个1被使用了,那么第二轮走到第二个1的时候就不会再去使用第二个1了,但是递归进去时,我们把第一个1的使用状态改为未使用,这样可以保证第二个1和第一个1可以被分到一个组合,递归函数退出后,再改回已使用的状态。
组合总和3:
- 力扣题目链接
- 这道题是组合问题的变种,把n个数中求k个组合,改为在1-9中9个数字里求和为n的k个数的组合。
- 思路:和组合问题一样,但是增加了组合的元素和为n,其实就是在递归参数列表里增加一个参数用来保存数组和即可(也可以用减法,我就是用的减法),退出条件还是path大小为k的时候。
- 剪枝:这里剪枝的时候需要注意for循环里还要加上
i <= 9 - need + 1 && n - i >= 0
,因为如果不加在,大小等于k的时候,假如不符合和为n,那么递归会继续向下走,然后就陷入递归死循环了。
电话号码组合
- 力扣题目链接
- 思路:
- 和上面组合问题的区别在于,这道题是不同集合之间的组合,所以每一层递归的for循环是数字对应的字母。
- 首先定义一个数字到字母的映射,数组和map都行,直接手动定义即可
- 还是回溯法的思路,但是这个题宽度是每个数字的代表的字母数,而深度为数字的个数
- 注意:面试时加上其他符号的判断,只要将符号- ‘0’,得到的结果不在0-9之间就不是数字。
切割问题
- 切割问题其实和组合问题是类似的,且看下面两道题
分割回文子串
- 力扣题目链接
- 就是将一个字符串分割为回文子串组合
- 思路,步骤:
- 类似于组合问题,for循环横向遍历字符串,分别相当于切1、2、3…n个元素,纵向就是往组合里添加一个个子串。
- 子串怎么界定?
- 这里显然在取得子串的后,就要判断字符串是不是回文子串。
- 递归退出条件为下标start_index是否走到了字符串尾部。
优化:这里不需要剪枝、剪枝操作就是判断回文子串,不是就continue,继续遍历。
复原IP地址
- 这道题也是切割问题,类似于组合问题的做法,力扣题目链接
- 思路,步骤:
- 只要字符串的长度小于4或者大于12,肯定是不对的,直接return。
- 切割字符串,这里可以在for循环里添加,每层最多切三个,切割后判断字符符合条件。
- 主要是四点:
- begin要小于等于end
- 如果子串长度大于1,那么开头元素不能为0
- 有特殊符号也不行
- 数字在0到255之间,超出也不行。
- 主要是四点:
- 如果符合条件,就修改s在i之后添加一个 ‘.’,然后调用递归函数,注意次数应该给start_index赋值为
i + 2
,因为加了点,然后递归结束再将.去掉(回溯) - 递归退出条件为进入到第四层(每次递归都加1层,递归结束再-1)的时候,此时判断剩下的子串是否符合,符合就放入结果集,不符合就返回。
子集问题
子集
- 力扣题目链接
- 和组合问题的区别在于,组合问题再递归退出的时候才存入结果集,而子集问题是每一次循环(代表每一个叶子节点)都存入结果集,退出条件就是start_index等于数组大小的时候
子集ii
- 力扣题目链接
- 在上题的基础上要进行去重,和组合问题一样的去重逻辑,使用used数组
递增子序列
- 力扣题目链接
- 这道题注意是在原序列上找递增子序列,而不是在排序后的数组上找,如果给数组排序,那整体就是有序的了,没有意义了
- 思路:
- 同样是子集问题,我们在path大小大于1的时候放入结果集,然后在for循环里要判断下当前元素和path最后一个元素的大小,如果小,就continue。
- 注意:这里需要去重,因为可能出现重复子序列,去重就是防止横向重复即可,因为不能对数组排序,所以used方法不能用了,但是由于只是横向去重,那么直接for循环前定义一个set进行去重即可。
排列问题
- 排列问题和组合问题的区别是,排列问题是有序的,但组合问题无序,所以,12和21在排列里是不同的组合
全排列
- 力扣题目链接
- 思路:
- 和组合问题一样,但是需要增加一个used数组作为参数,因为在一个组合里每个元素只能出现一次,但是由于每次start_index都是0,所以就需要used数组来帮我们判断这个元素在之前是否被使用过了。
- 函数退出条件为,path大小等于数组大小的时候
全排列2
- 这道题激素上道题的基础上,增加了去重逻辑,因为原数组有重复元素了
- 去重(这里依然是横向去重):
- 方法一:直接使用递增子序列的去重方法在普通排列的基础上增加set去横向去重即可解决。
- 方法二:因为这里不强调在原数组上的排列,所以可以对其进行排序,因为排列本来要使用used,所以可以将去重逻辑或判断元素是否被使用过放在一起。
- 增加一个条件判断
if (i > 0 && nums[i] == nums[i - 1] && used[i - 1] == false)
,其实按照思维逻辑应该使用if(i > 0 && nums[i] == nums[i - 1] && used[i - 1] == true)
去判断,就是说当前一个元素被用了,那么我就continue,但是由于排列问题在递归之前需要将当前元素的used置为true,递归结束后改为false,那么我们在进行去重判断的时候,其实如果取true就是在递归下一层里去判断前一个元素是否使用过,如果前一个元素是上一层的元素,那么就是成立的,如果是当前层元素那么就是不成立的。所以true的话是纵向去重,而改用false的话,因为for循环每次递归后,used数组会被改为false,所以false是在当前层去重。 - 但是事实上这两种判断方法都是正确的,但是使用false去判断时,效率更高,因为使用false的话是在树层上去重,而使用true是在树枝上去重,在树层上去重的话,直接就不用进入递归了,而树枝上去重还得进入递归再去判断。
- 增加一个条件判断
去重方法总结
- used去重法和set去重法
- 使用used数组去重效率会更高,时间复杂度和空间复杂度都比使用set来的快,而使用哈希set时,由于需要频繁的进行插入和查找工作,比较耗时,即使哈希set的效率已经很高,但是在插入的时候也还是要计算哈希值,扩充符号表等工作,时间上还是没有数组来的快,而且每次递归里都有一个set都话,空间复杂度也变为 O ( n 2 ) O(n^2) O(n2)了(递归为n,每次递归的set为n,乘起来!)。
- 使用set去重的话,其实普通的组合和子集问题也可以使用,但是一定要排序,因为不排序的话,假如有重复元素不连在一起,可能祖先节点已经使用过了,还是会出现重复的情况,那么为什么 递增子序列 这道题可以不排序去使用set?因为这道题有约束条件‘递增’,你排序了还怎么解题啊!
- 所以,能用used数组就用used数组去重,前提是得可排序,如果无法排序就使用set去解题吧。
- 但是,最重要的是分析题目,是需要纵向去重还是横向去重,还是都可以,比如全排列就都可以,但是优先使用横向去重。
棋盘问题(难题)
重新安排行程
N皇后
解数独
回溯算法性能分析
-
子集问题分析:
- 时间复杂度:
O
(
n
∗
2
n
)
O(n*2^n )
O(n∗2n) ,因为每一个元素的状态无外乎取与不取,由于节点总数为
2
n
2^n
2n(按二叉树算了),都要遍历一次,构造每一组子集都需要填进数组(对应的代码:
result.push_back(path)
,该操作的复杂度为 O ( n ) O(n) O(n),最终时间复杂度: O ( n × 2 n ) O(n × 2^n) O(n×2n)。所以时间复杂度为 O ( n ∗ 2 n ) O(n*2^n) O(n∗2n) - 空间复杂度: O ( n ) O(n) O(n),递归深度为n,所以系统栈所用空间为 O ( n ) O(n) O(n),每一层递归所用的空间都是常数级别,注意代码里的result和path都是全局变量,就算是放在参数里,传的也是引用,并不会新申请内存空间,最终空间复杂度为 O ( n ) O(n) O(n)
- 时间复杂度:
O
(
n
∗
2
n
)
O(n*2^n )
O(n∗2n) ,因为每一个元素的状态无外乎取与不取,由于节点总数为
2
n
2^n
2n(按二叉树算了),都要遍历一次,构造每一组子集都需要填进数组(对应的代码:
-
排列问题分析:
- 时间复杂度:
O
(
n
!
)
O(n!)
O(n!),这个可以从排列的树形图中很明显发现,每一层节点为
n
n
n,第二层每一个分支都延伸了n-1个分支,再往下又是n-2个分支,所以一直到叶子节点一共就是
n
∗
n
−
1
∗
n
−
2
∗
.
.
.
.
.
1
=
n
!
n * n-1 * n-2 * ..... 1 = n!
n∗n−1∗n−2∗.....1=n!, 而每个叶子节点都会有一个构造全排列填进数组的操作(对应的代码:
result.push_back(path)
,该操作的复杂度为 O ( n ) O(n) O(n)。所以,最终时间复杂度为: n ∗ n ! n * n! n∗n!,简化为 O ( n ! ) O(n!) O(n!)。 - 空间复杂度: O ( n ) O(n) O(n),和子集问题同理。
- 时间复杂度:
O
(
n
!
)
O(n!)
O(n!),这个可以从排列的树形图中很明显发现,每一层节点为
n
n
n,第二层每一个分支都延伸了n-1个分支,再往下又是n-2个分支,所以一直到叶子节点一共就是
n
∗
n
−
1
∗
n
−
2
∗
.
.
.
.
.
1
=
n
!
n * n-1 * n-2 * ..... 1 = n!
n∗n−1∗n−2∗.....1=n!, 而每个叶子节点都会有一个构造全排列填进数组的操作(对应的代码:
-
组合问题分析:
- 时间复杂度: O ( n ∗ 2 n ) O(n*2^n ) O(n∗2n),组合问题其实就是一种子集的问题,所以组合问题最坏的情况,也不会超过子集问题的时间复杂度。
- 空间复杂度: O ( n ) O(n) O(n),和子集问题同理。
-
切割问题和组合问题基本一样,回文子串由于要去求子串,空间复杂度为 O ( n 2 ) O(n^2) O(n2)
-
棋盘算法问题分析
-
一般回溯算法的复杂度,都是指数级别的时间复杂度。