一句话说明核心方法
回溯就是"走迷宫":沿着一条路一直走,走不通就退回上一个路口,换条路再走。
写成代码只有三步:做选择 → 递归 → 撤销选择。
hot100 里的八道回溯题,全部由这一个框架变形而来。
什么是回溯
很多人背模板背得很熟,但一直没搞懂回溯到底在干什么。其实它就是在遍历一棵看不见的树------决策树。
以 [1,2,3] 求子集为例,递归展开后就是这棵树:
[]
┌─────────┼─────────┐
[1] [2] [3]
┌───┴───┐ │
[1,2] [1,3] [2,3]
│
[1,2,3]
树上每个节点代表"当前已做的选择",往下走一层就是"再做一个选择",往回退就是"撤销刚才的选择"。
所以写任何回溯题之前,先回答三个问题,答案出来了,代码也就出来了:
- 路径 :已经做了哪些选择?→ 用一个
list维护 - 选择列表 :现在还能选什么?→ 只有两种控制方式:
start或used[] - 结束条件:什么时候把路径收进结果集?
万能框架
void backtrack(参数, List<Integer> path, List<List<Integer>> res) {
if (结束条件成立) {
res.add(new ArrayList<>(path)); // 注意:必须拷贝!
return;
}
for (选择 : 当前可选择的东西) {
做选择; // path.add(x)
backtrack(下一层);
撤销选择; // path.remove最后一个
}
}
八道题的差异,只有三个地方:
- 选择列表怎么控制 ------
start还是used[] - 什么时候收集答案------每个节点都收,还是只在叶子收
- 怎么剪枝------什么情况直接跳过这个选择
八道题全景表
| 题号 | 题目 | 控制选择列表 | 收集时机 | 关键剪枝 |
|---|---|---|---|---|
| 78 | 子集 | start |
每个节点 | 无 |
| 39 | 组合总和 | start 传 i |
叶子(sum = target) | sum 超了就停 |
| 17 | 电话号码 | index 前进 |
叶子(拼完) | 无 |
| 22 | 括号生成 | 左右括号计数 | 叶子(长度 2n) | 右括号数 < 左括号数 |
| 131 | 分割回文串 | start 跳 i+1 |
叶子(切完) | 切出来不是回文就跳过 |
| 46 | 全排列 | used[] |
叶子(选满 n 个) | 无 |
| 51 | N皇后 | row 逐行 + 冲突集合 |
叶子(放满 n 行) | 列/对角线冲突 |
| 79 | 单词搜索 | 棋盘置 # 标记 |
匹配完 word | 越界/字符不等 |
看着有八道,其实只有三套模板。
模板一:start 型(子集、组合、分割)
核心思想:选过的不再回头选 。递归传一个 start,下一层只能从 start 往后挑,天然避免了 [1,2] 和 [2,1] 这种重复。
78 子集(最纯粹的模板题)
class Solution {
public List<List<Integer>> subsets(int[] nums) {
List<List<Integer>> res = new ArrayList<>();
backtrack(nums, 0, new ArrayList<>(), res);
return res;
}
private void backtrack(int[] nums, int start,
List<Integer> path, List<List<Integer>> res) {
res.add(new ArrayList<>(path)); // 每个节点都是答案
for (int i = start; i < nums.length; i++) {
path.add(nums[i]); // 做选择
backtrack(nums, i + 1, path, res); // 只能往后选
path.remove(path.size() - 1); // 撤销选择
}
}
}
注意:子集型不需要终止条件 ,for 循环走完自然结束,if (start == n) return; 反而会漏答案。
39 组合总和(改一行 + 加剪枝)
和 78 只有两处不同:允许重复选同一个数 (递归传 i 不是 i+1),以及收集条件从"每个节点"变成"和恰好等于 target"。
private void backtrack(int[] candidates, int start, int target,
List<Integer> path, List<List<Integer>> res) {
if (target == 0) {
res.add(new ArrayList<>(path));
return;
}
for (int i = start; i < candidates.length; i++) {
if (candidates[i] > target) continue; // 剪枝:选这个数肯定超了
path.add(candidates[i]);
backtrack(candidates, i, target - candidates[i], path, res); // 传 i,可重复选
path.remove(path.size() - 1);
}
}
131 分割回文串(换个角度看 start)
start 变成"从哪里开始切",for 循环枚举"这一刀切在哪里",切出来的段是回文才有资格往下递归:
private void backtrack(String s, int start, List<String> path,
List<List<String>> res) {
if (start == s.length()) { // 切完了
res.add(new ArrayList<>(path));
return;
}
for (int i = start; i < s.length(); i++) {
String seg = s.substring(start, i + 1);
if (!isPalindrome(seg)) continue; // 不是回文,这刀白切
path.add(seg); // 做选择:留下这一段
backtrack(s, i + 1, path, res); // 下一刀从 i+1 开始
path.remove(path.size() - 1);
}
}
17 电话号码 / 22 括号生成(选择列表换个来源)
-
17 :选择列表不再是数组下标,而是
digits[index]这个数字对应的 3~4 个字母,index走到头就收集 -
22 :选择列表永远只有两个------放
(或放)。剪枝规则:左括号数 < n 才能放左;右括号数 < 左括号数才能放右private void backtrack(int n, int open, StringBuilder sb, List
res) {
if (sb.length() == 2 * n) {
res.add(sb.toString());
return;
}
if (open < n) { // 左括号还没用完
sb.append('(');
backtrack(n, open + 1, sb, res);
sb.deleteCharAt(sb.length() - 1);
}
if (sb.length() - open < open) { // 右括号比左括号少,才能放右
sb.append(')');
backtrack(n, open, sb, res);
sb.deleteCharAt(sb.length() - 1);
}
}
模板二:used 型(排列、棋盘)
核心思想:每层都从头扫,但用过的标记掉 。排列和组合的本质区别就在这:[1,2] 和 [2,1] 是两个不同的排列,所以不能只往后选,必须能回头。
46 全排列
class Solution {
public List<List<Integer>> permute(int[] nums) {
List<List<Integer>> res = new ArrayList<>();
backtrack(nums, new boolean[nums.length], new ArrayList<>(), res);
return res;
}
private void backtrack(int[] nums, boolean[] used,
List<Integer> path, List<List<Integer>> res) {
if (path.size() == nums.length) { // 选满了,收集
res.add(new ArrayList<>(path));
return;
}
for (int i = 0; i < nums.length; i++) { // 每层从头扫
if (used[i]) continue; // 用过的跳过
used[i] = true; // 做选择
path.add(nums[i]);
backtrack(nums, used, path, res);
path.remove(path.size() - 1); // 撤销选择
used[i] = false; // 撤销要完全对称!
}
}
}
51 N皇后(used 数组的进阶版)
一行只放一个皇后,所以递归参数就是 row,每层只决定"这一行放哪一列"。冲突检查就是把 used[] 换成三个集合:列、主对角线、副对角线。
class Solution {
public List<List<String>> solveNQueens(int n) {
List<List<String>> res = new ArrayList<>();
backtrack(n, 0, new HashSet<>(), new HashSet<>(), new HashSet<>(),
new int[n], res);
return res;
}
private void backtrack(int n, int row, Set<Integer> cols,
Set<Integer> diag1, Set<Integer> diag2,
int[] queens, List<List<String>> res) {
if (row == n) {
res.add(toBoard(queens, n));
return;
}
for (int col = 0; col < n; col++) {
if (cols.contains(col)
|| diag1.contains(row - col) // 主对角线:同一条线上 row-col 固定
|| diag2.contains(row + col)) { // 副对角线:row+col 固定
continue; // 剪枝:会打架,跳过
}
queens[row] = col; // 做选择
cols.add(col); diag1.add(row - col); diag2.add(row + col);
backtrack(n, row + 1, cols, diag1, diag2, queens, res);
cols.remove(col); diag1.remove(row - col); diag2.remove(row + col); // 撤销
}
}
private List<String> toBoard(int[] queens, int n) {
List<String> board = new ArrayList<>();
for (int r = 0; r < n; r++) {
char[] line = new char[n];
java.util.Arrays.fill(line, '.');
line[queens[r]] = 'Q';
board.add(new String(line));
}
return board;
}
}
对角线技巧记一下:同一条主对角线上,row - col 是定值;同一条副对角线上,row + col 是定值。
模板三:网格型(单词搜索)
前两套模板都是在"一维"上选,79 题把战场搬到了二维网格。套路不变,只是:
-
做选择 = 走进一个格子,把原字符临时改成
#当 visited 标记 -
撤销选择 = 把字符改回来
class Solution {
public boolean exist(char[][] board, String word) {
for (int i = 0; i < board.length; i++)
for (int j = 0; j < board[0].length; j++)
if (dfs(board, word, i, j, 0)) return true;
return false;
}private boolean dfs(char[][] board, String word, int i, int j, int k) { if (i < 0 || i >= board.length || j < 0 || j >= board[0].length || board[i][j] != word.charAt(k)) return false; // 剪枝:越界/不匹配 if (k == word.length() - 1) return true; // 匹配完了 char tmp = board[i][j]; board[i][j] = '#'; // 做选择:标记走过 boolean found = dfs(board, word, i + 1, j, k + 1) || dfs(board, word, i - 1, j, k + 1) || dfs(board, word, i, j + 1, k + 1) || dfs(board, word, i, j - 1, k + 1); board[i][j] = tmp; // 撤销选择:恢复现场 return found; }}
这题和前三套最大的不同:返回值是 boolean,找到了就一路短路返回,不再继续搜。
复杂度怎么估
不用精确算,记结论就够面试用:
- 子集型 :树有
2^n个节点,每个节点拷贝路径 O(n) → 时间 O(n · 2^n) - 排列型 :树有
n!个叶子,路径长 O(n) → 时间 O(n · n!) - 网格型 (79):每个格子出发,每步最多 3 个方向(不走回头路)→ O(mn · 3^L),L 是单词长度
空间都是递归栈深度:子集 O(n)、排列 O(n)、N皇后 O(n)、网格 O(L)。
易错点
- 收集时必须拷贝 :
res.add(new ArrayList<>(path))。直接 add path,最后所有结果共享同一个 list,答案全是空 - 做选择和撤销必须完全对称 :
add配remove,used[i]=true配used[i]=false,board置#配恢复原字符。漏一处,整棵树的搜索全乱 - 子集型不要写终止条件 :78 题写了
if (start == n) return;会漏掉[1,2,3]这种答案 - 39 题传
i不传i+1:一个字符的差别,决定能不能重复选同一个数 - 剪枝写在 for 循环里,不是写在递归开头:continue 跳过的是"这一个选择",return 结束的是"整棵子树",两者含义完全不同
面试追问:数组里有重复元素怎么办
hot100 这八道题的输入都没有重复元素,但面试官很爱追问这一句。答案两行:
- 先排序,让相同的数相邻
- 同层去重 :子集/组合型用
if (i > start && nums[i] == nums[i-1]) continue;,排列型用if (i > 0 && nums[i] == nums[i-1] && !used[i-1]) continue;
思路都一样:保证相同的数在同一层只被选一次。对应练习题:90 子集 II、40 组合总和 II、47 全排列 II。
总结:刷题顺序建议
按模板分组二刷,比按题号刷效果好得多:
- 78 → 39 → 131(start 型,从裸模板到变形)
- 17 → 22(选择列表换来源)
- 46 → 51(used 型,从一维到棋盘)
- 79(网格型收尾)
每道题动手前只问三个问题:路径是什么?选择列表靠什么控制?什么时候收集? 答得上来,回溯就算学透了。