回溯的本质是穷举,也就是暴力求解,它是递归的一部分。
所有回溯法解决的问题都可以抽象为树形结构,因为回溯法解决的都是在集合中递归查找子集,集合的大小构成了树的宽度,递归的深度就构成了树的深度(cr. 代码随想录)。
回溯一般被用于以下几种问题(cr. 代码随想录)的求解中:
- 组合问题:N个数里面按一定规则找出k个数的集合
- 切割问题:一个字符串按一定规则有几种切割方式
- 子集问题:一个N个数的集合里有多少符合条件的子集
- 排列问题:N个数按一定规则全排列,有几种排列方式
- 棋盘问题:N皇后,解数独等等
回溯法之所以常用于解决这些问题,是因为它能够把复杂的组合搜索问题转化为一系列简单的决策,并通过剪枝策略降低搜索量,而其他方法通常要么不够通用,要么求解效率更低。
for
循环:横向遍历void backtracking(参数) {
if (终止条件) {
存放结果;
return;
}
for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) {
处理节点;
backtracking(路径,选择列表); // 递归
回溯,撤销处理结果
}
}
思路是按照顺序取数放入集合,再在剩余的数组中取剩下的 k − 1 k-1 k−1 个数。for
循环中无需担心i+1>end
的情况,因为那样下一层递归不会进入循环。算是半AC,绕了一下,但是最终改出来了。
class Solution {
private:
int end;
vector<vector<int>> ans;
vector<int> count;
public:
void backtracking(int begin, int end, int k){
if(k == 0){
ans.push_back(count);
}
for(int i = begin; i <= end; i++){
if(end - i + 1 < k) return; //剪枝操作
count.push_back(i);
backtracking(i + 1, end, k - 1);
count.pop_back();
}
}
vector<vector<int>> combine(int n, int k) {
backtracking(1, n, k);
return ans;
}
};
注意,代码随想录中提到的 O ( n × 2 n ) O(n × 2^n) O(n×2n) 是针对生成所有子集的情况,因为子集总数是 2 n 2^n 2n,每个子集需要 O ( n ) O(n) O(n) 的处理时间。
而本题是针对固定 k k k 的组合问题,因此解法的时间复杂度为 O ( C ( n , k ) × k ) O(C(n,k) × k) O(C(n,k)×k) ,其中 C ( n , k ) C(n,k) C(n,k) 是组合数。如果 k k k 是固定常数,则 C ( n , k ) C(n,k) C(n,k) 是多项式级别( 大约 O ( n k ) 大约 O(n^k) 大约O(nk)),所以整体复杂度是 O ( n k × k ) O(n^k × k) O(nk×k),当 n n n 较大时,其远低于 O ( n × 2 n ) O(n × 2^n) O(n×2n)。
P.S. C ( n , k ) C(n,k) C(n,k)(也写作 ( n k ) \binom{n}{k} (kn)是组合数学中的一个符号,表示从 n n n 个元素中不考虑顺序选择 k k k 个元素的方案数。其计算公式是:
( n k ) = n ! k ! ( n − k ) ! \binom{n}{k} = \frac{n!}{k!(n-k)!} (kn)=k!(n−k)!n!
和前面一道题有点相似,套用回溯模板还算简单,时间复杂度同上。
class Solution {
private:
vector<vector<int>> ans;
vector<int> path;
public:
void backTracking(int begin, int end, int k, int n){
if(k == 0 && n == 0){
ans.push_back(path);
return;
}
for(int i = begin; i <= end; i++){
if(end - i + 1 < k || i > n) return;
path.push_back(i);
backTracking(i+1, end, k-1, n-i);
path.pop_back();
}
}
vector<vector<int>> combinationSum3(int k, int n) {
backTracking(1,9,k,n);
return ans;
}
};
以下是第一遍的代码,但是事实上并不需要一个额外的for循环,每次处理一个数字即可。并且通常来说对于固定大小和已知元素数量的数据,使用数组往往会比使用 vector
更高效。
class Solution {
private:
string path;
vector<string> ans;
int len;
const vector<string> keyboard = {"abc", "def", "ghi", "jkl", "mno","pqrs", "tuv", "wxyz"};
public:
void backTracking(int start, string &digits, const vector<string> &k){
if(path.size() == len){
ans.push_back(path);
return;
}
for(int i = start; i < len; i++){
int num = digits[i] - '0' - 2;
for(int j = 0; j < k[num].size(); j++){
path.push_back(k[num][j]);
backTracking(i + 1, digits, k);
path.pop_back();
}
}
}
vector<string> letterCombinations(string digits) {
len = digits.size();
if(len == 0) return ans;
backTracking(0, digits, keyboard);
return ans;
}
};
可以优化代码如下:
class Solution {
private:
string path;
vector<string> ans;
int len;
//mark一下: array在定义时必须指定大小
const string keyboard[8] = {"abc", "def", "ghi", "jkl", "mno","pqrs", "tuv", "wxyz"};
public:
void backTracking(int idx, string &digits){
if(path.size() == len){
ans.push_back(path);
return;
}
int num = digits[idx] - '0' - 2;
for(int j = 0; j < keyboard[num].size(); j++){
path.push_back(keyboard[num][j]);
backTracking(idx + 1, digits);
path.pop_back();
}
}
vector<string> letterCombinations(string digits) {
len = digits.size();
if(len == 0) return ans;
backTracking(0, digits);
return ans;
}
};