1. 回溯算法理论基础
1.1 回溯法
回溯法也可以叫做回溯搜索法,它是一种搜索的方式。
回溯是递归的副产品,只要有递归就会有回溯。
因此,回溯函数也就是递归函数,指的都是一个函数。
1.2 回溯法的效率
回溯的本质是穷举,穷举所有可能,然后选出我们想要的答案,如果想让回溯法高效一些,可以加一些剪枝的操作,但也改不了回溯法就是穷举的本质。
但由于有些题目用一般的暴力求解也无法求解,能用回溯法暴力求解也已经很可以了。
1.3 回溯法解决的问题
回溯法,一般可以解决如下几种问题:
- 组合问题:N个数里面按一定规则找出k个数的集合
- 切割问题:一个字符串按一定规则有几种切割方式
- 子集问题:一个N个数的集合里有多少符合条件的子集
- 排列问题:N个数按一定规则全排列,有几种排列方式
- 棋盘问题:N皇后,解数独等等
1.4 如何理解回溯法
回溯法解决的问题都可以抽象为树形结构。
因为回溯法解决的都是在集合中递归查找子集,集合的大小就构成了树的宽度,递归的深度,都构成的树的深度。(这句话很关键)
递归就要有终止条件,所以必然是一棵高度有限的树(N叉树)。
1.5 回溯法的模板
回溯三部曲:
1.5.1 回溯函数模板返回值以及参数
函数起名字为backtracking,这是业内常用起名
返回值一般是void
参数一般是先写逻辑,然后需要什么参数,就填什么参数
void backtracking(参数)
1.5.2 回溯函数终止条件
什么时候达到了终止条件,树中就可以看出,一般来说搜到叶子节点了,也就找到了满足条件的一条答案,把这个答案存放起来,并结束本层递归。
if (终止条件) { 存放结果; return; }
1.5.3 回溯函数遍历过程
在上面我们提到了,回溯法一般是在集合中递归搜索,集合的大小构成了树的宽度,递归的深度构成的树的深度
注意图中,我特意举例集合大小和孩子的数量是相等的!
for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) { 处理节点; backtracking(路径,选择列表); // 递归 回溯,撤销处理结果 }
for循环就是遍历集合区间,可以理解一个节点有多少个孩子,这个for循环就执行多少次。
backtracking这里自己调用自己,实现递归。
大家可以从图中看出for循环可以理解是横向遍历,backtracking(递归)就是纵向遍历,这样就把这棵树全遍历完了,一般来说,搜索叶子节点就是找的其中一个结果了。
1.6 回溯算法模板框架
void backtracking(参数) { if (终止条件) { 存放结果; return; } for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) { 处理节点; backtracking(路径,选择列表); // 递归 回溯,撤销处理结果 } }
2. LeetCode 77.组合
2.1 思路
- 这题为什么用回溯算法?因为这题想用一般的暴力求解时,那层循环是由k(数位)决定,因此嵌套循环层数无法确定。那么回溯算法如何实现嵌套循环的效果的呢?其实回溯算法就是通过递归来控制有多少层for循环,递归的每一层就是一层for循环,往后一层就是下一层for循环。
- 由于回溯算法能解决的问题都可以抽象成一个树形结构。
- 这个树形结构中叶子节点就是我们要求的所有的组合。
层数=组合大小=回溯深度。
可能会差异为什么第二层中取2以后子集合只剩下“34”没有“1”了呢?如果还有1,那下面叶子节点就多一个“21”,就与“12”重复了,这就是“组合”与“排列”的区别。
以下是回溯三部曲 - 回溯(递归)函数的参数和返回值:返回值一般都是void,函数名backtracking,参数呢?题目的答案中一个组合就是一个数组,把这个一维数组称为path,这题取数的过程就是求路径的过程,二维数组为result,这两个数组都定义为全局变量,参数需要n和k、startIndex,n表示n个数,k表示组合大小,startIndex,这个就是用来表示子集合为什么第一个是从2开始,第二个是从3开始,就是表示要搜索的起始位置
- 终止条件:到叶子节点就是结束了。怎么看出是到叶子节点呢?就是结果的大小长度就是组合的大小,就是如果path.length==k就可以result.add(path)然后return了
- 单层搜索(递归)的逻辑:每一个节点就是一个for循环,起始位置就是从startIndex开始的,然后遍历剩余的元素,for(int i=startIndex;i<=n;i++)注意这里是小于等于n,因为最开始startIndex是从1开始的。接着就是path把i加入到数组中。然后就是进入递归函数的过程,不同的是传入的startIndex要+1,也就是i+1。然后就是回溯的过程了,path要把元素弹出去,为什么呢?比如我们已经取到了“12”了,那么我们要取“13”前是不是要把“2”先弹出才能取到我们的“12”对吧。
2.2 代码
// class Solution { List<List<Integer>> result= new ArrayList<>(); LinkedList<Integer> path = new LinkedList<>(); public List<List<Integer>> combine(int n, int k) { backtracking(n,k,1); return result; } public void backtracking(int n,int k,int startIndex){ if (path.size() == k){ result.add(new ArrayList<>(path)); return; } for (int i =startIndex;i<=n;i++){ path.add(i); backtracking(n,k,i+1); path.removeLast(); } } }
2.3 剪枝优化思路
- 以这个为例子,第二层取2以后包括2也才剩下3个元素,怎么样也取不到4个数为组合了,后面也是一样,这些分支就可以做剪枝了,这个剪的枝如果不剪,那下面可能还有扩展,那么这个剪枝的效果就很明显了
- 回溯函数的参数和返回值:同上述普通思路
- 终止条件:同上述普通思路
- 单层搜索的逻辑:回溯算法通常都是递归函数里嵌套着for循环,而这题里每个节点都是for循环的过程。上面说到for(int i=startIndex;i<=n;i++),接着就是path把i加入到数组中。然后就是进入递归函数的过程。然后就是回溯的过程了,path要把元素弹出去。
- 而我们现在优化的就是要剪去节点的子孩子,有些没必要去搜索的,优化的地方就是在for循环里。
- 从上面的例子可以看出i=1可以继续往下搜索,i=2后面的都没必要搜索了,那我们知道path.size()是我们的当前已经选取结果的长度,我们还剩k-path.size()的元素要取,因为一共要取k个。那我们还要选取的元素至多可以让你从n-(k-path.size())+1开始搜索,+1是因为包括startIndex的位置。举个例子,4个元素选3个,最多让你从第2个元素开始选,所以就是4-3+1=2。k-path.size 表示path数组还能放几个元素, 再用n - 他们的和就是计算我从哪个下标开始能够满足path放k个元素
- 大多数回溯算法要做剪枝都是从这个i的范围做文章的,就是你思考是否可以做剪枝,就思考一下这个范围是不是有点大了,是否可以适当缩小一下
2.4 代码
// class Solution { List<List<Integer>> result = new ArrayList<>(); LinkedList<Integer> path = new LinkedList<>(); public List<List<Integer>> combine(int n, int k) { combineHelper(n, k, 1); return result; } /** * 每次从集合中选取元素,可选择的范围随着选择的进行而收缩,调整可选择的范围,就是要靠startIndex * @param startIndex 用来记录本层递归的中,集合从哪里开始遍历(集合就是[1,...,n] )。 */ private void combineHelper(int n, int k, int startIndex){ //终止条件 if (path.size() == k){ result.add(new ArrayList<>(path)); return; } for (int i = startIndex; i <= n - (k - path.size()) + 1; i++){ path.add(i); combineHelper(n, k, i + 1); path.removeLast(); } } }