【LeetCode】【回溯】77. 组合
【回溯】77. 组合
知识点:递归;回溯;组合;剪枝
题目描述
给定两个整数 n 和 k,返回范围 [1, n] 中所有可能的 k 个数的组合。
你可以按 任何顺序 返回答案。
示例
输入:n = 4, k = 2
输出:
[
[2,4],
[3,4],
[2,3],
[1,2],
[1,3],
[1,4],
]
输入:n = 1, k = 1
输出:[[1]]
解法一:回溯
这是回溯的第一道题,回溯是很经典的一个算法,什么是回溯,回溯其实是一种暴力枚举的方式,为啥都暴力了还是很经典的一种方法呢,其实是因为有些问题我们能暴力出来就不错了,就别要其他自行车了。常见的回溯类问题:组合;排列;切割;子集;棋牌;
比如最经典的排列。从1,2,3,4,5中取3个数组成排列有多少种,我们肯定会解决这种问题,但是程序怎么写呢。想一下我们解决这个问题的过程,我们先选1,然后第二个数可以选2,第三个数可以选3,这是一种答案了,然后呢,换第三个数,第三个数选4,又一种答案,再换,第三个数选5,没得选了,所以以12打头的数都选完了,得到三种答案.然后再换第二个数,第二个数选3,然后第三个数选4,注意是组合问题所以我们不能退往回选2了,不然就重复了。就是这样一种选择方案,一直到第3个数选成了3,得到答案345,就不用往后进行了。
这个过程中有递归吗?当然有啊,我们把问题缩小一点,比如123三个数字的全排列,首先1打头,得到123,132,这其实就是1+[2,3]的全排列;递归体现在这里!
你看,这其实就是一个多叉树啊!每走一步我们都要做出自己的选择,然后在该选择的基础上做下一步选择,直到这个选择达到了题目要求,然后我们放弃我们上一步做的选择,去换另外一种选择试一试。这个换掉我们上一步做的选择就是回溯的过程,也就是“撤销选择”。因为只有把上一步的选择撤销了我们才能够得到新的选择,比如123,只有把3撤销了我们才能去选择4.
回溯和递归相辅相成,前面也说过了这就是一颗树,而树就一定会用到递归。这棵树我们起了一个名字叫做决策树,每走一步都是在做一次选择一次决策,就和我们的人生一样。想象一下回溯、深度优先搜索(DFS),递归,都有一种“不撞南墙不死心" 的意思,而这个南墙就是我们的结束条件。
- 路径:记录我们做出了的选择(走过的决策树上的路径,我们一般都是在最后的叶子节点上去收集结果);【比如我们选的123,124】;
- 选择列表:当前情况下我们可以做出的选择;【比如在第三步我们可以选3.4.5】
- 结束条件:也就是到达了决策树的底层叶子节点,选择列表为空了,无法再做出别的选择了。【比如我们的树选完了123达到题目中的要求3个元素了,就不能够再做选择了】
回溯算法的模板:
result = [] //结果集
def backtrack(路径, 选择列表):
if 满足结束条件:
result.add(路径) //把已经做出的选择添加到结果集;
return //一般的回溯函数返回值都是空;
for 选择 in 选择列表: //其实每个题的不同很大程度上体现在选择列表上,要注意这个列表的更新,
//比如可能是搜索起点和终点,比如可能是已经达到某个条件,比如可能已经选过了不能再选;
做选择 //把新的选择添加到路径里;路径.add(选择)
backtrack(路径, 选择列表) //递归;
撤销选择 //回溯的过程;路径.remove(选择)
核心就是for循环里的递归,在递归之前做选择,在递归之后撤销选择;
在这个过程中还有一点很重要,就是我们其实是在做两种遍历;
- 横向遍历(for):其实就是我们在不停的做着的选择;
- 纵向遍历(递归):其实就是在做完选择后面临的下一轮选择;
其实不同的情境下最大的不同就在于决策列表的更新,比如说搜索起点和终点,比如说是否已经被选过,比如说是否达到某个条件(只要求k个数或者和为目标值);
回到本题上,本题如果画图的话就是下面的图,也就是本题对应的决策树。
- 需要有一个路径变量记录做出的选择,而且要能够撤销选择,所以可以选择栈结构;
- 每一个节点都是在做同样的事情,只不过选择列表不一样了而已,而选择列表不一样是因为开始区间不一样了(尾区间都是最后一个元素),所以需要一个start变量来限制选择列表;
另一种角度:从遍历的角度来看决策树;
- 横向遍历(for):从左到右做决策;
- 纵向遍历(递归):在做完决策后开始下一轮决策;
class Solution {
public List<List<Integer>> combine(int n, int k) {
List<List<Integer>> res = new ArrayList<>();
if(k <= 0 || k > n){
return res;
}
Stack<Integer> path = new Stack<>();
backtrack(n, k, 1, path, res); //题目中说的从1开始;
return res;
}
private void backtrack(int n, int k, int begin, Stack<Integer> path, List<List<Integer>> res){
//结束条件:收割结果;
if(path.size() == k){
res.add(new ArrayList<>(path)); //注意这里一定要新建一个;自始至终维持的是一个path;
return;
}
//遍历选择列表;
for(int i = begin; i <= n; i++){
//做选择;
path.push(i);
//递归:下一轮选择(注意选择列表的更新:选择列表变小,开始+1);
backtrack(n, k, i+1, path, res);
//撤销选择:回溯;
path.pop();
}
}
}
解法二:剪枝优化
上述程序有优化的空间,还拿熟悉的12345举例子,我们选3个,其实选完3就不用再往后继续了,因为即使选了4,也凑不够3个数了,所以我们的选择列表是还可以优化的,也就是我们的选择起点是有上界的!
选择起点与当前还需要再选几个数有关,而当前还需要再选几个数与已经选了几个数有关,也就时path的长度有关;
比如n=6,k=4;
- path.size()=1, 那接下来还需要再选3个数,搜索起点最大是4,最后一个被选的是456;
- path.size()=3, 那接下来还需要再选1个数,搜索起点最大是6,最后一个选的是6;
所以,搜索起点的上界+接下来要选择的元素个数-1 = n;
接下来要选择的元素个数 = k-path.size();
所以搜索起点的上界 = n-(k-path.size())+1;
所以我们的i <= n 要改为 i <= n-(k-path.size())+1
下面就是我们得到的剪枝树:
class Solution {
public List<List<Integer>> combine(int n, int k) {
List<List<Integer>> res = new ArrayList<>();
if(k <= 0 || k > n){
return res;
}
Stack<Integer> path = new Stack<>();
backtrack(n, k, 1, path, res); //题目中说的从1开始;
return res;
}
private void backtrack(int n, int k, int begin, Stack<Integer> path, List<List<Integer>> res){
//结束条件:收割结果;
if(path.size() == k){
res.add(new ArrayList<>(path)); //注意这里一定要新建一个;自始至终维持的是一个path;
return;
}
//遍历选择列表;
for(int i = begin; i <= n-(k-path.size())+1; i++){
//做选择;
path.push(i);
//递归:下一轮选择(注意选择列表的更新:选择列表变小,开始+1);
backtrack(n, k, i+1, path, res);
//撤销选择:回溯;
path.pop();
}
}
}
时间复杂度:
体会
- 1.只要是涉及到做选择的,尤其是提到的五个类型:组合、排序、分割、子集、棋盘。这种都可以构建一颗决策树,那就都可以用回溯算法去解。解之前先自己把决策树画出来。
- 2.整体上套用模板,最大的不同就在于选择列表的更新,要能够根据题目中的要求来更新选择列表,比如到达某个深度了,比如和为某个值了等等;
- 3.在求和问题中,排序之后加上剪枝是很常见的操作,能够舍弃无关的操作(和已经到达某一值了,因为排过序,其后的值就更大了);