图论专题(下)------ 回溯算法
本篇覆盖图论专题后 8 题:全排列、子集、电话号码的字母组合、组合总和、括号生成、单词搜索、分割回文串、N 皇后。这 8 道题全部基于回溯算法,是竞赛中出现频率极高的搜索技巧,也是 Hot100 里主观难度最高的专题之一。
一、前置知识:回溯算法框架
回溯的本质是带撤销的暴力枚举,在一棵决策树上做 DFS,每次做一个选择,递归处理后撤销这个选择,恢复现场,再尝试下一个选择。
回溯框架:
void backtrack(参数) {
if (满足结束条件) {
收集结果;
return;
}
for (选择 in 当前所有可选项) {
做选择; // 将选择加入路径
backtrack(...); // 递归,进入下一层
撤销选择; // 恢复现场,回到上一层
}
}
回溯三要素:
- 路径:已经做出的选择序列
- 选择列表:当前可以做的选择
- 结束条件:到达决策树的叶节点,收集结果
理解回溯的核心是理解决策树的形状,每道题的区别在于树的结构(深度、宽度、剪枝条件)不同。
二、全排列(#46)
题意
给一个不含重复数字的整数数组 nums,返回其所有可能的全排列。
输入:[1, 2, 3]
输出:
[[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]
思路
决策树:每层从剩余未选的数字中选一个放到当前位置,直到所有数字都被选完。
[]
/ | \
[1] [2] [3]
/ \ / \ / \
[1,2] [1,3] [2,1] [2,3] [3,1] [3,2]
| | | | | |
[1,2,3][1,3,2][2,1,3][2,3,1][3,1,2][3,2,1]
用 used 数组标记每个数字是否已被选择,避免重复选同一个数字。
逐步演示(以 [1,2,3] 为例):
backtrack(path=[], used=[F,F,F])
选1:path=[1], used=[T,F,F]
选2:path=[1,2], used=[T,T,F]
选3:path=[1,2,3],收集结果
撤销3:path=[1,2], used=[T,T,F]
撤销2:path=[1], used=[T,F,F]
选3:path=[1,3], used=[T,F,T]
选2:path=[1,3,2],收集结果
撤销2:path=[1,3], used=[T,F,T]
撤销3:path=[1], used=[T,F,F]
撤销1:path=[], used=[F,F,F]
选2:... (类似)
选3:... (类似)
代码
cpp
class Solution {
vector<vector<int>> res;
vector<int> path;
vector<bool> used;
void backtrack(vector<int>& nums) {
// 结束条件:路径长度等于数组长度,收集结果
if (path.size() == nums.size()) {
res.push_back(path);
return;
}
for (int i = 0; i < nums.size(); i++) {
if (used[i]) continue; // 已选过,跳过
// 做选择
used[i] = true;
path.push_back(nums[i]);
// 递归
backtrack(nums);
// 撤销选择
path.pop_back();
used[i] = false;
}
}
public:
vector<vector<int>> permute(vector<int>& nums) {
used.assign(nums.size(), false);
backtrack(nums);
return res;
}
};
复杂度
- 时间 :O(n⋅n!)O(n \cdot n!)O(n⋅n!),共 n!n!n! 个排列,每个排列长度 nnn
- 空间 :O(n)O(n)O(n),递归栈深度为 nnn
三、子集(#78)
题意
给一个不含重复元素的整数数组 nums,返回其所有可能的子集(幂集)。
输入:[1, 2, 3]
输出:
[[], [1], [2], [3], [1,2], [1,3], [2,3], [1,2,3]]
思路
子集问题的决策树:每层从当前位置之后的元素中选,保证不回头,避免重复子集。
和全排列的区别:
-
全排列每层都从全部元素里选(用
used防重) -
子集每层从
start往后选(保证递增顺序,天然去重) -
子集每进入一层就收集一次结果(不只在叶节点收集)
决策树(每个节点都收集为一个子集):
[] ← 收集 []
├─ [1] ← 收集 [1]
│ ├─ [1,2] ← 收集 [1,2]
│ │ └─ [1,2,3] ← 收集 [1,2,3]
│ └─ [1,3] ← 收集 [1,3]
├─ [2] ← 收集 [2]
│ └─ [2,3] ← 收集 [2,3]
└─ [3] ← 收集 [3]
start 参数控制每层的起始位置,每次递归时传入 i+1,确保不会选到已选元素左边的元素。
代码
cpp
class Solution {
vector<vector<int>> res;
vector<int> path;
void backtrack(vector<int>& nums, int start) {
res.push_back(path); // 每进入一层就收集当前路径
for (int i = start; i < nums.size(); i++) {
path.push_back(nums[i]); // 做选择
backtrack(nums, i + 1); // 下一层从 i+1 开始,不回头
path.pop_back(); // 撤销选择
}
}
public:
vector<vector<int>> subsets(vector<int>& nums) {
backtrack(nums, 0);
return res;
}
};
复杂度
- 时间 :O(n⋅2n)O(n \cdot 2^n)O(n⋅2n),共 2n2^n2n 个子集,每个子集平均长度 n/2n/2n/2
- 空间 :O(n)O(n)O(n),递归栈深度
四、电话号码的字母组合(#17)
题意
给一个包含数字 2-9 的字符串 digits,返回数字能表示的所有可能的字母组合(手机九宫格)。
输入:"23"
输出:["ad","ae","af","bd","be","bf","cd","ce","cf"]
2→abc,3→def
思路
每个数字对应一组字母,依次对每个数字做选择,选完所有数字后收集结果。
这是多叉树的 DFS ,树的深度等于 digits 的长度,每层的宽度等于当前数字对应的字母数(2或3或4)。
digits = "23"
第0层:选数字'2'对应的字母
选'a'→
第1层:选数字'3'对应的字母
选'd'→ 收集"ad"
选'e'→ 收集"ae"
选'f'→ 收集"af"
选'b'→
第1层:...收集"bd","be","bf"
选'c'→
第1层:...收集"cd","ce","cf"
idx 参数表示当前处理到 digits 的第几个字符,当 idx == digits.size() 时收集结果。
代码
cpp
class Solution {
vector<string> res;
string path;
unordered_map<char, string> phoneMap = {
{'2', "abc"}, {'3', "def"}, {'4', "ghi"}, {'5', "jkl"},
{'6', "mno"}, {'7', "pqrs"}, {'8', "tuv"}, {'9', "wxyz"}
};
void backtrack(string& digits, int idx) {
if (idx == digits.size()) {
res.push_back(path); // 所有数字都处理完,收集结果
return;
}
string letters = phoneMap[digits[idx]]; // 当前数字对应的字母
for (char c : letters) {
path.push_back(c); // 做选择
backtrack(digits, idx + 1); // 处理下一个数字
path.pop_back(); // 撤销选择
}
}
public:
vector<string> letterCombinations(string digits) {
if (digits.empty()) return {};
backtrack(digits, 0);
return res;
}
};
复杂度
- 时间 :O(4n⋅n)O(4^n \cdot n)O(4n⋅n),nnn 为
digits长度,每个数字最多对应 4 个字母 - 空间 :O(n)O(n)O(n),递归栈深度
五、组合总和(#39)
题意
给一个无重复元素的整数数组 candidates 和目标值 target,找出所有可以使数字之和等于 target 的组合,每个数字可以重复选取。
输入:candidates = [2,3,6,7], target = 7
输出:[[2,2,3],[7]]
思路
和子集问题类似,用 start 控制不回头,但允许重复选同一个数(递归时传 i 而不是 i+1)。
用 remain(剩余目标值)做剪枝:
- 如果
remain == 0,收集结果 - 如果
remain < 0,当前路径已经超过目标,直接返回
先对 candidates 排序,这样当 candidates[i] > remain 时,后面的数更大,可以直接 break 剪枝,减少无效搜索。
candidates=[2,3,6,7], target=7
backtrack(start=0, remain=7)
选2:remain=5
选2:remain=3
选2:remain=1
选2:remain=-1 < 0,剪枝返回
选3:remain=-2 < 0,剪枝返回
撤销2
选3:remain=0,收集[2,2,3] ✓
撤销3
选6:remain=-3 < 0,剪枝返回(后面更大,直接break)
撤销2
选3:remain=2
选3:remain=-1 < 0,剪枝
撤销3
选6:remain=-1 < 0,剪枝,break
撤销2
选3:remain=4
选3:remain=1
选3:remain=-2,剪枝,break
撤销3
选6:remain=-2,剪枝,break
撤销3
选6:remain=1
选6:remain=-5,剪枝,break
撤销6
选7:remain=0,收集[7] ✓
撤销7
代码
cpp
class Solution {
vector<vector<int>> res;
vector<int> path;
void backtrack(vector<int>& candidates, int start, int remain) {
if (remain == 0) {
res.push_back(path); // 找到一个合法组合
return;
}
for (int i = start; i < candidates.size(); i++) {
if (candidates[i] > remain) break; // 已排序,后面更大,直接剪枝
path.push_back(candidates[i]);
backtrack(candidates, i, remain - candidates[i]); // i 不是 i+1,允许重复选
path.pop_back();
}
}
public:
vector<vector<int>> combinationSum(vector<int>& candidates, int target) {
sort(candidates.begin(), candidates.end()); // 排序,方便剪枝
backtrack(candidates, 0, target);
return res;
}
};
复杂度
- 时间 :O(nT/M)O(n^{T/M})O(nT/M),TTT 为 target,MMM 为最小元素,指数级但剪枝后实际很快
- 空间 :O(T/M)O(T/M)O(T/M),递归最深层数
六、括号生成(#22)
题意
给整数 n,生成所有由 n 对括号组成的有效括号组合。
输入:n=3
输出:["((()))","(()())","(())()","()(())","()()()"]
思路
回溯,但有明确的剪枝条件,使决策树大幅缩小。
两个关键变量:
left:已放置的左括号数量right:已放置的右括号数量
合法性约束(剪枝条件):
- 任何时候
left < right,说明右括号多于左括号,已经非法,直接返回 left > n或right > n,超出数量限制,返回- 结束条件:
left == right == n,收集结果
每次选择只有两种:放左括号或放右括号。
n=2 的决策树:
""
├─ "(" (left=1,right=0)
│ ├─ "((" (left=2,right=0)
│ │ └─ "(()" (left=2,right=1)
│ │ └─ "(())" ✓
│ └─ "()" (left=1,right=1)
│ └─ "()(" (left=2,right=1)
│ └─ "()()" ✓
└─ ")" → right>left,非法,剪枝
代码
cpp
class Solution {
vector<string> res;
string path;
void backtrack(int n, int left, int right) {
if (path.size() == 2 * n) {
res.push_back(path); // 长度达到 2n,收集结果
return;
}
// 放左括号:左括号数量还没达到 n
if (left < n) {
path.push_back('(');
backtrack(n, left + 1, right);
path.pop_back();
}
// 放右括号:右括号数量必须小于左括号数量(保证合法性)
if (right < left) {
path.push_back(')');
backtrack(n, left, right + 1);
path.pop_back();
}
}
public:
vector<string> generateParenthesis(int n) {
backtrack(n, 0, 0);
return res;
}
};
复杂度
- 时间 :O(4nn)O(\dfrac{4^n}{\sqrt{n}})O(n 4n),这是第 nnn 个卡特兰数,有效括号序列的数量
- 空间 :O(n)O(n)O(n),递归深度为 2n2n2n
七、单词搜索(#79)
题意
给一个字符矩阵 board 和字符串 word,判断 word 是否存在于矩阵中。单词必须按上下左右相邻顺序构成,且同一个格子不能重复使用。
输入:
board =
A B C E
S F C S
A D E E
word = "ABCCED"
输出:true(路径:A→B→C→C→E→D)
思路
对矩阵中每个格子作为起点做 DFS,尝试匹配 word。
idx 表示当前需要匹配 word[idx],每次匹配成功就向四个方向递归尝试匹配下一个字符。
防止重复使用同一格子 :进入格子时把它临时标记(比如改成 '#'),回溯时还原,避免开辟额外的 visited 数组。
剪枝:
-
越界或当前格子不等于
word[idx],直接返回 false -
idx == word.size(),所有字符匹配完成,返回 trueboard: word = "ABCCED"
A B C E
S F C S
A D E E从(0,0)='A'出发:
匹配A(idx=0) ✓,标记(0,0)='#'
向右到(0,1)='B':
匹配B(idx=1) ✓,标记(0,1)='#'
向右到(0,2)='C':
匹配C(idx=2) ✓,标记(0,2)='#'
向下到(1,2)='C':
匹配C(idx=3) ✓,标记(1,2)='#'
向下到(2,2)='E':
匹配E(idx=4) ✓,标记(2,2)='#'
向左到(2,1)='D':
匹配D(idx=5) ✓,idx==6==word.size(),返回true ✓
代码
cpp
class Solution {
public:
bool dfs(vector<vector<char>>& board, string& word, int i, int j, int idx) {
if (idx == word.size()) return true; // 所有字符匹配完成
int m = board.size(), n = board[0].size();
if (i < 0 || i >= m || j < 0 || j >= n) return false; // 越界
if (board[i][j] != word[idx]) return false; // 字符不匹配
char tmp = board[i][j];
board[i][j] = '#'; // 临时标记,防止重复使用
bool found = dfs(board, word, i+1, j, idx+1) ||
dfs(board, word, i-1, j, idx+1) ||
dfs(board, word, i, j+1, idx+1) ||
dfs(board, word, i, j-1, idx+1);
board[i][j] = tmp; // 回溯,还原格子
return found;
}
bool exist(vector<vector<char>>& board, string word) {
int m = board.size(), n = board[0].size();
for (int i = 0; i < m; i++)
for (int j = 0; j < n; j++)
if (dfs(board, word, i, j, 0)) return true;
return false;
}
};
复杂度
- 时间 :O(mn⋅4L)O(mn \cdot 4^L)O(mn⋅4L),LLL 为
word长度,每个格子作为起点,DFS 最多扩展 4L4^L4L 条路径 - 空间 :O(L)O(L)O(L),递归栈深度为 LLL
八、分割回文串(#131)
题意
给字符串 s,将 s 分割成若干子串,使得每个子串都是回文串,返回所有可能的分割方案。
输入:"aab"
输出:[["a","a","b"],["aa","b"]]
思路
回溯,start 表示当前从 s[start] 开始分割。枚举每个可能的切割点 end,如果 s[start..end] 是回文串,就把它加入路径,递归处理 s[end+1..]。
判断回文串:双指针从两端向中间对比。
剪枝 :s[start..end] 不是回文串就不递归,直接跳过。
s = "aab"
backtrack(start=0)
end=0,s[0..0]="a",是回文 ✓,path=["a"]
backtrack(start=1)
end=1,s[1..1]="a",是回文 ✓,path=["a","a"]
backtrack(start=2)
end=2,s[2..2]="b",是回文 ✓,path=["a","a","b"]
start==3==s.size(),收集["a","a","b"] ✓
撤销"b"
撤销"a"
end=2,s[1..2]="ab",不是回文,跳过
撤销"a"
end=1,s[0..1]="aa",是回文 ✓,path=["aa"]
backtrack(start=2)
end=2,s[2..2]="b",是回文 ✓,path=["aa","b"]
收集["aa","b"] ✓
撤销"b"
撤销"aa"
end=2,s[0..2]="aab",不是回文,跳过
代码
cpp
class Solution {
vector<vector<string>> res;
vector<string> path;
bool isPalindrome(string& s, int left, int right) {
while (left < right) {
if (s[left] != s[right]) return false;
left++; right--;
}
return true;
}
void backtrack(string& s, int start) {
if (start == s.size()) {
res.push_back(path); // 整个字符串都被切完了,收集结果
return;
}
for (int end = start; end < s.size(); end++) {
if (!isPalindrome(s, start, end)) continue; // 不是回文,跳过
path.push_back(s.substr(start, end - start + 1)); // 做选择
backtrack(s, end + 1); // 递归处理剩余部分
path.pop_back(); // 撤销选择
}
}
public:
vector<vector<string>> partition(string s) {
backtrack(s, 0);
return res;
}
};
复杂度
- 时间 :O(n⋅2n)O(n \cdot 2^n)O(n⋅2n),最多 2n−12^{n-1}2n−1 种分割方式,每次判断回文 O(n)O(n)O(n)
- 空间 :O(n)O(n)O(n),递归栈深度
九、N 皇后(#51)
题意
给整数 n,返回 n 皇后问题的所有不同解法。皇后不能互相攻击,即同行、同列、同对角线上不能有两个皇后。
输入:n=4
输出:
[".Q..", ["..Q.",
"...Q", "Q...",
"Q...", "...Q",
"..Q."] ".Q.."]
思路
逐行放置皇后,每行只放一个皇后,因此同行冲突天然不存在,只需检查:
- 列冲突:该列是否已有皇后
- 主对角线冲突 :同一主对角线的格子满足
行 - 列相同 - 副对角线冲突 :同一副对角线的格子满足
行 + 列相同
用三个集合分别记录已被占用的列、主对角线、副对角线,检查冲突 O(1)O(1)O(1)。
n=4,逐行放置:
第0行:尝试列0,1,2,3
列0:col={0},diag1={0-0=0},diag2={0+0=0},放置
第1行:尝试列0,1,2,3
列0:col有0,冲突
列1:diag2有0+0=0,现在1行1列 1+1=2 不冲突;diag1有0,现在1-1=0冲突
列2:检查:col无2,diag1: 1-2=-1 无,diag2: 1+2=3 无 → 放置
第2行:尝试列0,1,2,3
列0:diag2: 2+0=2,col有0冲突
列1:diag2: 2+1=3 冲突(第1行放了2列,3在diag2中)?
实际diag2={0,3},2+1=3 冲突
列2:col有2冲突
列3:检查全部无冲突 → 放置
第3行:全部列都冲突 → 回溯
撤销列3
第2行无解,回溯
撤销列2
列3:diag1: 1-3=-2 无;diag2: 1+3=4 无;col无3 → 放置
第2行:
列0:col无0,diag1: 2-0=2 无,diag2: 2+0=2 无 → 放置
第3行:
列0:col有0
列1:diag1: 3-1=2,col有?diag2:3+1=4 冲突
列2:col无2,diag1: 3-2=1 无,diag2: 3+2=5 无 → 放置
第4行已出界,收集结果:
.Q..
...Q
Q...
..Q. ✓
代码
cpp
class Solution {
vector<vector<string>> res;
vector<string> board;
unordered_set<int> cols, diag1, diag2; // 列、主对角线(行-列)、副对角线(行+列)
void backtrack(int row, int n) {
if (row == n) {
res.push_back(board); // 所有行放完,收集结果
return;
}
for (int col = 0; col < n; col++) {
// 检查冲突
if (cols.count(col)) continue;
if (diag1.count(row - col)) continue;
if (diag2.count(row + col)) continue;
// 放置皇后
board[row][col] = 'Q';
cols.insert(col);
diag1.insert(row - col);
diag2.insert(row + col);
backtrack(row + 1, n);
// 撤销皇后
board[row][col] = '.';
cols.erase(col);
diag1.erase(row - col);
diag2.erase(row + col);
}
}
public:
vector<vector<string>> solveNQueens(int n) {
board.assign(n, string(n, '.')); // 初始化棋盘
backtrack(0, n);
return res;
}
};
复杂度
- 时间 :O(n!)O(n!)O(n!),第 0 行有 nnn 种选择,第 1 行最多 n−1n-1n−1 种,以此类推
- 空间 :O(n)O(n)O(n),递归栈深度 + 三个集合各最多 nnn 个元素
十、本篇小结
| 题目 | 树的结构 | 收集时机 | 关键剪枝 | 时间 |
|---|---|---|---|---|
| 全排列 | 每层从全部元素选,used防重 | 叶节点(路径满) | used标记 | O(n⋅n!)O(n \cdot n!)O(n⋅n!) |
| 子集 | 每层从start往后选,不回头 | 每个节点 | start递增 | O(n⋅2n)O(n \cdot 2^n)O(n⋅2n) |
| 电话号码字母组合 | 每层处理一个数字的所有字母 | 叶节点(处理完所有数字) | 无 | O(4n⋅n)O(4^n \cdot n)O(4n⋅n) |
| 组合总和 | 每层从start往后选,可重复 | remain==0 | 排序+remain<0剪枝 | 指数级 |
| 括号生成 | 每层只有放左/右括号两种选择 | 路径长度==2n | right<left才能放右括号 | O(4n/n)O(4^n/\sqrt{n})O(4n/n ) |
| 单词搜索 | 矩阵上的DFS,四个方向 | idx==word.size() | 越界或字符不匹配 | O(mn⋅4L)O(mn \cdot 4^L)O(mn⋅4L) |
| 分割回文串 | 每层枚举切割点 | start==s.size() | 非回文串不递归 | O(n⋅2n)O(n \cdot 2^n)O(n⋅2n) |
| N 皇后 | 逐行放置,每列一个皇后 | row==n | 列/对角线冲突检查 | O(n!)O(n!)O(n!) |
回溯的本质是暴力搜索加剪枝。所有回溯题的代码结构都一样,区别只在于:
- 选择列表:每层可以做哪些选择
- 路径约束:什么时候某个选择非法(直接跳过或剪枝)
- 结束条件:什么时候收集结果
把这三个问题想清楚,回溯题就能套框架写出来。