文章目录
一、概念
回溯算法(BackTracking)一种通过递归,实现暴力枚举
得出答案的算法。
你没看错,就是递归+暴力枚举,所以他的算法效率并不高。那么有些小伙伴可能就会问了:关于暴力我已经有for循环这个爸爸了,为什么还要学回溯呢,这不是多此一举???
倘若按照题中得示例一(n=4,k=2),用for循环还是比较好写的:
如图代码,只要写两个for循环,就可以找到所有两个数字的组合。
但是如果题目给的参数n=1,000 k=100呢?
难道写100个for循环???
显然通过for循环来实现枚举已经是现实的了,我们需要通过回溯法
来解决这道题目。
我们先不着急解决这一道题目,先接着往下看。
二、算法原理
上一节讲到,回溯法本质就是通过递归枚举所有的可能,返回符合情况的结果。
那么回溯法是如何通过递归,枚举所有的可能呢?他为什么这种算法叫回溯,回溯在哪里?
接下来,我会以上一节的例题为例,统一回答这些问题:
还是以n=4 k=2为例,回溯算法解决这个问题的过程可以形象的表示成这一个树形图:
注意:回溯法通过前序遍历(root \ left \ right)这个N叉树,寻找可能的答案,所以这张图你需要通过前序遍历的方式去观看
三、代码模板
理论讲完了,看起来好像有点复杂,但是实际上代码的编写还是比较简单的。
不论什么题目,只要使用回溯算法,代码风格都比较的固定:
//回溯函数
void backtracking(参数) {
if (终止条件) {
存放结果;
return;
}
for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) {
处理节点;
backtracking(路径,选择列表); // 递归
回溯,撤销处理结果
}
}
四、例题实现
回溯的套路比较固定,就是构建一个递归函数,一般分为三步:
1、参数确定
n: 表示需要选取的数字范围是1-n。
k: 表示每一个组合的到小是k 。
curIndex: 由于我们需要确保不会枚举到重复的组合,所以我们用一个指针curIndex表示当前选择的数组(从左到右移动)。
2、确定终止条件
第二节的图片解析说明了,搜索的返回条件就是找到k个元素的符合条件的组合就回退,枚举下一个可能。
3、for循环的构建
依据N叉树的示意图,我们只需要从1到N进行for循环,在每一次循环再次调用backTracking(参数)即可。
注意:for循环的具体编写方式不同题目,可能不太一样,需要通过刷题来找感觉。
4、AC代码
Java
class Solution {
//存放满足条件的组合 ans是一个列表,列表中存储着满足条件的所有组合
List<List<Integer>> ans=new ArrayList<>();
//用来存储可能满足条件的一个组合(已选数字)
List<Integer> path=new ArrayList<>();
public List<List<Integer>> combine(int n, int k) {
//1、确定参数
backTracking(n,k,1);
return ans;//记得返回答案
}
private void backTracking(int n,int k,int curIndex){
//2、终止条件:找到k个元素的组合就返回
if(path.size()==k){
//记得先把答案塞到答案集
ans.add(new ArrayList<>(path));
return;
}
//for循环的构建
for(int i=curIndex;i<=n;i++){
//尝试枚举
path.add(i);
backTracking(n,k,i+1);//这里必须curIndex+1 避免枚举到重复的组合
//在递归完成后,根据N叉树示意图,必须还原已选数字!!(回溯)
path.remove(path.size()-1);//这也是回溯 区别于普通递归的地方
}
}
}
C++
class Solution {
private:
vector<vector<int>> result;
vector<int> path;
void backtracking(int n, int k, int curIndex) {
if (path.size() == k) {
result.push_back(path);
return;
}
for (int i = curIndex; i <= n; i++) {
path.push_back(i); // 处理节点
backtracking(n, k, i + 1);
path.pop_back(); // 回溯,撤销处理的节点
}
}
public:
vector<vector<int>> combine(int n, int k) {
backtracking(n, k, 1);
return result;
}
};
5、剪枝优化
理论:
如果把回溯法通过N叉树来理解,对于这道例题,N叉树的某些枝节是没有必要遍历的,可以直接剪短(选4的这个结点):
那么为什么可以剪断这个枝节呢?
很简单,在选择完4之后,就不能在往后面选了呀。如果返回去选1、2、3那么就必定会与之前的枝节发生重复,导致答案重复。
代码编写方式:
根据优化的原理,我们只需要对代码中for循环的边界做一个限制即可:
修改成具体什么值,只用关注这几个变量之间的关系:
- 已选集合的大小:
path.size()
- 候选集合的大小:
n
- 要求组合的大小:
k
- 当前指针指向的数字:
i
k
- path.size()
(要求组合的大小-已选集合的大小)=
剩余需要选择的元素数量 (记作a)
另外,n
- i
=
实际剩余可选的值
那么如果要避免无意义的搜索,必须满足这样一个大小关系:A>=a
即:n-i+1>=k-path.size()
=》i<=n-(k-path.size())+1
Java
class Solution {
List<List<Integer>> ans=new ArrayList<>();
List<Integer> path=new ArrayList<>();
public List<List<Integer>> combine(int n, int k) {
backTracking(n,k,1);
return ans;
}
private void backTracking(int n,int k,int curIndex){
if(path.size()==k){
ans.add(new ArrayList<>(path));
return;
}
for(int i=curIndex;i<=n-(k-path.size())+1;i++){//唯一优化的地方
path.add(i);
backTracking(n,k,i+1);
path.remove(path.size()-1);
}
}
}
C++
class Solution {
private:
vector<vector<int>> result;
vector<int> path;
void backtracking(int n, int k, int curIndex) {
if (path.size() == k) {
result.push_back(path);
return;
}
for (int i = curIndex; i <=n-(k-path.size())+1; i++) { //唯一优化的地方
path.push_back(i);
backtracking(n, k, i + 1);
path.pop_back();
}
}
public:
vector<vector<int>> combine(int n, int k) {
backtracking(n, k, 1);
return result;
}
};
参考资料:代码随想录