11 回溯
前言
如果要构造长度为\(2\)的字符串,可以写一个二重循环:
for x in "abc"
for y in "def"
外层枚举第\(1\)个字母,内层枚举第\(2\)个字母,这样可以。
但是如果要构造长度为\(3\)或者\(4\)或者不确定呢?
原问题:构造长度为\(n\)的字符串\(\rightarrow\)枚举\(1\)个字母
子问题:构造长度为\(n-1\)的字符串
像这样增量构造答案的过程通常用递归实现。
思考回溯问题:
- 当前操作?枚举\(path[i]\)要填入的字母
- 子问题?dfs(i)表示构造字符串\(\geq i\)的部分
- 下一个子问题?构造字符串\(\geq i+1\)的部分
1 电话号码的字母组合

1.1 代码实现
点击查看代码
class Solution {
public:
vector<string> letterCombinations(string digits) {
unordered_map<char, string> hash_table {
{'2', "abc"},
{'3', "def"},
{'4', "ghi"},
{'5', "jkl"},
{'6', "mno"},
{'7', "pqrs"},
{'8', "tuv"},
{'9', "wxyz"},
};
vector<string> ans;
int n = digits.size();
// dfs(i)表示确定第i个索引对应的数字
string path(n, 0);
auto dfs = [&](this auto&& dfs, int i) {
if (i == n) {
ans.emplace_back(path);
return;
}
for (auto& c: hash_table[digits[i]]) {
path[i] = c;
dfs(i + 1);
}
};
dfs(0);
return ans;
}
};
- 时间复杂度:\(n*4^n\)(可以从答案的角度来理解,\(digits\)的长度为\(n\),那么第\(1\)个字符就有\(4\)种可能,不断组合,最多有\(4^n\)种;又因为每次记录答案都需要\(O(n)\)的拷贝。)
- 空间复杂度:\(O(n)\)
子集型回溯(包括\(0-1\)背包问题)
2 子集

2.1 解题思路
2.1.1 法一:
站在输入的角度思考问题。
每个元素都可以选 or 不选。
根节点是空的,对于第\(1\)个元素,选 or 不选,生成二叉树;然后左根节点 和 又根节点 再对第\(2\)个元素进行判断,选 or 不选......叶子节点是答案。
思考回溯问题:
- 当前操作?枚举第\(i\)个数,选 or 不选
- 子问题?dfs(i)表示从下标\(\geq i\)的数字中构造子集
- 下一个子问题?从下标\(\geq i+1\)的数字中构造子集
2.1.2 法二:
站在构造答案的角度思考问题。
根节点为空。
每次必须选一个数,枚举答案的第\(1\)个数选谁,可能是\(1,2,3\);然后下一层,看第\(2\)个数选谁。如果第\(1\)层选择了\(1\),那么第二层可能选择\(2,3\)......每个节点都是答案。
\([1,2]\)和\([2,1]\)本质是同一个子集
思考回溯问题:
- 当前操作?枚举一个下标为\(j \geq i\)的数字
- 子问题?从下标\(\geq i\)的数字中构造子集
- 下一个子问题?从下标\(\geq j+1\)的数字中构造子集
2.2 代码实现
2.2.1 法一:
点击查看代码
class Solution {
public:
vector<vector<int>> subsets(vector<int>& nums) {
vector<vector<int>> ans;
int n = nums.size();
if (n == 0) {
return {};
}
vector<int> subset;
auto dfs = [&](this auto&& dfs, int i) {
if (i == n) {
ans.push_back(subset);
return;
}
// 如果选择的话
subset.emplace_back(nums[i]);
dfs(i + 1);
subset.pop_back();
// 如果不选择当前元素
dfs(i + 1);
};
dfs(0);
return ans;
}
};
- 时间复杂度:\(O(n*2^n)\)
- 空间复杂度:\(O(n)\)
2.2.2 法二:
点击查看代码
class Solution {
public:
vector<vector<int>> subsets(vector<int>& nums) {
vector<vector<int>> ans;
int n = nums.size();
if (n == 0) {
return {};
}
vector<int> subset;
auto dfs = [&](this auto&& dfs, int i) {
ans.emplace_back(subset);
if (i == n) {
return;
}
for (int j = i; j < n; ++j) {
subset.emplace_back(nums[j]);
dfs(j + 1);
subset.pop_back();
}
};
dfs(0);
return ans;
}
};
3 分割回文串

根据子集型回溯思考问题,看看自己掌握了没有?
3.1 解题思路
3.1.1 法一:
根据示例\(1\),枚举\([a,a,b]\)的两个逗号,选 or 不选。
思考回溯问题:
- 当前操作?枚举第\(i\)个逗号,选 or 不选
- 子问题?从下标\(\geq i\)的数字中构造子集
- 下一个子问题?从下标\(\geq i+1\)的数字中构造子集
3.1.2 法二:
站在构造答案的角度。
回溯三问:
- 当前操作?选择回文串\(s[i\cdots j]\)
- 子问题?从下标\(\geq i\)的后缀中构造回文串
- 下一个子问题?从下拨\(\geq j + 1\)的后缀中构造回文串
3.2 代码实现
3.2.1 法一
点击查看代码
class Solution {
bool isPalindrome(string& s, int left, int right) { // [left, right]
while (left < right) {
if (s[left] != s[right]) {
return false;
}
left++, right--;
}
return true;
}
public:
vector<vector<string>> partition(string s) {
// 应用法一:选 or 不选
int n = s.size();
if (n == 0) {
return {};
}
vector<vector<string>> ans;
vector<string> substr;
// dfs(i, start) i表示i后面的逗号选 or 不选, start 当前回文串的起始位置
auto dfs = [&](this auto&& dfs, int i, int start) {
if (i == n - 1) {
if (isPalindrome(s, start, i)) {
substr.emplace_back(s.substr(start, i - start + 1));
ans.emplace_back(substr);
substr.pop_back();
}
return;
}
// 加逗号
if (isPalindrome(s, start, i)) {
substr.emplace_back(s.substr(start, i - start + 1));
dfs(i + 1, i + 1);
substr.pop_back();
}
// 不加
dfs(i + 1, start);
};
dfs(0, 0);
return ans;
}
};
3.2.2 法二
点击查看代码
class Solution {
bool isPalindrome(string& s, int left, int right) { // [left, right]
while (left < right) {
if (s[left] != s[right]) {
return false;
}
left++, right--;
}
return true;
}
public:
vector<vector<string>> partition(string s) {
// 应用法二:从构造答案的角度
int n = s.size();
if (n == 0) {
return {};
}
vector<vector<string>> ans;
vector<string> substr;
// dfs(i)表示以第i个字符为起点,枚举字符串结束的位置
auto dfs = [&](this auto&& dfs, int i) {
if (i == n) {
ans.emplace_back(substr);
return;
}
// 加逗号
for (int j = i; j < n; ++j) {
if (isPalindrome(s, i, j)) {
substr.emplace_back(s.substr(i, j - i + 1));
dfs(j + 1);
substr.pop_back();
}
}
};
dfs(0);
return ans;
}
};
- 时间复杂度:\(O(n2^n)\) 从答案的角度理解,选 or 不选一共有\(2^n-2\)情况,拷贝又至多是\(O(n)\)的,所以是\(O(n2^n)\)的。
- 空间复杂度:\(O(n)\)
组合型回溯
4 组合

4.1 解题思路
4.1.1 法一
在子集[构造答案]的基础上增加逻辑判断减枝即可。
从 \(n\) 个数中选 \(k\) 个数的组合可以看成长度固定的子集。
4.1.2 法二
选 or 不选
4.2 代码实现
4.2.1 法一
点击查看代码
class Solution {
public:
vector<vector<int>> combine(int n, int k) {
vector<vector<int>> ans;
vector<int> subset;
/*
回溯三问:
当前操作:选择第 $j >= i$ 元素
原问题:dfs(i)表示选择第 $j >= i$ 元素,构造子集
子问题:dfs(i+1)表示选择 $> j$的元素
*/
// 优化1:我们是从小到大枚举的,枚举到哪1个数一定无法满足k个数了呢
/*
如果当前选择的元素数量为 size(),那么就还需要 k - size()个数,如果n - i + 1< k - size(第i个数还没选),直接返回
*/
auto dfs = [&](this auto&& dfs, int i) {
if (n - i + 1 < k - subset.size()) {
return;
}
if (subset.size() == k) {
ans.emplace_back(subset);
return;
}
for (int j = i; j <= n; ++j) {
subset.push_back(j);
dfs(j + 1);
subset.pop_back();
}
};
dfs(1);
return ans;
}
};
- 时间复杂度: \(O(kC^n_k)\)
- 空间复杂度:\(O(k)\)
注:如果说倒序枚举,设 \(path\) 长为 \(m\),那么还需要选 \(d=k-m\) 个数。设当前需要从 \([1,i]\) 这 \(i\) 个数中选,那么 \(i < d\) 时,必然无法选出 \(k\) 个数,不需要再递归。
4.2.2 法二
点击查看代码
class Solution {
public:
vector<vector<int>> combine(int n, int k) {
// 选 or 不选
/*
回溯三问:当前的操作?第 $i$ 个数 选 or 不选
原问题 从 $n$ 个数中选择 $k$ 个数
子问题 从 $i + 1 ~n$个数中选择 $k-1$ 个数
*/
vector<vector<int>> ans;
vector<int> subset;
auto dfs = [&](this auto&& dfs, int i) {
if (subset.size() == k) {
ans.emplace_back(subset);
return;
}
if (i == n + 1) {
return;
}
// 选
subset.push_back(i);
dfs(i + 1);
subset.pop_back();
// 不选
dfs(i + 1);
};
dfs(1);
return ans;
}
};
- 时间复杂度:\(O(kC^k_n)\)
- 空间复杂度:\(O(k)\)
5 组合总和 III


5.1 解题思路
5.1.1 选 or 不选
5.1.2 构造答案
设还需要选择 \(d = k - m\) 个数字
设还需要选和为 \(t\) 的数字
(初始为 \(n\),每选一个数字 \(j\),就把 \(t\) 减小 \(j\))
剪枝:
- 剩余数字数目不够 \(i \leq d\)
- \(t \leq 0\)
- 剩余数字即使全部选最大的,和也不够 \(t\),例如 \(i=5\),还需要选 \(d=3\) 个数,那么如果 \(t > 5 + 4 + 3\),可以直接返回。
5.2 代码实现
5.2.1 法一
点击查看代码
class Solution {
public:
vector<vector<int>> combinationSum3(int k, int n) {
// 选 or 不选
/* 剪枝 优化
设还需要 d = k - subset.size() 个数字
设还需要选择和为 target 的数字
1. 剩余数字不够 d 个, i < d
2. target <= 0
3. 选择最大的 d 个数字, target依然 > 0 可以直接返回
i + (i - 1) + ... + (i - d + 1) = d(i + i -d + 1)/2
如果说d = 2
*/
vector<vector<int>> ans;
vector<int> subset;
int target = n;
auto dfs = [&](this auto&& dfs, int i) {
int d = k - subset.size();
if (i < d || target < 0 || target > d*(2*i-d+1)/2) {
return;
}
if (subset.size() == k) {
ans.emplace_back(subset);
return;
}
// 选
subset.push_back(i);
target -= i;
dfs(i - 1);
subset.pop_back();
target += i;
// 不选
dfs(i - 1);
};
dfs(9);
return ans;
}
};
5.2.2 法二
点击查看代码
class Solution {
public:
vector<vector<int>> combinationSum3(int k, int n) {
// 构造答案的角度
vector<vector<int>> ans;
vector<int> subset;
/*
回溯三问:当前操作?
原问题: dfs(i) 选第 $j \geq i$ 个数
子问题:dfs(i + 1) 选第 $ \geq j + 1$ 个数
*/
/* 剪枝优化
1. 剩余数字数目不够
2. target < 0
3. 剩余数字即使全部选择最大的,和也不够 target
最大的数字是 i,还需要 d 个
例如 i = 5,还需要 3 个
target > 5 + 4 + 3
条件应该是 target > i + (i - 1) + ... (i - d + 1) = d(i + i - d + 1) / 2
*/
int target = n;
auto dfs = [&](this auto&& dfs, int i) {
int d = k - subset.size();
if (i < d || target < 0 || target > d*(2*i - d + 1) / 2) {
return;
}
if (subset.size() == k) {
ans.emplace_back(subset);
return;
}
for (int j = i; j >= 1; --j) {
target -= j;
subset.push_back(j);
dfs(j - 1);
subset.pop_back();
target += j;
}
};
dfs(9);
return ans;
}
};
6 括号生成

6.1 解题思路
6.1.1 选 or 不选
- 对于字符串的前缀,左括号的个数一定要大于等于右括号的个数。
- 左括号的个数是 \(n\)。
这道题可以看成是从 \(2*n\) 个位置中选 \(n\) 个位置放置左括号。
对于一个位置,你选择,可以认为是放置左括号;你不选择,就等价于放置右括号。
上述是 选 or 不选的思路。
6.1.2 枚举下一个左括号的位置
6.2 代码实现
6.2.1 法一
点击查看代码
class Solution {
public:
vector<string> generateParenthesis(int n) {
// 选 or 不选
/*
回溯三问: 当前操作?枚举 $path[i]$是左括号还是右括号
子问题? 构造 $\geq i$ 的部分
下一个子问题? 构造 $\geq i + 1$ 的部分
*/
vector<string> ans;
string str(2 * n, 0);
int left_cnt = 0;
auto dfs = [&](this auto&& dfs, int i) {
if (i == 2 * n) {
ans.emplace_back(str);
return;
}
// 选
// 需要选 $n$ 个左括号,只要左括号的个数小于 $n$ 就可以选择左括号
if (left_cnt < n) {
str[i] = '(';
left_cnt += 1;
dfs(i + 1);
left_cnt -= 1;
}
// 不选
// 右括号的个数为 $i - left_cnt$,如果右括号的个数 < 左括号的个数,那么可以选择右括号
if (i - left_cnt < left_cnt) {
str[i] = ')';
dfs(i + 1);
}
};
dfs(0);
return ans;
}
};
- 时间复杂度:\(O(2nC^n_{2n})\)
- 空间复杂度:\(O(n)\)
6.2.2 枚举下一个左括号的位置
还是太抽象了,搞不明白,真遇上了再说吧。
排列型回溯
7 全排列

7.1 解题思路
回溯三问:
数组 \(path\) 记录路径上的数(已选数字),集合 \(s\) 记录未选数字。
当前操作?从集合 \(s\) 中枚举 \(path[i]\) 要填入的数字 \(x\)
子问题? 构造排列 \(\geq i\) 的部分,剩余未选数字集合为 \(s\)
下一个子问题? 构造排列 \(\geq i + 1\) 的部分,剩余未选数字集合为 \(s-\{x\}\)
7.2 代码实现
7.2.1 \(bool\) 数组
点击查看代码
class Solution {
public:
vector<vector<int>> permute(vector<int>& nums) {
vector<vector<int>> ans;
int n = nums.size();
vector<int> temp(n, 0);
vector<int> visited(n, false);
auto dfs = [&](this auto&& dfs, int i) {
if (i == n) {
ans.emplace_back(temp);
return;
}
for (int j = 0; j < n; ++j) {
if (!visited[j]) {
temp[i] = nums[j];
visited[j] = true;
dfs(i + 1);
visited[j] = false;
}
}
};
dfs(0);
return ans;
}
};
7.2.2 哈希表
点击查看代码
class Solution {
public:
vector<vector<int>> permute(vector<int>& nums) {
vector<vector<int>> ans;
int n = nums.size();
vector<int> temp(n, 0);
unordered_set<int> hash_table;
auto dfs = [&](this auto&& dfs, int i) {
if (i == n) {
ans.emplace_back(temp);
return;
}
for (int j = 0; j < n; ++j) {
int x = nums[j];
if (!hash_table.contains(x)) {
hash_table.insert(x);
temp[i] = x;
dfs(i + 1);
hash_table.erase(x);
}
}
};
dfs(0);
return ans;
}
};
- 时间复杂度: \(O(n*n!)\)
解释:对于长度为 \(n\) 的数组,全排列的总数是 \(n!\)
每生成一个排列,需要执行 \(n\) 次操作
无论是哈希表还是 \(bool\)数组,查询时间都是 \(O(1)\)。 - 空间复杂度: \(O(n)\)
解释:递归栈深度 + temp 数组 + hash_table 的空间,都是 O (n) 级别
8 \(N\) 皇后


8.1 解题思路
不同行,不同列 \(\rightarrow\) 每行每列恰好有一个皇后
证明:反证法
假设有一行,一个皇后都没有。那么剩下 \(n - 1\) 行,需要放 \(n\) 个皇后,那么必然有一行至少要放 \(2\) 个皇后,矛盾,所以每行恰好有一个皇后。
用一个长度为 \(n\) 的数组 \(col\) 记录皇后的位置,即第 \(i\) 行的皇后在第 \(col\) 列,那么 \(col\) 将是 \(0 ~ n - 1\) 的排列。
如图1 $\begin{bmatrix}1 & 3 & 0 & 2\end{bmatrix};
如图2 $\begin{bmatrix}2 & 1 & 3 & 1\end{bmatrix}。
于是,变成枚举 \(col\) 的全排列,每行只选一个,每列只选一个,同时还要判断右上(x+y=c)或者左下(x-y=c)是否有其他皇后。
8.2 代码实现
点击查看代码
class Solution {
public:
vector<vector<string>> solveNQueens(int n) {
// 全排列
vector<vector<string>> ans;
vector board(n, string(n, '.')); // 一开始棋盘是空的
unordered_set<int> hash_table1; // 记录 r - c
unordered_set<int> hash_table2; // 记录 r + c
vector<bool> col(n, false);
// r 表示当前要枚举的行号
auto dfs = [&](this auto&& dfs, int r) {
if (r == n) {
ans.emplace_back(board);
return;
}
// 在 (r, c) 放皇后
for (int c = 0; c < n; ++c) {
if (!hash_table1.contains(r + c) && !hash_table2.contains(r - c) && !col[c]) {
hash_table1.insert(r + c);
hash_table2.insert(r - c);
board[r][c] = 'Q';
col[c] = true;
dfs(r + 1);
hash_table1.erase(r + c);
hash_table2.erase(r - c);
board[r][c] = '.';
col[c] = false;
}
}
};
dfs(0);
return ans;
}
};
这里的话判断对角条件的哈希表也可以替换成 \(bool\) 数组,然后为了解决索引是负数,需要添加一个偏移量,负数最大为 \(0 - (n - 1)\)(row-col),所以 \(+(n - 1)\)即可。
- 时间复杂度:\(O(n^2n!)\)
- 空间复杂度:\(O(n)\)
完结撒花!

浙公网安备 33010602011771号