回溯初学必看
学完本文,你可以解决如下问题。
回溯的目的??
我先问下读者,你说计算机最牛逼地方和人比是啥啊? 很多人都会说计算速度,我也承认这一点。问题来了,你让计算机去算一个曲面积分,和一个数学系的学生比一定快吗? 答案肯定是否定的,为啥??? 因为你计算曲面积分需要很多的准备工作,你得高速计算机啥是曲面积分?由此又衍生出了一系列前置问题,具体我就不说了。但是问题来了,他这个快是快在哪儿啊??快在简单的数理逻辑运算,也就是加减乘除模,移位,你让计算机算12321321*21321321412肯定比人快吧。我们回溯就是为了发挥计算机的这个特长,去解决一些没有很明显的优良算法的问题,再说大白话点,就是暴力枚举。但是你别忘了,以mac m2为例,每秒可以进行好像400e次简单的计算,其实是很大的一个数字。
初学者学回溯的时候往往都是,连这个概念都很懵逼,完全不知道该怎么下手。我以题目带知识点来讲解。
首先我默认大家已经很清楚的了解递归是个什么玩意儿了,我直接开始说题。
简单的递归
这个题很简单,我们想算最大深度,是不是就是max(左树的深度, 右树的深度) + 1就行了啊,为啥+1, 你不还有个根吗是吧。然后往下走,左树的深度不就是max(左树的左树深度, 左树的右树的深度) + 1啊,同理右树你也可以递归下去。对了这里我想说一件事,无论大家做什么题啊,回溯也好,dp,或者其他的问题,第一步一定要先控制好边界啊,也就是考虑极端情况,对于树来说常见的就是空树咋办,或者单叉链这种很极端的情况,大家要先考虑清楚再敲键盘,起码能过几个用例。
class Solution {
public:
int maxDepth(TreeNode* root) {
int depth = 0;
if (root == nullptr)
return 0;
int leftmax = maxDepth(root->left);
int rightmax = maxDepth(root->right);
return 1 + max(leftmax, rightmax);
}
};
我们观察一下这段代码,有几个很重要的block需要注意一下。我们咋确定递归不是无限深度啊,别给咱溢出了直接崩了? 也就是啥时候咱结束递归啊,结束条件是我们在递归的时候永远需要考虑的最重要的问题。对于上面这个题我们很容易就想到了,ok到了叶子就结束当前层的递归。
class Solution {
public:
TreeNode* trimBST(TreeNode* root, int low, int high) {
if (root == nullptr)
return NULL;
if (root->val < low) {
return trimBST(root->right, low, high);
} else if (root->val > high) {
return trimBST(root->left, low, high);
}
else
{
root->left = trimBST(root->left, low, high);
root->right = trimBST(root->right, low, high);
return root;
}
}
这题和上面的一个原理,唯一区别就是我们需要比一下value是不是啊?我们让根的左边等于左边修剪好的子树,右边等于右边修剪好的子树,是不是就可以了。
加大难度
掌握了简单的递归,我们就可以来玩一下回溯了。回溯是咋回事,大白话就是我们试了一下,觉得不行,所以回到上一个状态,试试别的,再不行,再回,再试。
1到n的所有组合啥意思? 大家高中都学过排列和组合,排列是有顺序的,组合是无顺序的。排列里2,1和1,2是俩结果,组合里他俩是一回事。比如这道题, n3的话, k2, 组合有几种? (1, 2) (1, 3) (2, 3)对吧。但是排列呢, (1,2)(1,3)(2,1)(2,3)(3,1)(3,2),有6种啊,读者要注意区分。
我们来看这道题,我当时第一反应做这种题,直接无脑for,你k不是等于2吗,我就套俩for,但是后来一想不对劲啊,k是可变的,万一k==5咋办呢? 5个for真的我就崩溃了。这时候咱的回溯就派上用场了。不管你K是几,我直接退回上一步,再重新选数字,利用递归来控制, 很多读者看到这还是一脸懵逼的啊。 我再细细分析下,首先我们需要收集组合的结果是吧,你比如1,2的时候我们为啥要输出啊,因为他正好俩数,等于我们的K了是不是啊,所以我们收集条件就是保存结果的数组等于k了,就保存起来。然后呢?我们现在有1,2了,下一步是不是该1.3了啊。咋收集1,3啊, 我们先把2 pop出去,然后把3拿进来,也等于K,又是一个新的结果。 逻辑搞清楚了,我们咋实现代码啊? 我们这里用一个开始索引,啥意思呢, 你现在是1到n, 起始数字就是1, 我们1放进去了,下一个你还能放1吗,是不是只能从2开始啊, 所以你开始的位置是固定死的,但是你下一个要往后遍历。
class Solution {
public:
vector<vector<int>> res;
vector<int> path;
void backtrack(int n, int k, int startindex)
{
if (path.size() == k)
{
res.push_back(path);
return;
}
for (int i = startindex; i <= n ; i++)
{
path.push_back(i);
backtrack(n, k , i + 1); //这里为啥是k+1啊, 就是我说的,你用了1还能用1吗, 是不是该2了啊
path.pop_back();//1,2结束了你是不是得把2拿下来啊,再进行下一轮循环
}
}
vector<vector<int>> combine(int n, int k) {
backtrack(n, k , 1);
return res;
}
};
加大亿点点
做完上一题,我们看下这一个题目是咋回事啊, 这个题目难点在于,上一个都是选一次,这个一个可以重复选,读者又懵了,这重复我咋知道用几次啊??? 其实我们可以考虑一种极端情况, 你比如target目标和是5, 我们元素就一个1,让你算组合,我们不就是抓着1使劲薅羊毛吗?? 我们直接1给他用5次就得到结果了。 那复杂点, 比如样例给的这个,那我们最深的深度是不就是2用4次啊,发现不行,我们回退到2用三次,和别的数字开始凑是不是就行了啊。逻辑上就是这样的。 那么结束条件是啥啊,很简单我们定义个sum保存和,啥时候sum==target就保存到结果集里面是不是啊。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-WrIVRHUu-1670745693544)(C:\Users\41988\AppData\Roaming\Typora\typora-user-images\image-20221211154713245.png)]
class Solution {
public:
vector<vector<int>> res;
vector<int> path;
void backtrack(vector<int>& candidates, int target, int sum, int startindex)
{
if (sum > target)
return;
if (sum == target)
{
res.push_back(path);
return;
}
for (int i = startindex ; i < candidates.size(); i++)
{
sum += candidates[i];
path.push_back(candidates[i]);
backtrack(candidates, target, sum, i);
sum -= candidates[i];
path.pop_back();
}
}
vector<vector<int>> combinationSum(vector<int>& candidates, int target) {
backtrack(candidates, target, 0, 0);
return res;
}
};
有的读者看到sum-=懵了, 这是咋回事啊,其实和我刚才说的一模一样。你用了4个2发现凑不出来啊,但是这时候你的sum是不是已经是8了啊,你都超了,所以得-2, 再去和别的数字凑。 剩下的逻辑还是一样的,遍历candidate就可以了。
简单的排列
正如我前文说的,排列唯一的区别就是排列是有顺序的,你1开头的时候,可以用2, 2开头的时候可以用1, 我们凡是遇到可以重复用数字的这种题目啊,只要开一个bool数组,记录下你用没用过就可以了。
class Solution {
public:
vector<vector<int>> res;
vector<int> path;
void backtrack(vector<int>& nums, vector<bool>& used)
{
if (path.size() == nums.size())
{
res.push_back(path);
return;
}
for (int i = 0; i < nums.size(); i++)
{
if (used[i] == true)
{
continue;
}
path.push_back(nums[i]);
used[i] = true;
backtrack(nums, used);
used[i] = false;
path.pop_back();
}
}
vector<vector<int>> permute(vector<int>& nums) {
vector<bool> used(nums.size(), false);
backtrack(nums, used);
return res;
}
};
第一步还是看看结束条件,啥时候停止啊,全排列,就是我们的答案必须和原数组一样是不是结束递归啊。回溯条件呢? 是不是回退到了上一步,记得把这个bool数组的对应值改成没用过,然后从末尾拿下来就行了啊。你1,2,3拿到结果了,开始回退,你开始的时候要控制bool数组,要是用过了就跳过本次循环了。
总结
回溯是笔试爱考,检验代码功力,考察边界控制的一个很综合,难度也大的题。很多读者都很懵逼啊一开始接触到这种题。但是我们只要把把握住主线,两个:1结束条件,不然无限月读溢出了是吧 2.回溯的时候有很多添加的细节是不是啊,比如bool数组啊, sum加减啥的, 最主要的是,理解这个逻辑思路很重要啊,其实大家看多了就知道回溯一般都是多叉树啊,也就是更复杂的多路递归,我们只要把握住回溯函数的写法,套路基本都差不多,感谢观看。