理论基础
我们将回溯问题抽象为树形结构。回溯解决的问题是在集合中递归查找子集,集合的大小构成了树的宽度,递归的深度构成了树的深度。
回溯是递归的副产品,有递归就会有回溯。
因而回溯和递归一样,有三部曲:
参数和返回值
终止条件
递归逻辑
解决特定的问题时需要用到回溯算法:
组合问题:N个数里面按一定规则找出k个数的集合
切割问题:一个字符串按一定规则有几种切割方式
子集问题:一个N个数的集合里有多少符合条件的子集
排列问题:N个数按一定规则全排列,有几种排列方式
棋盘问题:N皇后,解数独等等
例题-组合
题目描述
给定两个整数 n 和 k,返回范围 [1, n] 中所有可能的 k 个数的组合。
你可以按 任何顺序 返回答案。
力扣 77.组合
分析
如果我们输入4,2,即在[1,4]范围内的两个数的组合,则有以下情况。
当然对应这组数据,我们用两层for循环即可得到结果,但如果是k个数的组合,那么就要k层for循环,这对于k很大的情况是不可行的。
那么我们使用回溯算法,如图所示。纵向上,每递归一次,向vector<int> path中push_back一个值,直到path.size()==k,将其push_back到vector<vector<int>> res 中。而在横向上,每一层又for循环遍历所有结果。
回溯是递归的副产品,所以回溯的过程实际上就是递归的过程。那么,我们用递归三部曲来分析一下它吧。
递归三部曲
调用参数和返回值:参数除了输入的n和k,还要再添加一个参数startIndex来记录递归的起始位置。返回值则为vector<vector<int>>类型。
终止条件:path.size()==k时终止递归。
递归逻辑:用for循环进行横向遍历,递归进行纵向遍历,递归到终止条件后将path放入res中。最后通过回溯清空path,进行下一次横向遍历。代码如下:
for (int i = startIndex; i <= n; i++) { // 控制树的横向遍历
path.push_back(i); // 处理节点
backtracking(n, k, i + 1); // 递归:控制树的纵向遍历,注意下一层搜索要从i+1开始
path.pop_back(); // 回溯,撤销处理的节点
}
完整代码:
#include <iostream>
#include <vector>
using namespace std;
class Solution{
private:
vector<int> path;
vector<vector<int>> res;
void backtracking(int n, int k, int startIndex) {
if (path.size() == k) {
res.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 res;
}
};
int main(){
Solution s;
vector<vector<int>> res = s.combine(4, 2);
for (auto i : res) {
for (auto j : i) {
cout << j << " ";
}
cout << endl;
}
return 0;
}
backtracking方法被放置在private部分是为了隐藏实现细节并且将其视为内部方法。这样做可以防止外部代码直接调用backtracking方法,以避免不正确的使用和潜在的错误。通过将backtracking方法放置在private部分,可以强调它是作为combine方法的辅助函数,而不是作为公共API的一部分。
这种做法也符合封装的原则,即将实现细节隐藏在类的内部,限制外部对内部数据和方法的直接访问,以提高类的安全性和可维护性。
优化方法
即便回溯算法是一种暴力算法,它也可以优化。
我们可以通过剪枝,删除一些不必要的过程,从而优化代码,提高效率。可以从下面的图示里看出:
可以看出:
第一层:从[1,4]四个数里取三个数,则 i 在[1,2]范围内
第二层:从[2,4]三个数里取两个数,则 i 在[2,3]范围内
我们可以总结出如下规律:
对于横向遍历,第startIndex层 i 范围是:[startIndex , n-(k-path.size())+1]
即:for (int i = startIndex; i <= n - (k - path.size()) + 1; i++)
class Solution{
//private:
// vector<int> path;
// vector<vector<int>> res;
// void backtracking(int n, int k, int startIndex) {
// if (path.size() == k) {
// res.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 res;
// }
//优化
private:
vector<int> path;
vector<vector<int>> res;
void backtracking(int n, int k, int startIndex) {
if (path.size() == k) {
res.push_back(path);
return;
}
for (int i = startIndex; i <= n-k+path.size()+1; i++) {
path.push_back(i);
backtracking(n, k, i + 1);
path.pop_back();//这里要pop_back,因为每一次都要回溯到上一个状态,所以要pop_back
}
}
public:
vector<vector<int>> combine(int n, int k) {
backtracking(n, k, 1);
return res;
}
};
总结
在特定问题时需要使用回溯算法(例如组合问题),回溯实际上是递归的副产品,因而效率并不高,需要尽可能地剪枝优化。