332. 重新安排行程(可跳过)
题目链接:代码随想录
~
51. N皇后(可跳过)
题目链接:代码随想录
视频讲解:代码随想录
~
37. 解数独(可跳过)
题目链接:代码随想录
视频讲解:回溯算法二维递归?解数独不过如此!| LeetCode:37. 解数独_哔哩哔哩_bilibili
~
回溯算法总结
回溯算法理论基础
回溯是递归的副产品,只要有递归就会有回溯,所以回溯法也经常和二叉树遍历,深度优先搜索混在一起,因为这两种方式都是用了递归。
回溯法就是暴力搜索,并不是什么高效的算法,最多再剪枝一下。
回溯算法能解决如下问题:
- 组合问题:N个数里面按一定规则找出k个数的集合
- 排列问题:N个数按一定规则全排列,有几种排列方式
- 切割问题:一个字符串按一定规则有几种切割方式
- 子集问题:一个N个数的集合里有多少符合条件的子集
- 棋盘问题:N皇后,解数独等等
回溯法确实不好理解,所以需要把回溯法抽象为树状图来理解就容易多了
回溯法模板:
void backtracking(参数) { if (终止条件) { 存放结果; return; } for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) { 处理节点; backtracking(路径,选择列表); // 递归 回溯,撤销处理结果 } }
组合问题
在回溯算法:求组合问题! (opens new window)中,我们开始用回溯法解决第一道题目:组合问题。
用递归控制for循环嵌套的数量!
for循环横向遍历,递归纵向遍历,回溯不断调整结果集
优化回溯算法只有剪枝一种方法,在回溯算法:组合问题再剪剪枝 (opens new window)中把回溯法代码做了剪枝优化,树形结构如图:
回溯算法:求组合问题! (opens new window)剪枝精髓是:for循环在寻找起点的时候要有一个范围,如果这个起点到集合终止之间的元素已经不够题目要求的k个元素了,就没有必要搜索了。
在for循环上做剪枝操作是回溯法剪枝的常见套路!
组合总和
组合总和(一)
在回溯算法:求组合总和! (opens new window)中,相当于 回溯算法:求组合问题! (opens new window)加了一个元素总和的限制。
本题的剪枝会好想一些,即:已选元素总和如果已经大于n(题中要求的和)了,那么往后遍历就没有意义了,直接剪掉,如图:
在本题中,依然还可以有一个剪枝,就是回溯算法:组合问题再剪剪枝 (opens new window)中提到的,对for循环选择的起始范围的剪枝。
组合总和(二)
在回溯算法:求组合总和(二) (opens new window)中讲解的组合总和问题,和回溯算法:求组合问题! (opens new window),回溯算法:求组合总和! (opens new window)和区别是:本题没有数量要求,可以无限重复,但是有总和的限制,所以间接的也是有个数的限制。
本题还需要startIndex来控制for循环的起始位置,对于组合问题,什么时候需要startIndex呢?
我举过例子,如果是一个集合来求组合的话,就需要startIndex,例如:回溯算法:求组合问题! (opens new window),回溯算法:求组合总和! (opens new window)。
如果是多个集合取组合,各个集合之间相互不影响,那么就不用startIndex,例如:回溯算法:电话号码的字母组合
以上我只是说求组合的情况,如果是排列问题,又是另一套分析的套路。
优化后树形结构如下:
组合总和(三)
在回溯算法:求组合总和(三) (opens new window)中集合元素会有重复,但要求解集不能包含重复的组合。
所以难就难在去重问题上了。
“树枝去重”和“树层去重”。
“使用过”在这个树形结构上是有两个维度的,一个维度是同一树枝上“使用过”,一个维度是同一树层上“使用过”。没有理解这两个层面上的“使用过” 是造成大家没有彻底理解去重的根本原因。
可以看出在candidates[i] == candidates[i - 1]相同的情况下:
- used[i - 1] == true,说明同一树枝candidates[i - 1]使用过
- used[i - 1] == false,说明同一树层candidates[i - 1]使用过
对于去重,其实排列和子集问题也是一样的道理。
多个集合求组合
在回溯算法:电话号码的字母组合 (opens new window)中,开始用多个集合来求组合,还是熟悉的模板题目,但是有一些细节。
因为本题每一个数字代表的是不同集合,也就是求不同集合之间的组合,而回溯算法:求组合问题! (opens new window)和回溯算法:求组合总和! (opens new window)都是是求同一个集合中的组合!
切割问题
在回溯算法:分割回文串 (opens new window)中,我们开始讲解切割问题,虽然最后代码看起来好像是一道模板题,但是从分析到学会套用这个模板,是比较难的。
我列出如下几个难点:
- 切割问题其实类似组合问题
- 如何模拟那些切割线
- 切割问题中递归如何终止
- 在递归循环中如何截取子串
- 如何判断回文
如果想到了用求解组合问题的思路来解决 切割问题本题就成功一大半了,接下来就可以对着模板照葫芦画瓢。
但后序如何模拟切割线,如何终止,如何截取子串,其实都不好想,最后判断回文算是最简单的了。
所以本题应该是一个道hard题目了。
除了这些难点,本题还有细节,例如:切割过的地方不能重复切割所以递归函数需要传入i + 1。
子集问题
子集问题(一)
在回溯算法:求子集问题! (opens new window)中讲解了子集问题,在树形结构中子集问题是要收集所有节点的结果,而组合问题是收集叶子节点的结果。
本题其实可以不需要加终止条件,因为startIndex >= nums.size(),本层for循环本来也结束了,本来我们就要遍历整棵树。
子集问题(二)
在回溯算法:求子集问题(二) (opens new window)中,开始针对子集问题进行去重。
本题就是回溯算法:求子集问题! (opens new window)的基础上加上了去重,去重我们在回溯算法:求组合总和(三) (opens new window)也讲过了,一样的套路。
递增子序列
在回溯算法:递增子序列 (opens new window)中,处处都能看到子集的身影,但处处是陷阱,值得好好琢磨琢磨!
子集问题一定要排序,为什么呢?
我用没有排序的集合{2,1,2,2}来举个例子画一个图,如下:
排列问题
排列问题(一)
回溯算法:排列问题! (opens new window)又不一样了。
排列是有序的,也就是说 [1,2] 和 [2,1] 是两个集合
可以看出元素1在[1,2]中已经使用过了,但是在[2,1]中还要在使用一次1,所以处理排列问题就不用使用startIndex了。
大家此时可以感受出排列问题的不同:
- 每层都是从0开始搜索而不是startIndex
- 需要used数组记录path里都放了哪些元素了
排列问题(二)
排列问题也要去重了,在回溯算法:排列问题(二) (opens new window)中又一次强调了“树层去重”和“树枝去重”。
这道题目神奇的地方就是used[i - 1] == false也可以,used[i - 1] == true也可以!
我就用输入: [1,1,1] 来举一个例子。
树层上去重(used[i - 1] == false),的树形结构如下:
树枝上去重(used[i - 1] == true)的树型结构如下:
可以清晰的看到使用(used[i - 1] == false),即树层去重,效率更高!
本题used数组即是记录path里都放了哪些元素,同时也用来去重,一举两得。
去重问题
以上我都是统一使用used数组来去重的,其实使用set也可以用来去重!
在本周小结!(回溯算法系列三)续集 (opens new window)中给出了子集、组合、排列问题使用set来去重的解法以及具体代码,并纠正一些同学的常见错误写法。
同时详细分析了 使用used数组去重 和 使用set去重 两种写法的性能差异:
使用set去重的版本相对于used数组的版本效率都要低很多,大家在leetcode上提交,能明显发现。
原因在回溯算法:递增子序列 (opens new window)中也分析过,主要是因为程序运行的时候对unordered_set 频繁的insert,unordered_set需要做哈希映射(也就是把key通过hash function映射为唯一的哈希值)相对费时间,而且insert的时候其底层的符号表也要做相应的扩充,也是费时的。
而使用used数组在时间复杂度上几乎没有额外负担!
使用set去重,不仅时间复杂度高了,空间复杂度也高了,在本周小结!(回溯算法系列三) (opens new window)中分析过,组合,子集,排列问题的空间复杂度都是O(n),但如果使用set去重,空间复杂度就变成了O(n^2),因为每一层递归都有一个set集合,系统栈空间是n,每一个空间都有set集合。
那有同学可能疑惑 用used数组也是占用O(n)的空间啊?
used数组可是全局变量,每层与每层之间公用一个used数组,所以空间复杂度是O(n + n),最终空间复杂度还是O(n)。
性能分析
子集问题分析:
- 时间复杂度: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),和子集问题同理。
一般说道回溯算法的复杂度,都说是指数级别的时间复杂度,这也算是一个概括吧!
总结