专题 08递归与回溯:枚举所有可能
回溯(Backtracking)听起来吓人,其实就一句话:递归 + 撤销选择。 它用来解决一类最朴素也最常见的问题——「把所有满足条件的组合/排列/子集都找出来」。 本章先告诉你回溯背后那棵「决策树」长什么样,再给你一个四件套模板,最后用 4 道题(含困难题 N 皇后)把它练到能默写。
8.1回溯 = 递归 + 撤销选择
回溯解决的是「枚举所有可能」的问题。比如「从 1,2,3 里选 2 个数有哪些组合」「把 abc 全排列」。 暴力枚举的本质,是在一棵决策树上做深度优先搜索(DFS):每个节点代表「当前已经做了哪些选择」,往下走一步就是「再做一个选择」。
关键就在「撤销」二字。DFS 走到一个分支、收集完结果后,要退回上一层,把刚才的选择取消,才能去试另一条分支。
就像走迷宫:走进死胡同,得沿原路退回来,才能走另一条岔路。这个「退回来」的动作,就是回溯里那句 path.remove(...)。
从 [1,2,3] 选 2 个(组合)的决策树:
path=[]
/ | \
选1 选2 选3
path=[1] path=[2] path=[3]
/ \ |
选2 选3 选3 (选3后再选>3的,无路)
[1,2][1,3] [2,3]
↑ 三个叶子就是答案:[1,2][1,3][2,3]
注意:选完 [1,2] 后,必须「撤销 2」回到 [1],
再「撤销 1」回到 [],才能去试选 2 那条分支。
把「做选择 → 递归 → 撤销选择」三步套在 DFS 上,这就是回溯的全部秘密。 后面所有题,骨架都一样,变的只是「什么时候算一个答案」「怎么避免重复」。
把决策树和代码逐行对上
拿「从 [1,2,3] 选 2 个」再走一遍,把树的每一步对应到回溯代码的执行流,你就能彻底看清「撤销」发生在哪:
回溯代码(组合 k=2)的执行轨迹:
backtrack(path=[], start=1)
for i=1: path=[1]
backtrack(path=[1], start=2)
for i=2: path=[1,2] → 长度==k → 收 [1,2];回到本层
path.remove → path=[1] ← 【撤销】2,退回选 2 之前
for i=3: path=[1,3] → 收 [1,3]
path.remove → path=[1] ← 【撤销】3
(i 循环结束)path.remove → path=[] ← 【撤销】1,退回起点
for i=2: path=[2]
backtrack(path=[2], start=3)
for i=3: path=[2,3] → 收 [2,3]
path.remove → path=[2]
path.remove → path=[]
for i=3: path=[3]
backtrack(path=[3], start=4) → i 从 4 起,啥也不选,直接返回
path.remove → path=[]
收集到 [1,2][1,3][2,3] ✓
注意那三处 path.remove:每一层递归返回前,都必须把刚加进去的元素撤销,
否则上层循环再 path.add 时,path 里还残留着下层的选择,答案就串味了。
「撤销」和「做选择」严格对称,是回溯能正确枚举的核心纪律。
path.add 和 path.remove 各打一行日志,
看 path 的「长出来」和「缩回去」是否对称,能秒定位绝大多数 bug。
8.2回溯通用模板:决策树
把上面的思想落成代码,所有回溯题都是这四件套:
| 四件套 | 是什么 |
|---|---|
| path | 当前路径(已经做的选择),通常是一个 List |
| res | 结果集,收集所有合法的 path |
| 递归参数 | 控制「下一步能选哪些」:如 start(从哪开始选)、used(用过没) |
| 撤销 | 递归返回后,把刚加进 path 的元素移除,恢复原状 |
void backtrack(路径 path, 选择列表, 结果集 res) {
if (满足结束条件) { // 比如 path 长度够了
res.add(拷贝(path)); // 注意:加的是拷贝,不是引用
return;
}
for (每个可选选择) {
path.add(选择); // ① 做选择
backtrack(path, ...); // ② 递归进下一层
path.remove(最后); // ③ 撤销选择(回溯的灵魂)
}
}
逐行注意点:
- 结束条件:告诉递归「什么时候这个 path 算一个完整答案」。组合是 path 长度等于 k,子集是每个节点都收,全排列是 path 长度等于 n。
- 加拷贝:
res.add(new ArrayList<>(path))。如果不拷贝,后面撤销 path 时,res 里存的同一个对象也被改空,最后全变空列表。这是新手第一坑。 - 撤销必须对称:加了什么就移除什么,顺序无所谓(移除最后一个即可),但绝不能漏。
- 选择列表怎么缩:用
start参数保证「不回头选」,用used数组标记「已用过」,避免重复——这两招后面分别讲。
res.add(path) 没拷贝。Java 里 path 是引用,你加进去的是「同一个列表对象」,
后面 path.remove 把它清空,res 里所有元素都跟着空。务必写成 res.add(new ArrayList<>(path))。
8.3组合(中等,力扣 77)
中等 核心:start 参数防重复
题意:从 1 到 n 里选 k 个数,返回所有组合(不考虑顺序,[1,2] 和 [2,1] 算同一个,只留 [1,2])。
例:n=4, k=2 → [1,2][1,3][1,4][2,3][2,4][3,4]。
思路推导
用决策树想:第一个数可以选 1~n,选了之后第二个数只能从「比它大的」里选,这样自然不会重复(不会出现 [2,1])。
怎么在代码里表达「只能选更大的」?加一个 start 参数:本层只从 start 到 n 枚举,递归时传 start+1。
因为下一层从 i+1 开始,永远不会回头选更小的,所以 [1,2] 和 [2,1] 不会被都生成。
n=4,k=2 的决策树(start 控制不回头):
[]
1 2 3 4
2 3 4 3 4 4 (4后再无更大)
[12][13][14][23][24][34] ✓ 六个,无重复
参考代码
class Solution {
public List<List<Integer>> combine(int n, int k) {
List<List<Integer>> res = new ArrayList<>();
List<Integer> path = new ArrayList<>();
backtrack(n, k, 1, path, res); // start 从 1 开始
return res;
}
private void backtrack(int n, int k, int start, List<Integer> path, List<List<Integer>> res) {
if (path.size() == k) { // 选满 k 个,收答案
res.add(new ArrayList<>(path)); // 拷贝!
return;
}
for (int i = start; i <= n; i++) { // 只从 start 往后,防重复
path.add(i); // 做选择
backtrack(n, k, i + 1, path, res); // 下一层从 i+1 起
path.remove(path.size() - 1); // 撤销
}
}
}
复杂度
时间 O(C(n,k) × k):共 C(n,k) 个组合,每个拷贝 path 花 O(k)。空间 O(k) 递归栈 + 结果集。
为什么 i+1 而非 start?传 start 会重复选同一位置导致无限递归;传 i+1 才保证「不回头」。
巩固练习(先自己想 15 分钟,卡住再看括号里的提示)
- 组合总和 III(力扣 216,中等)——与本题共用「start 防重复 + 回溯四件套」技巧;差异在多加了「k 个数之和必须等于 n」的约束,需要带一个当前和参数并做求和剪枝(提示:递归里维护 sum,sum > n 时直接 return,到叶子再判断 sum == n)。
- 组合总和(力扣 39,中等)——与本题共用「回溯枚举组合」技巧;差异在候选数字可重复使用,且数组元素不限定 1~n(提示:递归传 i 而不是 i+1,允许同一数字再被选;用 sum > target 剪枝)。
8.4子集(中等,力扣 78)
中等 核心:每个节点都收集
题意:给定数组(无重复元素),返回所有子集。例:[1,2,3] → 8 个:[], [1], [2], [3], [1,2], [1,3], [2,3], [1,2,3]。
思路推导
子集和组合的区别在哪?组合只要「长度恰好 k 的」;子集要所有长度的,包括空集。 所以决策树里每一个节点都是一个合法答案,不能只在叶子收,要「每往下走一步就收一次」。
仍用 start 防重复(同组合一样,元素不重复使用)。区别仅在结束条件:子集没有「必须选满」的硬要求,
递归自然会在 for 走完时停下;而「收集答案」放在递归函数最开头,每进入一层就先把当前 path 收进去。
[1,2,3] 子集决策树(每个节点都收):
[] ← 收 []
/ | \
1 2 3 ← 收 [1][2][3]
/ \ |
2 3 3 ← 收 [1,2][1,3][2,3]
|
3 ← 收 [1,2,3]
共 8 个 = 2^3 ✓
参考代码
class Solution {
public List<List<Integer>> subsets(int[] nums) {
List<List<Integer>> res = new ArrayList<>();
List<Integer> path = new ArrayList<>();
backtrack(nums, 0, path, 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); // 撤销
}
// 没有「长度等于 k」的硬性结束,for 走完自然回退
}
}
复杂度
时间 O(2ⁿ × n):共 2ⁿ 个子集,每个拷贝 O(n)。空间 O(n) 递归栈。 为什么子集是「每个节点收集」而组合是「叶子收集」?因为子集允许任意长度,路径本身就是答案; 组合只认长度恰到好处的那条,所以只在 path.size()==k 时收。两题骨架几乎一模一样,差的就是这一句放哪。
if (i>start && nums[i]==nums[i-1]) continue;),
否则会生成重复子集。本题数组无重复,不用这步。
巩固练习(先自己想 15 分钟,卡住再看括号里的提示)
- 子集 II(力扣 90,中等)——与本题共用「每个节点都收集 + start 不回头」技巧;差异在数组有重复元素,需要先排序再在同层跳过相同值,否则会生成重复子集(提示:排序后写
if (i > start && nums[i] == nums[i-1]) continue;跳过同层重复)。 - 子集的异或和(力扣 1863,简单)——与本题共用「枚举所有子集」框架;差异在不需要返回子集本身,而是求所有子集的异或和的总和(提示:可直接用回溯枚举子集对每个子集算 XOR 累加;或更简单——每个元素在 2^(n-1) 个子集中出现,可直接数学求和)。
8.5全排列(中等,力扣 46)
中等 核心:used 数组防重用
题意:给定数组(无重复),返回所有全排列(顺序不同算不同)。例:[1,2,3] → 6 个排列。
思路推导
排列和组合/子集最大的不同:每个数必须用且仅用一次,但顺序任意。
组合用 start 保证「不回头」来避免重复;但排列里 [1,2,3] 和 [2,1,3] 都是合法答案,不能「不回头」。
那怎么避免「同一个数用两次」?用 used 布尔数组标记哪些数已被选入当前 path,for 循环里跳过已用的。
[1,2,3] 全排列(used 挡住重复用):
第一层可选 1/2/3(都未用)
选1 → used[1]=true → 第二层可选 2/3
选2 → 第三层只能选 3 → [1,2,3]
选3 → 第三层只能选 2 → [1,3,2]
选2 → ... → [2,1,3][2,3,1]
选3 → ... → [3,1,2][3,2,1]
共 6 = 3! ✓
参考代码
class Solution {
public List<List<Integer>> permute(int[] nums) {
List<List<Integer>> res = new ArrayList<>();
List<Integer> path = new ArrayList<>();
boolean[] used = new boolean[nums.length]; // 标记用过没
backtrack(nums, used, path, res);
return res;
}
private void backtrack(int[] nums, boolean[] used, List<Integer> path, List<List<Integer>> res) {
if (path.size() == nums.length) { // 选满 n 个,一个排列完成
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; // 撤销标记(对称的!)
}
}
}
复杂度
时间 O(n! × n):共 n! 个排列,每个拷贝 O(n)。空间 O(n) 递归栈 + used 数组。
为什么组合用 start、排列用 used?组合/子集要「不回头」去重,排列要「每个都能放任意位置但不能重复用」——
本质区别是要不要保留顺序自由。背住这个对照,三题就不会混。
used[i] = false 这行极易漏,它和 used[i] = true 必须成对。
漏了的话,上一层回溯后该数仍被标记用过,后续分支就永远选不到它,答案残缺。
巩固练习(先自己想 15 分钟,卡住再看括号里的提示)
- 全排列 II(力扣 47,中等)——与本题共用「used 数组防重用」技巧;差异在数组有重复元素,要在同层跳过已用过的相同值,否则产生重复排列(提示:先排序,for 里加
if (i > 0 && nums[i] == nums[i-1] && !used[i-1]) continue;跳过同层重复)。 - 下一个排列(力扣 31,中等)——与本题共用「对排列做原地变换」的思维;差异在不是枚举所有排列,而是求「字典序下一个排列」,是纯思维题(提示:从右往左找第一对 nums[i] < nums[i+1],再找右侧比它大的最小数交换,最后把右侧反转)。
8.6N 皇后(困难,力扣 51)
困难 核心:回溯模板的完整形态
题意:在 n×n 棋盘上放 n 个皇后,使任意两个不互相攻击(不在同一行、同一列、同一对角线)。返回所有摆法。
例:n=4 有 2 种解。
思路推导
N 皇后是回溯模板的「完整形态」——组合/子集/排列的技巧它全用上:
- 一行一行放皇后(每行必放一个,天然保证不同行);
row参数相当于「当前层」,递归到 row==n 说明 n 行都放完,收一个解;- 每一层 for 循环枚举这一行的每一列,用
isValid检查「同列 / 左上方对角线 / 右上方对角线」有没有冲突; - 放的格子标记
'Q',递归返回后改回'.'——这就是「撤销选择」。
对角线判断的直觉:对于已放的行 r'(r' < row),若它放在列 c',那么要挡住当前 (row, col),需满足
col == c'(同列),或 row - r' == col - c'(左上↘方向,行差=列差),或 row - r' == c' - col(右上↙方向)。
把后两个合并成 abs(row - r') == abs(col - c') 即「同一对角线」。
参考代码(完整可提交)
class Solution {
public List<List<String>> solveNQueens(int n) {
List<List<String>> res = new ArrayList<>();
char[][] board = new char[n][n];
for (int i = 0; i < n; i++) // 棋盘初始化为 '.'
for (int j = 0; j < n; j++) board[i][j] = '.';
backtrack(board, 0, n, res); // 从第 0 行开始
return res;
}
// 在第 row 行尝试放皇后
private void backtrack(char[][] board, int row, int n, List<List<String>> res) {
if (row == n) { // 0..n-1 都放完 → 一个解
res.add(construct(board));
return;
}
for (int col = 0; col < n; col++) { // 枚举这一行的每一列
if (isValid(board, row, col, n)) {
board[row][col] = 'Q'; // 做选择
backtrack(board, row + 1, n, res); // 下一行
board[row][col] = '.'; // 撤销选择
}
}
}
// 检查 (row, col) 能不能放:同列 / 左上方 / 右上方 有无皇后
private boolean isValid(char[][] board, int row, int col, int n) {
for (int i = 0; i < row; i++) // 同列(只查上方,下方还没放)
if (board[i][col] == 'Q') return false;
for (int i = row - 1, j = col - 1; i >= 0 && j >= 0; i--, j--)
if (board[i][j] == 'Q') return false; // 左上方对角线 ↖
for (int i = row - 1, j = col + 1; i >= 0 && j < n; i--, j++)
if (board[i][j] == 'Q') return false; // 右上方对角线 ↗
return true;
}
// 把 char 矩阵转成 List<String> 方便输出
private List<String> construct(char[][] board) {
List<String> list = new ArrayList<>();
for (char[] r : board) list.add(new String(r));
return list;
}
}
复杂度
时间最坏 O(n!)(每行排除已占列,类似排列),实际因对角线剪枝远小于此。空间 O(n²) 棋盘 + O(n) 递归栈。 为什么只查「上方」的冲突?因为下方还没放皇后,只需保证和已放的行不冲突即可——这正体现回溯「逐步决策」的特点。
isValid 只向上查,千万别 horizontal 查(每行只放一个,不可能同行冲突)。
巩固练习(先自己想 15 分钟,卡住再看括号里的提示)
- N 皇后 II(力扣 52,困难)——与本题共用「逐行放置 + isValid 冲突检查」框架;差异在只要求解的个数而不需要具体摆法,把收集 List 改成计数即可(提示:去掉 construct 和 res.add,命中 row==n 时 count++ 即可,其余 isValid / 撤销逻辑一字不变)。
- 解数独(力扣 37,困难)——与本题共用「回溯 + 放置 + 撤销」的完整框架;差异在棋盘是 9×9 且要填数字 1~9,冲突检查变为行/列/宫三方(提示:用双重 for 找空格,对每个空格试 1~9,isValid 同时查行、列、3×3 宫;找到一个完整解即可直接返回)。
8.7剪枝:回溯的效率生命线
回溯慢的根因是「枚举了太多注定失败的分支」。剪枝(Pruning)就是在还没走到死胡同之前,提前判断「这条路不可能出答案」,直接不进去,把整棵子树砍掉。
组合剪枝公式的直观推导
回到组合(8.3):n=4, k=2,已选 path=[1],接下来 for 从 i=2 到 4。
但注意:还需要再选 k − path.size() = 1 个数。从位置 i 往后(含 i)还剩多少个数可挑?
是 n − i + 1 个。要让「剩下的数」够选,必须满足:
剩下的可选数 ≥ 还需要选的个数
n − i + 1 ≥ k − path.size()
移项得:i ≤ n − (k − path.size()) + 1
这就是剪枝上界。例:n=4, k=2,path=[1](已选 1 个),还需 1 个:
i ≤ 4 − (2−1) + 1 = 4,所以 i 到 4 都行([1,4] 合法)。
但若 path=[]、k=3、n=4:i ≤ 4 − (3−0) + 1 = 2,第一层只枚举到 2——
因为选了 3 之后只剩 [4] 一个数,凑不够 3 个,整条分支砍掉,省掉大量递归。
把 for 循环的上界从 i <= n 改成 i <= n - (k - path.size()) + 1:
// 组合剪枝版(仅改 for 上界)
for (int i = start; i <= n - (k - path.size()) + 1; i++) {
path.add(i);
backtrack(n, k, i + 1, path, res);
path.remove(path.size() - 1);
}
其他常见剪枝
- 子集 II 同层去重:
if (i > start && nums[i] == nums[i-1]) continue;跳过同层相同值,避免重复子集; - 求和类(如组合总和):若要求和等于 target,当前和已 > target 直接 return(前提是数组非负);
- N 皇后:
isValid本身就是在「放之前」剪掉冲突列,是最直接的剪枝。
一个具体例子:求和剪枝(组合总和类)
假设题目变成「从 [2,3,6,7] 里选数(可重复),凑出和为 7 的所有组合」。递归到某一层 path=[2,2]、当前和 = 4,
接下来要选的数若再取 6,和变成 10 已经超过 7——这条分支整棵子树都不可能等于 7,直接 return 剪掉,
不用再往下深搜。代码骨架:
void backtrack(int[] nums, int target, int start, int sum, List<Integer> path, List<List<Integer>> res) {
if (sum == target) { res.add(new ArrayList<>(path)); return; } // 凑齐,收
if (sum > target) return; // 超了,剪枝(数组非负才成立)
for (int i = start; i < nums.length; i++) {
path.add(nums[i]);
backtrack(nums, target, i, sum + nums[i], path, res); // 可重复就传 i 不传 i+1
path.remove(path.size() - 1);
}
}
这里 if (sum > target) return; 就是最朴素的剪枝:它把「和已超目标」的所有后续选择一次性砍光。
对比「不剪枝、硬搜到选满再判断」,在数组稍大时差距是数量级的——这也是为什么剪枝叫「回溯的效率生命线」。
8.8自测题
Q1.(动手题)组合、子集、全排列三题,for 循环的「防重复手段」分别是什么?一句话概括。
组合 / 子集用 start 参数(i 从 start 起、递归传 i+1),保证「不回头选」,去的是顺序重复;
全排列用 used 布尔数组跳过已选元素,允许任意顺序但不允许同一元素用两次。三题骨架相同,差在「怎么缩选择列表」。
Q2. 为什么 res.add(path) 不拷贝,最后会得到一堆空列表?
因为 path 是引用,res 里存的「同一个对象」和回溯过程里的 path 是同一个。后续 path.remove 撤销选择时,
把那个对象也清空了,于是 res 中所有元素都指向同一个被清空的列表。必须 res.add(new ArrayList<>(path)) 拷贝一份快照。
Q3.(动手题)组合剪枝上界 i <= n - (k - path.size()) + 1,用 n=5, k=3, path=[1] 算一下,第一层该枚举到几?
已选 1 个,还需 2 个。上界 = 5 − (3 − 1) + 1 = 3。所以当前层 i 从 2 枚举到 3(start=2): 选 2 后还能选 [3,4,5] 凑 3 个;选 3 后还能选 [4,5] 凑 3 个;选 4 后只剩 [5] 一个不够,被剪掉。 上界 3 正把「4、5」这两条注定凑不齐的分支砍了。