leetcode算法刷题的第二十二天
1.leetcode 491.非递减序列
题目链接
class Solution {
public:vector<vector<int>> result;vector<int> path;void backtracking(vector<int>& nums,int startIndex){if(path.size()>1){result.push_back(path);//注意这里不要加return,要取树上的节点}unordered_set<int> uset;//使用set对本层元素进行去重for(int i=startIndex;i<nums.size();i++){if((!path.empty()&&nums[i]<path.back())||uset.find(nums[i])!=uset.end()){continue;}uset.insert(nums[i]);//记录这个元素在本层用过了,本层后面不能再用了path.push_back(nums[i]);backtracking(nums,i+1);path.pop_back();}}vector<vector<int>> findSubsequences(vector<int>& nums) {backtracking(nums,0);return result;}
};
思路总结:这个递增子序列比较像是取有序的子集,而且本题也要求不能有相同的递增子序列,这里又是子集,又是去重,可能就会有人想到子集II这道题,但是这两道题有差别。
在子集II中,我们是通过排序,再加上一个标记数组来达到去重的目的。而本题求递增子序列,是不能对原数组进行排序的,排完序的数组都是递增子序列了。所以不能使用之前的去重逻辑。
本题题解清一色都说是深度优先搜索,但我更倾向于说它用回溯法,而且本题我也是完全使用回溯法的逻辑来分析的。
对于养成思维定式或者套模板套嗨了的同学,这道题起到了很好的警醒作用。更重要的是拓展了大家的思路!
2.leetcode 46.全排列
题目链接
class Solution {
public:vector<vector<int>> result;vector<int> path;void backtracking(vector<int>& nums,vector<bool>& used){//此时说明找到了一组if(path.size()==nums.size()){result.push_back(path);return;}for(int i=0;i<nums.size();i++){if(used[i]==true) continue;//path里面已经收录的元素,直接跳过used[i]=true;path.push_back(nums[i]);backtracking(nums,used);used[i]=false;path.pop_back();}}vector<vector<int>> permute(vector<int>& nums) {vector<bool> used(nums.size(),false);backtracking(nums,used);return result;}
};
思路总结:
大家此时可以感受出排列问题的不同:
- 每层都是从0开始搜索而不是startIndex
- 需要used数组记录path里都放了哪些元素了
排列问题是回溯算法解决的经典题目,大家可以好好体会体会。
我们这里之所以从0开始,因为这道题不像前面的题目一样,2、1和1、2是同一种组合,但是是不同的排列,这两个都是我们题目所需要的答案,用startIndex就是为了避免取到相同的组合,所以前面的题目是从startIndex开始,这也是组合问题和排列问题的不同之处。
3.leetcode 47.全排列II
题目链接
class Solution {
public:vector<vector<int>> result;vector<int> path;void backtracking(vector<int>& nums,vector<bool>& used){if(path.size()==nums.size()){//此时说明找到了一组result.push_back(path);return;}for(int i=0;i<nums.size();i++){// used[i - 1] == true,说明同一树枝nums[i - 1]使用过// used[i - 1] == false,说明同一树层nums[i - 1]使用过// 如果同一树层nums[i - 1]使用过则直接跳过if(i>0&&nums[i]==nums[i-1]&&used[i-1]==false){continue;}if(used[i]==false){used[i]=true;path.push_back(nums[i]);backtracking(nums,used);used[i]=false;path.pop_back();}}}vector<vector<int>> permuteUnique(vector<int>& nums) {sort(nums.begin(),nums.end());//排序vector<bool> used(nums.size(),false);backtracking(nums,used);return result;}
};
思路总结:这道题和上一道题目不同的是,这道题给的数组里面是可以包含重复数字的序列,而且要返回所有不重复的全排列,这里又涉及到去重了。这里还要强调一下去重一定要对元素进行排序,这样我们才方便通过相邻的节点来判断是否重复使用了。
一般来说,组合问题和排列问题是在树形结构的叶子节点上收集结果,而子集问题就是取树上所有节点的结果。
4.回溯算法系统性总结
关于回溯算法的理论基础可以看我博客第一篇关于回溯算法的讲解。
回溯是递归的副产品,只要有递归就会有回溯,所以回溯法也经常和二叉树遍历,深度优先搜索混在一起,因为这两种方式都是用了递归。
回溯法就是暴力搜索,并不是什么高效的算法,最多再剪枝一下。
回溯算法能解决如下问题:
- 组合问题:N个数里面按一定规则找出k个数的集合
- 排列问题:N个数按一定规则全排列,有几种排列方式
- 切割问题:一个字符串按一定规则有几种切割方式
- 子集问题:一个N个数的集合里有多少符合条件的子集
- 棋盘问题:N皇后,解数独等等
我在回溯算法系列讲解中就按照这个顺序给大家讲解,可以说深入浅出,步步到位。
回溯法确实不好理解,所以需要把回溯法抽象为一个图形来理解就容易多了,在后面的每一道回溯法的题目我都将遍历过程抽象为树形结构方便大家的理解。
这里我给出一下回溯算法的模板:
void backtracking(参数) {if (终止条件) {存放结果;return;}for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) {处理节点;backtracking(路径,选择列表); // 递归回溯,撤销处理结果}
}
事实证明,这个模板会伴随整个回溯法系列。
组合问题
我在文中开始的时候给大家列举k层for循环例子,进而得出都是同样是暴力解法,为什么要用回溯法!
此时大家应该深有体会回溯法的魅力,用递归控制for循环嵌套的数量!
可以直观的看出其搜索的过程:for循环横向遍历,递归纵向遍历,回溯不断调整结果集,这个理念贯穿整个回溯法系列,也是我做了很多回溯的题目,不断摸索其规律才总结出来的。
对于回溯法的整体框架,网上搜的文章这块都说不清楚,按照天上掉下来的代码对着讲解,不知道究竟是怎么来的,也不知道为什么要这么写。
剪枝精髓是:for循环在寻找起点的时候要有一个范围,如果这个起点到集合终止之间的元素已经不够题目要求的k个元素了,就没有必要搜索了。
在for循环上做剪枝操作是回溯法剪枝的常见套路! 后面的题目还会经常用到。
组合总和问题
组合总和问题相当于在组合问题的基础上再加上了一个元素总和的限制。
这种问题还要考虑去重和数组是否有重复的问题
多个集合求组合
这种问题的经典问题就是leetcode里面的电话号码的字母组合。其实不是很难,但也是处处是细节,还是要反复琢磨。
切割问题
例如分割回文串这道题目。
我列出如下几个难点:
- 切割问题其实类似组合问题
- 如何模拟那些切割线
- 切割问题中递归如何终止
- 在递归循环中如何截取子串
- 如何判断回文
如果想到了用求解组合问题的思路来解决 切割问题本题就成功一大半了,接下来就可以对着模板照葫芦画瓢。
但后序如何模拟切割线,如何终止,如何截取子串,其实都不好想,最后判断回文算是最简单的了。
所以本题应该是一个道hard题目了。
除了这些难点,本题还有细节,例如:切割过的地方不能重复切割所以递归函数需要传入i + 1。
子集问题
在树形结构中子集问题是要收集所有节点的结果,而组合问题是收集叶子节点的结果。
子集问题其实可以不需要加终止条件,因为startIndex >= nums.size(),本层for循环本来也结束了,本来我们就要遍历整棵树。
有的同学可能担心不写终止条件会不会无限递归?
并不会,因为每次递归的下一层就是从i+1开始的。
如果要写终止条件,注意:result.push_back(path);
要放在终止条件的上面,如下:
result.push_back(path); // 收集子集,要放在终止添加的上面,否则会漏掉结果
if (startIndex >= nums.size()) { // 终止条件可以不加return;
}
有的子集问题还要进行去重操作。
排列问题
排列问题又和组合问题不一样了。
排列是有序的,也就是说 [1,2] 和 [2,1] 是两个集合,这和之前分析的子集以及组合所不同的地方。
可以看出元素1在[1,2]中已经使用过了,但是在[2,1]中还要在使用一次1,所以处理排列问题就不用使用startIndex了。
大家此时可以感受出排列问题的不同:
- 每层都是从0开始搜索而不是startIndex
- 需要used数组记录path里都放了哪些元素了
而且这种题目有的问题也要考虑去重操作,也就是给你的数组里面是否有重复的元素。
used数组即是记录path里都放了哪些元素,同时也用来去重,一举两得。
去重问题
以上我都是统一使用used数组来去重的,其实使用set也可以用来去重!
使用set去重的版本相对于used数组的版本效率都要低很多,大家在leetcode上提交,能明显发现。
而使用used数组在时间复杂度上几乎没有额外负担!
使用set去重,不仅时间复杂度高了,空间复杂度也高了
如果使用set去重,空间复杂度就变成了O(n^2),因为每一层递归都有一个set集合,系统栈空间是n,每一个空间都有set集合。
那有同学可能疑惑 用used数组也是占用O(n)的空间啊?
used数组可是全局变量,每层与每层之间公用一个used数组,所以空间复杂度是O(n + n),最终空间复杂度还是O(n)。
总结
因为回溯和递归都是相辅相成的,所以我们也要理解并熟练掌握递归的三部曲。
1.确定递归函数的参数和返回值。
2.确定终止条件。
3.确定单层递归的逻辑。