题目描述
给定两个整数 n 和 k,返回范围 [1, n] 中所有可能的 k 个数的组合。
你可以按 任何顺序 返回答案。
示例 1:
输入:n = 4, k = 2
输出:
[
[2,4],
[3,4],
[2,3],
[1,2],
[1,3],
[1,4],
]
示例 2:
输入:n = 1, k = 1
输出:[[1]]
来源:力扣(LeetCode)
链接:https://leetcode.cn/problems/combinations
算法描述
我一开始直接的想法是使用for循环暴力,例如示例中k为2,很容易想到用两个for循环,这样就可以输出和示例中一样的结果。
int n = 4;
for (int i = 1; i <= n; i++) {
for (int j = i + 1; j <= n; j++) {
cout << i << " " << j << endl;
}
}
如果题目的要求改成n=1000,并且是四个数的组合呢,即 k = 4,很显然就需要四层for循环了。
int n = 1000;
for (int i = 1; i <= n; i++) {
for (int j = i + 1; j <= n; j++) {
for (int u = j + 1; u <= n; u++) {
for(int h = u + 1; h <= n; h++) {
cout << i << " " << j << " " << u << " " << h << endl;
}
}
}
}
可以发现这样的方法在n和k比较小的时候就已经体现出了非常大的时间复杂度。
那么,如何解决组合问题呢?
我们就介绍到了回溯搜索法,它主要用来解决那些暴力枚举也无法解决的问题。虽然回溯法也是暴力,但至少能写出来,不像for循环嵌套k层让人绝望。
那么回溯法怎么暴力搜呢?
回溯算法描述
实际上,对于上面的for循环嵌套,回溯法就是用递归来解决嵌套的问题。
即使用递归来做层叠嵌套(可以理解是开k层for循环),每一次的递归中嵌套一个for循环,那么递归就可用于解决多层嵌套循环的问题了。
所以说回溯问题和递归问题实质上是一类问题,那么回溯与递归是什么关系呢?
回溯可以说是递归的横向拓展,它主要是递归(纵向)+局部暴力枚举(横向),所以我们可以从递归和枚举两个方面来拆解回溯问题。
回溯的关键不在于递归,而是在于“回”上,也就是回去之后要撤销掉之前的操作,具体来说,就是在return回上一层递归之后,要将之前递归的操作消除,也就是要让变量的状态回到第一次递归到该层时的初始状态,因为回溯本质上仍然是枚举。
在回溯算法中,我们可能经常看到“剪枝”这个词,我们知道递归算法的时间复杂度也是比较高的,尤其是在递归较深的时候,所以实际上进行剪枝操作就是要去掉那些不必要的递归,从而提高执行效率。比如在递归到某一层时,我们能够判断出此时结果已经不满足要求了,那么就没必要继续向下递归了,从而提高回溯算法的效率。
回溯算法模板
void backtracking(参数) {
if (终止条件) {
存放结果;
return;
}
for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) {
处理节点;
backtracking(路径,选择列表); // 递归
回溯,撤销处理结果
}
}
不难发现回溯算法的模板其实与在遍历二叉树时的递归模板相差不多,二者最本质的区别就在于,回溯撤销之前层处理结果的操作。
那么,对于这道组合的题目,就不难写出求解方法了@~@
class Solution {
private:
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); // 递归
path.pop_back(); // 回溯,撤销处理的节点
}
}
public:
vector<vector<int>> combine(int n, int k) {
backtracking(n, k, 1);
return result;
}
};
从代码中,我们很清楚的可以发现,其实回溯法就是将嵌套的循环改为了使用递归来处理。
剪枝优化
其实,上述代码还有优化空间。
for(int i = startIndex; i <= n; i++) {
path.push_back(i); // 处理节点
backtracking(n, k, i + 1); // 递归
path.pop_back(); // 回溯,撤销处理的节点
}
比如当题目要求4个数的组合时,即k = 4时,我们可以提前预测出结果就只有一种情况:1234。
很显然,代码中是有多余递归的,那么,为什么会出现多余的递归情况呢?
因为我们在横向迭代时,并没有判断此时剩余的数是否满足条件,而是只有在纵向递归的过程中,有一个if语句的判断。
我们可以稍微在纸上调试一下代码,其实当 i 横向迭代到2的时候,之后的情况就一定不会满足 path.size() == k的条件了,因为此时还未组合的数的数量< 待组合数的数量,所以继续向下递归肯定是多余的,那么,优化的思路就是需要在横向迭代时判断剩余的数是否满足条件,也就是对 i 的条件进一步优化。
已经组合的数的数量为path.size(),所以待组合的数的数量就是( k - path.size() ) ,而未组合的数的数量为( n - i + 1){注意,在for判断时,i 还没有被添加进组合数,所以计算未组合的数的数量要把 i 给算上,即 + 1},二者必须满足 ( n - i + 1) >= ( k - path.size() ),接下的递归才是有意义的,所以就得到了横向递归时要满足的条件 i <= n - ( k - path.size() ) + 1。
for(int i = startIndex; i <= n - (k - path.size()) + 1; i++) {
path.push_back(i); // 处理节点
backtracking(n, k, i + 1); // 递归
path.pop_back(); // 回溯,撤销处理的节点
}
剪枝,实际上就是优化代码,尽量避免代码有多余的处理。