文章目录
- 一、概念
- 二、算法原理
- 三、代码模板
- 四、例题实现
- 1、参数确定
- 2、确定终止条件
- 3、for循环的构建
- 4、AC代码
- Java
- C++
- 5、剪枝优化
- 理论:
- 代码编写方式:
- Java
- C++
一、概念
回溯算法(BackTracking)一种通过递归,实现暴力枚举
得出答案的算法。
你没看错,就是递归+暴力枚举,所以他的算法效率并不高。那么有些小伙伴可能就会问了:关于暴力我已经有for循环这个爸爸了,为什么还要学回溯呢,这不是多此一举???
这个问题提得好!虽然回溯算法看起来效率并不高(当然效率的确不高😂,因为还增加了递归),但是在应对一些复杂问题的时候,我们只能通过回溯来完成,如果使用for循环写会非常麻烦!
对于某些题目,你甚至根本写不出通过for循环实现的枚举!例如这道题目:
题目链接
注意:组合和排列是两个不同的概念: 组合不强调元素的前后顺序,排列反之。 例如[1,2]和[2,1]被认为是同一个组合,但[1,2]和[2,1]是两个不同的排列!
倘若按照题中得示例一(n=4,k=2),用for循环还是比较好写的:
如图代码,只要写两个for循环,就可以找到所有两个数字的组合。
但是如果题目给的参数n=1,000 k=100呢?
难道写100个for循环???
显然通过for循环来实现枚举已经是现实的了,我们需要通过回溯法
来解决这道题目。我们先不着急解决这一道题目,先接着往下看。
二、算法原理
上一节讲到,回溯法本质就是通过递归枚举所有的可能,返回符合情况的结果。
那么回溯法是如何通过递归,枚举所有的可能呢?他为什么这种算法叫回溯,回溯在哪里?
接下来,我会以上一节的例题为例,统一回答这些问题:
在此之前,我们需要了解递归这三个知识:
1、递归程序需要设置一个终止条件,否则就会死循环。
2、任何递归程序的运行过程,可以通过一个树形图来表示,也就是N叉树。
3、想要写出递归,我们要寻找出这个问题的重复子问题,因为递归就是把一个大问题拆分成小的、重复的子问题。
还是以n=4 k=2为例,回溯算法解决这个问题的过程可以形象的表示成这一个树形图:
注意:回溯法通过前序遍历(root \ left \ right)这个N叉树,寻找可能的答案,所以这张图你需要通过前序遍历的方式去观看
图片解析:
1、我们看到,每次递归,都会选取候选集合中的一个数字,放到已选集合中,这就是递归的子问题。
2、递归到第三层的叶子结点后,由于已选集合大小==k,所以终止搜索,向不在向下递归,这就是递归的终止条件。
3、图中的回退操作,就是所谓的回溯。 在搜索遍历到叶子节点后,由于递归的性质,会逐层回退,当前结点中的状态会转变成上一层结点原来的状态。这样做的目的是让每一次枚举的结果都不会被上一次枚举的结果影响。
三、代码模板
理论讲完了,看起来好像有点复杂,但是实际上代码的编写还是比较简单的。
不论什么题目,只要使用回溯算法,代码风格都比较的固定:
//回溯函数
void backtracking(参数) {if (终止条件) {存放结果;return;}for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) {处理节点;backtracking(路径,选择列表); // 递归回溯,撤销处理结果}
}
回溯函数定义的三个要点:
1、确定参数、返回值(返回值一般是void)
2、确定递归终止条件
3、构建合适的for循环
四、例题实现
回溯的套路比较固定,就是构建一个递归函数,一般分为三步:
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
=
实际剩余可选的值
这里其实有一个小问题:
所以正确的推导应该是:n
-i
+1
=
实际剩余可选的值(记作A)
那么如果要避免无意义的搜索,必须满足这样一个大小关系: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;}
};
参考资料:代码随想录