目录
回溯算法理论基础
1.什么是回溯算法
回溯算法也可以叫做回溯搜索算法,简称回溯法,是一种搜索的方式。
回溯是递归的“副产品”,只要有递归的地方就会有对应回溯的过程。在后续内容中,回溯函数就是递归函数,指的都是一个函数。
2.回溯法可以解决的问题
- 组合问题:如何按照一定规则在N个数中找出k个数的集合
- 切割问题:一个字符串按照一定的规则切割,有几种切割方式
- 子集问题:一个N个数的集合中几个符合条件的子集
- 排列问题:N个数按一定规则全排列,有几种排列方式
- 棋盘问题:N皇后、数独等问题。
注:有些同学可能分不清组合和排列,只需要记住组合无序,排列有序即可。
3.回溯法模板
回溯法解决的问题都可以抽象为树形结构,因为回溯法解决的都是在集合中递归查找子集,集合的大小就构成了树的宽度,递归的深度构成了树的深度。
递归就要有终止条件,所以必然是一颗高度有限的树。
回溯 “三部曲” :
(1)确定回溯函数的返回值和参数。
回溯算法的返回值一般为 void。
因为回溯算法需要的参数并不像二叉树的递归过程那么容易一次性确定下来,所以一般是先写逻辑,然后需要什么参数就填什么参数。
(2)确定回溯的终止条件。
既然是树形结构,那么遍历树形结构就一定要有终止条件,所以回溯也要有终止条件。
什么时候达到了终止条件?从树中就可以看出,一般搜索到叶子结点了,也就找到了满足条件的一个答案,把这个答案存放起来,并结束本层递归。
所以回溯算法的伪代码如下:
if(终止条件){
存放结果;
return;
}
(3)确定回溯算法的遍历过程。
集合的大小构成了树的宽度,递归的深度构成了树的深度。
回溯函数遍历过程的伪代码如下:
for(选择:本层集合中的元素(树中节点孩子的数量就是集合的大小)){
处理结点;
backtracking(路径,选择列表); //递归
回溯,撤销处理结果 ;
}
for 循环的作用就是遍历集合区间,可以理解为一个结点有多少个孩子,这个for循环就执行多少次。backtracking函数则是调用自己,实现递归。
for 循环可以理解为横向遍历,递归过程则是纵向遍历,这样就把这棵树全遍历了。一般来说,搜索到叶子节点就是找到了其中一个结果了。
回溯算法的模板如下:
void backtracking(参数){
if(终止条件){
存放结果;
return;
}
for(选择:本层集合中的元素(树中节点孩子的数量就是集合的大小)){
处理结点;
backtracking(路径,选择列表); //递归
回溯,撤销处理结果;
}
}
注:这份模板很重要,后面求解回溯法相关题目都靠他了。
LeetCode里的组合问题
对于很多组合问题,我们往往可以使用 for 循环进行暴力搜索,但是在某些情况下,求解问题需要层数过多的for循环,那么代码就不具有可行性。所以我们需要用回溯法来解决嵌套层数过多的问题。
通常情况下,利用大脑来模拟回溯搜索的过程是很困难的,所以我们往往利用树形结构来理解。所以,碰见回溯问题时需要想办法将其转化为树形图来进行理解。
最后,在下面的每一道题中,我们都将使用回溯法 “三部曲” 来解题。
例题一
回溯法 “三部曲” 如下:
(1)确定递归函数的返回值和参数
这里要定义两个全局变量,一个用来存放符合条件的单一结果,另一个用来存放符合条件的结果的集合。
题目中说从 n 个树中取 k 个数,那么就还有n和k两个int类型的参数。这里还需要一个int类型的变量startindex,这个参数用来记录本层递归中,集合从哪里开始遍历。因为每次从集合中选出元素后,可选择的范围逐渐收缩,需要用startindex来调整可选择的范围。
整体代码如下:
vector<vector<int>> result; //存放符合条件的结果的集合
vector<int> path; //用来存放符合条件的结果
void backtracking(int n,int k,int startindex)
(2)确定回溯函数的终止条件
这个其实很好想,当path数组的大小达到了题目所给的k,则说明我们找到了子集大小为 k 的组合了。此时使用result二位数组保存path数组,并终止本层递归。代码如下:
if(path.size()==k){
result.push_back(path);
return;
}
(3)确定单层搜索的过程
回溯法的搜索过程就是一个树形结构的遍历过程,for循环用来横向遍历,递归则是纵向遍历。for循环每次从startindex开始遍历,然后用数组保存获取的节点 i。代码如下:
for(int i=startindex ; i<=n ; i++)
{
path.push_back(i); //处理节点
backtracking(n,k,i+1); //递归:控制树的纵向遍历,下一层搜索从i+1开始
path.pop_back(); // 回溯,撤销处理的结点
}
可以看出backtracking通过不断调用自己一直往深处遍历,总会遇到叶子节点,遇到叶子节点就返回。backtracking后续的操作就是回溯,即撤销本次处理的结果。完整代码如下:
class Solution {
public:
vector<vector<int>> result;
vector<int> path; //用来存放符合条件的结果
void backtracking(int n,int k,int startindex){
if(path.size()==k){
result.push_back(path);
return;
}
for(int i=startindex ; i<=n ; i++)
{
path.push_back(i); //处理节点
backtracking(n,k,i+1); //递归:控制树的纵向遍历,下一层搜索从i+1开始
path.pop_back(); // 回溯,撤销处理的结点
}
}
vector<vector<int>> combine(int n, int k) {
result.clear();
path.clear();
backtracking(n,k,1);
return result;
}
};
ps:剪枝优化
举个例子,如果 n=4、k=4,那么在第一层循环中,从元素2开始的遍历都没有意义。在第二次for循环中,从元素3开始的遍历都没有意义。画叉的部分为优化掉的分支。
所以,可以剪枝的位置就在递归过程中每一层for循环所选择的起始位置。如果起始位置之后的元素个数以及少于题目要求的元素个数,那么就没有必要进行搜索了。注意,i 变量就是for循环的起始位置。
综上,优化后的代码如下:
for(int i=startindex ; i<=n-(k-path.size())+1 ; i++)
例题二
回溯 “三部曲” 如下:
(1)确定递归的参数。
这里依然先定义两个全局变量,二维数组result用于存放结果的集合,数组path用于存放单个结果。题目中给出的两个参数:集合candidates以及目标值target。此外还需要int类型的sum来统计单一结果path的总和。最后需要int类型的startindex来控制for循环变量的起始位置。
对于组合问题,是时候需要用到startindex呢?
如果是在一个集合中求元素组合,那么就需要startindex。如果是在多个集合中求元素组合,各集合之间互不影响,那么就不需要startindex。代码如下:
vector<vector<int>> result;
vector<int> path;
void backtracking(vector<int>& candidates,int sum,int target,int startindex)
(2)确定终止条件。
终止只有两种情况,sum大于target和sum等于target。当sum等于target时收集结果。代码如下:
if(sum>target){
return;
}
if(sum==target){
result.push_back(path);
return;
}
(3)确定单层搜索的逻辑
单层for循环依然从startindex开始搜索candidates集合。但是,本题中的元素是可重复选取的。重复选取元素的逻辑代码如下:
for(int i=startindex;i<candidates.size();i++)
{
sum+=candidates[i];
path.push_back(candidates[i]);
// 关键点:不需要i+1,表示可以重复读取当前的数
backtracking(candidates,sum,target,i);
sum-=candidates[i]; // 回溯
path.pop_back(); // 回溯
}
整体代码如下:
class Solution {
public:
vector<vector<int>> result;
vector<int> path;
void backtracking(vector<int>& candidates,int sum,int target,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]);
// 关键点:不需要i+1,表示可以重复读取当前的数
backtracking(candidates,sum,target,i);
sum-=candidates[i]; // 回溯
path.pop_back(); // 回溯
}
}
vector<vector<int>> combinationSum(vector<int>& candidates, int target) {
backtracking(candidates,0,target,0);
return result;
}
};
ps:剪枝优化
对于sum大于target的情况,代码逻辑依然进入了下一层递归,只是下一层递归结束前会做判断,如果sum大于target就返回。如果知道下一层的sum会大于target就没必要进入下一层递归了。这时,可以在for循环的搜索范围上做文章。
这里我们需要先将candidates集合进行排序,如果下一层的sum(就是本层的sum+candidates[i])大于target,则结束本轮的循环。剪枝操作的代码如下:
class Solution {
public:
vector<vector<int>> result;
vector<int> path;
void backtracking(vector<int>& candidates,int sum,int target,int startindex){
if(sum>target){
return;
}
if(sum==target){
result.push_back(path);
return;
}
// 如果sum+candidates[i]>target就终止遍历
for(int i=startindex;i<candidates.size()&&sum+candidates[i]<=target;i++)
{
sum+=candidates[i];
path.push_back(candidates[i]);
// 关键点:不需要i+1,表示可以重复读取当前的数
backtracking(candidates,sum,target,i);
sum-=candidates[i]; // 回溯
path.pop_back(); // 回溯
}
}
vector<vector<int>> combinationSum(vector<int>& candidates, int target) {
sort(candidates.begin(),candidates.end()); //需要排序
backtracking(candidates,0,target,0);
return result;
}
};
例题三
这道题目在使用回溯 “三部曲” 之前,我们需要先捋一下思路:
- 本题的candidates中的数字在每个组合中只能使用一次。
- 本题的candidates中有重复的元素。
本题的难点就在于:集合中有重复的元素,但不能有重复的组合。解决的办法就是在搜索过程中去掉重复的组合。这里需要引入两个概念:
- 同一树枝上 “使用过”;
- 同一树层上 “使用过”;
那么在本题中,元素在同一个组合内是可以重复的,但两个组合不能相同。所以我们要去重的是同一树层上“使用过”的元素,同一树枝上的元素都是同一个组合里的,不用去重。这里要注意的是,树层去重需要对数组进行排序。
回溯 “三部曲” 如下:
(1)确定递归函数的参数
本题需要增加一个bool类型的used数组,用来记录同一树枝上的元素是否使用过。剩下的参数就是与上面相同的全局变量以及题目所给的candidates和target。代码如下:
vector<vector<int>> result;
vector<int> path;
void backtracking(vector<int> &candidates,int sum,int target,
int startindex,vector<bool>& used)
(2)确定终止条件
if(sum>target){
return;
}
if(sum==target){
result.push_back(path);
return;
}
(3)确定单层搜索的逻辑
这里主要就是去重操作,如果递归的过程中出现重复,那么本层的for循环执行continue操作。我们以下图为例进行说明。
在candidates[i]与candidates[i-1]相同的前提下:
- 如果used[i-1]==true,则说明同一树枝使用过candidates[i-1]。
- 如果used[i-1]==false,则说明同一树层使用过candidates[i-1]。
整体代码如下:
class Solution {
public:
vector<vector<int>> result;
vector<int> path;
void backtracking(vector<int> &candidates,int sum,int target,int startindex,vector<bool>& used){
if(sum>target){
return;
}
if(sum==target){
result.push_back(path);
return;
}
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-1]==candidates[i] && used[i-1]==false){
continue;
}
path.push_back(candidates[i]);
used[i]=true;
sum+=candidates[i];
//i+1表示每个元素只能使用一次
backtracking(candidates,sum,target,i+1,used);
sum-=candidates[i];
used[i]=false;
path.pop_back();
}
}
vector<vector<int>> combinationSum2(vector<int>& candidates, int target) {
vector<bool> used(candidates.size(),false);
// 排序让其相同的元素挨在一起
sort(candidates.begin(),candidates.end());
backtracking(candidates,0,target,0,used);
return result;
}
};