回溯专题:Mermaid 图解搜索树、剪枝与 4 道 LeetCode 核心题
用 Mermaid 图解回溯的搜索树、路径选择、剪枝策略和 4 道 LeetCode 经典题,系统掌握回溯专题。
回溯题最容易让人产生一种错觉:代码好像不长,但一写就乱。
原因通常不是语法不会,而是脑中没有一棵明确的搜索树。你不知道:
- 当前这层在选什么
- 路径里保存的是什么
- 什么时候该撤销选择
- 为什么有些分支可以直接剪掉
这篇文章延续上一篇的 Mermaid 图解风格,直接把回溯题最核心的结构画出来,再用 4 道 LeetCode 题把“子集、组合、排列、剪枝”这几类高频模型串起来。
学习目标:
- 理解回溯的本质:在搜索树上做深度优先枚举。
- 掌握回溯三件事:路径、选择列表、结束条件。
- 理解剪枝的作用与常见策略。
- 用 4 道 LeetCode 题覆盖回溯高频模型。
- 用一张知识卡片快速复习回溯模板与性能要点。
一、回溯的本质:在搜索树上试错
回溯并不神秘,本质上就是:
在一棵隐式搜索树上进行 DFS,走得通就继续走,走不通就退回来换一条路。
flowchart TD
A[起点状态] --> B[做一个选择]
B --> C[进入下一层状态]
C --> D{是否满足结束条件}
D -->|否| E[继续枚举下一层选择]
D -->|是| F[记录答案]
E --> G[发现无效或走不通]
G --> H[撤销选择]
H --> I[尝试同层的下一个选择]
这就是“回溯”两个字的含义:
- 向前:做选择,深入一层
- 向后:撤销选择,回到上一层
所以回溯的关键词从来不是“递归”,而是:
- 搜索树
- 枚举
- 试错
- 撤销选择
二、回溯三要素:路径、选择列表、结束条件
大多数回溯题,本质都能拆成这三个问题。
1. 路径 path
表示你当前已经做出的选择。
2. 选择列表 choices
表示当前这一层还可以选什么。
3. 结束条件 end condition
表示什么时候这一条路径已经构成一个完整答案。
flowchart TD
A[回溯函数] --> B[路径 path]
A --> C[选择列表 choices]
A --> D[结束条件 end]
B --> E[当前已选内容]
C --> F[这一层还能选谁]
D --> G[什么时候收集答案]
一句话模板
做选择 -> 递归进入下一层 -> 撤销选择
这是回溯题最核心的一行注释。
三、为什么回溯通常用递归
回溯本质上是在树上做深度优先遍历,而递归天然适合描述“进入下一层、返回上一层”的结构。
flowchart TD
A[第 0 层] --> B[第 1 层]
B --> C[第 2 层]
C --> D[第 3 层]
D --> E[达到叶子节点]
E --> F[返回第 2 层]
F --> G[撤销选择]
G --> H[尝试同层其他分支]
所以回溯可以理解成:
- 递归负责“进”和“退”
path负责记录当前路径- 循环负责枚举同层分支
四、回溯模板:真正要背的是结构,不是代码
最通用的回溯模板如下:
1
2
3
4
5
6
7
8
9
10
11
12
void backtrack(/* ?? */) {
if (??????) {
????;
return;
}
for (auto choice : ???????) {
???;
backtrack(/* ????? */);
????;
}
}
用 Mermaid 画出来就是:
flowchart TD
A[进入 backtrack] --> B{是否满足结束条件}
B -->|是| C[记录结果并返回]
B -->|否| D[遍历当前层所有选择]
D --> E[做选择]
E --> F[递归进入下一层]
F --> G[撤销选择]
G --> H[继续枚举下一个选择]
真正写题时,你只需要把三样东西补进去:
- 结束条件是什么
- 当前层能选什么
- 做选择和撤销选择分别怎么写
五、搜索树视角:为什么有些题是子集,有些是排列
很多人刷回溯题时,明明模板长得差不多,却总觉得题和题之间跳跃很大。原因是没有从“搜索树结构”去分型。
1. 子集型
每个元素只有“选”或“不选”两种状态。
flowchart TD
A["[]"] --> B["选 1 -> [1]"]
A --> C["不选 1 -> []"]
B --> D["选 2 -> [1,2]"]
B --> E["不选 2 -> [1]"]
C --> F["选 2 -> [2]"]
C --> G["不选 2 -> []"]
2. 组合型
关心“选哪些”,不关心顺序,通常通过 startIndex 避免重复。
flowchart TD
A["start=0, []"] --> B["选 1 -> [1]"]
A --> C["选 2 -> [2]"]
A --> D["选 3 -> [3]"]
B --> E["从 2 开始继续选 -> [1,2]"]
B --> F["从 3 开始继续选 -> [1,3]"]
C --> G["从 3 开始继续选 -> [2,3]"]
3. 排列型
关心顺序,所以每一层都要从“还没用过的元素”里选。
flowchart TD
A["[]"] --> B["选 1 -> [1]"]
A --> C["选 2 -> [2]"]
A --> D["选 3 -> [3]"]
B --> E["再选 2 -> [1,2]"]
B --> F["再选 3 -> [1,3]"]
C --> G["再选 1 -> [2,1]"]
C --> H["再选 3 -> [2,3]"]
你把题先判成“子集 / 组合 / 排列”中的哪一类,回溯会好写很多。
六、剪枝:为什么回溯可以从暴力变得可用
回溯本质是枚举,所以理论上很容易爆炸。真正让回溯题可做的关键,是剪枝。
剪枝就是:
提前判断这条路不可能产生合法答案,于是立刻停止往下搜。
flowchart TD
A[当前状态] --> B{这条路径还有希望吗}
B -->|没有| C[直接返回 剪枝]
B -->|有| D[继续递归]
常见剪枝方式
1. 越界剪枝
例如组合总和里,当前和已经大于目标值,就没必要继续往下搜。
2. 有序数组剪枝
如果数组已经排序,而当前值已经让结果超标,后面更大的值也不可能成立,可以直接 break。
3. 重复元素剪枝
在同一层中,跳过相同元素,避免重复答案。
4. 约束冲突剪枝
例如 N 皇后里,同列、同对角线冲突时立即返回。
mindmap
root((剪枝))
越界
sum 超过 target
长度超过限制
有序
排序后 break
去重
同层相同元素跳过
冲突
列冲突
对角线冲突
七、4 道 LeetCode 题目打通回溯专题
下面这 4 题分别对应子集、组合、排列、强约束回溯四种核心模型。
1)LeetCode 78. 子集
题型定位:子集型回溯。
核心:每到一个节点,当前路径本身就是一个答案。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Solution {
public:
vector<vector<int>> subsets(vector<int>& nums) {
vector<vector<int>> res;
vector<int> path;
function<void(int)> dfs = [&](int start) {
res.push_back(path);
for (int i = start; i < static_cast<int>(nums.size()); ++i) {
path.push_back(nums[i]);
dfs(i + 1);
path.pop_back();
}
};
dfs(0);
return res;
}
};
flowchart TD
A["[]"] --> B["[1]"]
A --> C["[2]"]
A --> D["[3]"]
B --> E["[1,2]"]
B --> F["[1,3]"]
C --> G["[2,3]"]
E --> H["[1,2,3]"]
这题训练的是:
path的含义- 为什么“每个节点都是答案”
- 为什么要用
start防止回头选
2)LeetCode 77. 组合
题型定位:组合型回溯。
核心:从 [1..n] 中选出 k 个数,不关心顺序。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Solution {
public:
vector<vector<int>> combine(int n, int k) {
vector<vector<int>> res;
vector<int> path;
function<void(int)> dfs = [&](int start) {
if (static_cast<int>(path.size()) == k) {
res.push_back(path);
return;
}
for (int i = start; i <= n; ++i) {
path.push_back(i);
dfs(i + 1);
path.pop_back();
}
};
dfs(1);
return res;
}
};
组合题的经典剪枝
如果当前还需要选 need = k - path.size() 个数,而从 i 到 n 剩余元素个数都不够,就可以剪掉。
flowchart TD
A["path.size < k"] --> B["还需要 need 个数"]
B --> C{"剩余元素数量 < need ?"}
C -->|是| D["剪枝返回"]
C -->|否| E["继续搜索"]
这题训练的是:
startIndex的作用- 组合与排列的区别
- 剪枝如何减少分支
3)LeetCode 46. 全排列
题型定位:排列型回溯。
核心:顺序不同就是不同答案,因此每一层都从未使用元素里选。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Solution {
public:
vector<vector<int>> permute(vector<int>& nums) {
vector<vector<int>> res;
vector<int> path;
vector<bool> used(nums.size(), false);
function<void()> dfs = [&]() {
if (path.size() == nums.size()) {
res.push_back(path);
return;
}
for (int i = 0; i < static_cast<int>(nums.size()); ++i) {
if (used[i]) continue;
used[i] = true;
path.push_back(nums[i]);
dfs();
path.pop_back();
used[i] = false;
}
};
dfs();
return res;
}
};
flowchart TD
A["[]"] --> B["[1]"]
A --> C["[2]"]
A --> D["[3]"]
B --> E["[1,2]"]
B --> F["[1,3]"]
E --> G["[1,2,3]"]
F --> H["[1,3,2]"]
这题训练的是:
- 为什么排列必须有
used[] - 为什么不能只靠
startIndex - 顺序敏感与顺序不敏感题型的区别
4)LeetCode 39. 组合总和
题型定位:带剪枝的组合型回溯。
核心:元素可重复使用,但路径和不能超过 target。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Solution {
public:
vector<vector<int>> combinationSum(vector<int>& candidates, int target) {
sort(candidates.begin(), candidates.end());
vector<vector<int>> res;
vector<int> path;
function<void(int, int)> dfs = [&](int start, int sum) {
if (sum == target) {
res.push_back(path);
return;
}
for (int i = start; i < static_cast<int>(candidates.size()); ++i) {
if (sum + candidates[i] > target) break;
path.push_back(candidates[i]);
dfs(i, sum + candidates[i]);
path.pop_back();
}
};
dfs(0, 0);
return res;
}
};
flowchart TD
A["sum=0, []"] --> B["选 2 -> sum=2, [2]"]
B --> C["再选 2 -> sum=4, [2,2]"]
C --> D["再选 3 -> sum=7, [2,2,3]"]
C --> E["若下一步超过 target -> 剪枝"]
这题最重要的不是模板,而是理解两件事:
- 为什么可以重复选,所以递归还是从
i开始 - 为什么排序后可以直接
break,而不是continue
八、回溯题怎么快速分类
做题时可以按下面这个流程判断。
flowchart TD
A[题目要求枚举所有可能吗] -->|否| B[未必是回溯 先看贪心 DP 图论]
A -->|是| C[结果是否关心顺序]
C -->|关心| D[排列型]
C -->|不关心| E[元素是否可以重复使用]
E -->|可以| F[组合型 可重复]
E -->|不可以| G[是要全部枚举还是选固定个数]
G -->|全部枚举| H[子集型]
G -->|固定个数| I[组合型]
这个分类框架非常实用,能快速决定:
- 要不要
used[] - 要不要
startIndex - 下一层是从
i + 1还是i开始
九、回溯常见错误
1)忘记撤销选择
这是最典型的问题。你做了选择却没撤销,path 状态就会污染兄弟分支。
flowchart TD
A[做选择] --> B[递归进入下一层]
B --> C[返回当前层]
C --> D[撤销选择]
少了最后一步,整棵搜索树都会错。
2)把“树层去重”和“树枝去重”混在一起
尤其是包含重复元素的题,同层去重和同一路径去重不是一回事。
3)组合题和排列题写混
组合题通常用 startIndex,排列题通常用 used[]。
4)不会剪枝,导致超时
回溯如果不剪枝,很多题理论能做,实际跑不完。
5)结果集直接存 path
必须存 path 的拷贝,例如 res.push_back(path),否则后续回溯会把结果改掉。
十、回溯知识卡片
下面这张图适合单独复习。
mindmap
root((回溯))
本质
搜索树
DFS
试错
撤销选择
三要素
path
choices
end
分类
子集
组合
排列
核心动作
做选择
递归
撤销选择
优化
剪枝
排序
去重
常错点
忘记回退
去重错误
存引用不存拷贝
复习版要点:
- 回溯是在隐式搜索树上做 DFS
- 模板核心是“做选择 -> 递归 -> 撤销选择”
- 子集、组合、排列三类题的搜索树结构不同
- 剪枝是让回溯真正可用的关键
- 组合题常见
startIndex,排列题常见used[]
十一、最后总结
如果只记一句话,请记这个:
回溯不是在“写递归”,而是在“管理搜索树”。
你真正要训练的是:
- 这题的路径是什么
- 这层还能选什么
- 什么时候收集答案
- 哪些分支可以提前剪掉
把这篇里的 4 道题做透,再把“回溯知识卡片”反复看几遍,回溯专题就能真正建立起稳定框架。