在进入研究具体的回溯问题之前,先来看看
回溯是什么
回溯——顾名思义,就是在处理完某一点之后返回到上一状态,再在此基础上处理另外的case。
回溯的本质就是一种枚举,只不过没有那么“暴力”,结合上记忆化搜索以及各种剪枝,回溯可以处理相当数目的中档题。同时,回溯算法因为与递归相联系,所以会隐式地生成一棵树,结合树形图,也是设计与分析回溯递归的一个好办法。
实现回溯最直接的办法就是递归,回溯与递归相辅相成。
回溯算法的使用场景,和递归算法的使用场景高度重合,包括但不限于:
- 组合问题:N个数里面按一定规则找出k个数的集合
- 切割问题:一个字符串按一定规则有几种切割方式
- 子集问题:一个N个数的集合里有多少符合条件的子集
- 排列问题:N个数按一定规则全排列,有几种排列方式
- 棋盘问题:N皇后,解数独等等
- 树的问题:N叉树的构造,遍历等
- 动态规划问题
- 其他:例如KMP算法中的next数组,虽然不是回溯,却用到了回溯的思想
回溯算法的学习与应用任重而道远,此文仅就组合问题讨论。
参考题目:
leetcode77:力扣(LeetCode)官网 - 全球极客挚爱的技术成长平台(最直接的组合问题)
leetcode216:力扣(LeetCode)官网 - 全球极客挚爱的技术成长平台(从子集枚举到小量组合)
leetcode17:力扣(LeetCode)官网 - 全球极客挚爱的技术成长平台(回溯综合题——与哈希结合)
leetcode39:力扣(LeetCode)官网 - 全球极客挚爱的技术成长平台(排序剪枝)
leetcode40:力扣(LeetCode)官网 - 全球极客挚爱的技术成长平台(树层去重)
回溯算法模板
回溯算法在细节处理上各有不同,但是大体框架上逃不开类似递归遍历的三段式
1.确定函数头
返回值:回溯算法不同于递归,只是进行一个回溯的操作,相关的处理只在同一次搜索进行,所以不需要返回值,一般用void即可。
参数:至于参数,也不同于树的遍历,回溯算法因为其处理逻辑的不同,往往需要穿不同的参数,但是不管怎么样,进行回溯的数组肯定是需要的,可以先传入。
void backtracking(参数)
2.确定终止条件
这个就和树的遍历十分相似了,一般遍历到叶子节点就退出。
在树的遍历中,我们判断的是cur!=nullptr或者cur->left!=nullptr&&cur->right!=nullptr,这是因为树是显式的,但是回溯递归的树是隐式的,就需要特判path数组是否达到了需要的大小,或者是否满足了题设的某条件一般写作:
if (终止条件) {
存放结果;
return;
}
3.确定遍历过程
这一步事实上要比树的遍历简单很多。树的遍历中因为时常需要考虑到是否用到子树的值,因而在前中后序遍历中纠结很久,回溯法就要方便很多,只需要一次递归,递归前处理path和相关数据,递归后再次复原即可。事实上,部分情况下可以把对path和相关数据的处理也塞到递归函数里面,从而简化代码。
for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) {
处理节点;
backtracking(路径,选择列表); // 递归
回溯,撤销处理结果
}
由以上三步,就可以实现一个大体的回溯递归函数
结合下图,不难发现:
所谓回溯,就是一边通过递归不断地深入,在每一层递归中,又用for语句遍历集合,实现对集合所有情况的枚举。
有了上述模板,解决组合问题就差不多了。但是,区分回溯和其他暴力枚举方法的一大特点,还得是回溯特有的剪枝操作
浅谈剪枝
上述leetcode中的题目,都或多或少可以用上一些剪枝操作。
a.首先是对for循环的剪枝,以77为例:
for (int i = startIndex; i <= n; i++) {
path.push_back(i);
backtracking(n, k, i + 1);
path.pop_back();
}
上述代码在i过大,大到根本不能取到k个数的时候,是始终不成立的,在这里可以直接用break跳出实现剪枝。
for (int i = startIndex; i <= n - (k - path.size()) + 1; i++) { // 优化的地方
path.push_back(i); // 处理节点
backtracking(n, k, i + 1);
path.pop_back(); // 回溯,撤销处理的节点
}
b.同时,对于退出条件,我们也能通过越界判定来剪枝,以leetcode216为例:
当sum已经大于target时,再继续搜索是没有意义的(不存在负数元素,只会越加越大),可以通过判断sum和target关系来剪枝:
for (int i = startIndex; i <= 9 - (k - path.size()) + 1; i++) { // 剪枝
sum += i; // 处理
path.push_back(i); // 处理
if (sum > targetSum) { // 剪枝操作
sum -= i; // 剪枝之前先把回溯做了
path.pop_back(); // 剪枝之前先把回溯做了
return;
}
backtracking(targetSum, k, sum, i + 1); // 注意i+1调整startIndex
sum -= i; // 回溯
path.pop_back(); // 回溯
}
c.可以发现,上面的剪枝都是在单调有序的条件下进行的,所以对于无序的一个序列,搜索时可以先排序再剪枝,效果也是一样,例如39题:
for (int i = startIndex; i < candidates.size() && sum + candidates[i] <= target; i++)
上述for循环代码在进行排序的条件下,可以剪去无效的case。
d.记忆化剪枝
这个其实源于递归,就是把经常会递归重复用到的值存在一个数组里面,需要的时候就把他直接从数组中取出,减少递归层数。
具体怎么用,可以尝试做一下洛谷P1464:P1464 Function - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)
题目很简洁,重要的一个考点就是记忆化剪枝了。
除了剪枝,回溯的另一个技巧在于
设置startindex
以leetcode39为例:
class Solution {
private:
vector<vector<int>> result;
vector<int> path;
void backtracking(vector<int>& candidates, int target, int sum, int startIndex) {
if (sum > target) {
return;
}
if (sum == target) {
result.push_back(path);
return;
}
for (int i = startIndex; i < candidates.size(); i++) {
sum += candidates[i];
path.push_back(candidates[i]);
backtracking(candidates, target, sum, i); // 不用i+1了,表示可以重复读取当前的数
sum -= candidates[i];
path.pop_back();
}
}
public:
vector<vector<int>> combinationSum(vector<int>& candidates, int target) {
result.clear();
path.clear();
backtracking(candidates, target, 0, 0);
return result;
}
};
如图的代码中,我们只看backtracking(回溯)函数,在传参时,除了传入基本量,还额外传入了一个startindex。
在这个startindex在不同情况下可以发挥不同的妙用。
仅以此题为例,在递归式中给startindex传入i,可以让下一层递归的for循环略过i之前的元素,实现一个简单的去重(前提是candidates是有序的,所以要先进行排序,这不仅对剪枝有益,对回溯本身同样有益)。
接下来是有关组合问题的重头戏,
组合去重
以此为特点的一道题就是leetcode的40题:
这道题的去重主要还是在树层上操作,并且要略过对树枝的操作,具体参考下图:
都知道组合问题可以抽象为树形结构,那么“使用过”在这个树形结构上是有两个维度的,一个维度是同一树枝上使用过,一个维度是同一树层上使用过。没有理解这两个层面上的“使用过” 是造成大家没有彻底理解去重的根本原因。
那么问题来了,我们是要同一树层上使用过,还是同一树枝上使用过呢?
回看一下题目,元素在同一个组合内是可以重复的,怎么重复都没事,但两个组合不能相同。
所以我们要去重的是同一树层上的“使用过”,同一树枝上的都是一个组合里的元素,不用去重。
为了理解去重我们来举一个例子,candidates = [1, 1, 2], target = 3,(方便起见candidates已经排序了)
强调一下,树层去重的话,需要对数组排序!
在图中去重操作下,应用到了used数组来标记相同元素是在同一树枝还是同一树层。
前面我们提到:要去重的是“同一树层上的使用过”,如何判断同一树层上元素(相同的元素)是否使用过了呢。
如果candidates[i] == candidates[i - 1]
并且 used[i - 1] == false
,就说明:前一个树枝,使用了candidates[i - 1],也就是说同一树层使用过candidates[i - 1]。
此时for循环里就应该做continue的操作。
我在图中将used的变化用橘黄色标注上,可以看出在candidates[i] == candidates[i - 1]相同的情况下:
- used[i - 1] == true,说明同一树枝candidates[i - 1]使用过
- used[i - 1] == false,说明同一树层candidates[i - 1]使用过
可能有的录友想,为什么 used[i - 1] == false 就是同一树层呢,因为同一树层,used[i - 1] == false 才能表示,当前取的 candidates[i] 是从 candidates[i - 1] 回溯而来的。
而 used[i - 1] == true,说明是进入下一层递归,去下一个数,所以是树枝上,如图所示:
去重操作相关讲解来源:代码随想录 (programmercarl.com)
所以单层递归的代码逻辑应该是:
for (int i = startIndex; i < candidates.size() && sum + candidates[i] <= target; i++) {
// used[i - 1] == true,说明同一树枝candidates[i - 1]使用过
// used[i - 1] == false,说明同一树层candidates[i - 1]使用过
// 要对同一树层使用过的元素进行跳过
if (i > 0 && candidates[i] == candidates[i - 1] && used[i - 1] == false) {
continue;
}
sum += candidates[i];
path.push_back(candidates[i]);
used[i] = true;
backtracking(candidates, target, sum, i + 1, used); // 和39.组合总和的区别1:这里是i+1,每个数字在每个组合中只能使用一次
used[i] = false;
sum -= candidates[i];
path.pop_back();
}
这样就解决了组合中去重的问题。