回溯法的“递归四步法”与去重模板:组合、排列、剪枝一网打尽
摘要:回溯法本质是遍历一棵决策树。本文从递归四步法出发,拆解组合与排列的本质区别,总结去重逻辑的一句话口诀,提供剪枝优化策略和题型分类速查表,让你遇到回溯题不再靠“背代码”。
📑 目录
- 回溯法是什么?——决策树的遍历
- 递归四步法:写回溯前先问自己这四个问题
- 两大核心分支:组合 vs 排列
- 四种骨架代码:套就完了
- 去重逻辑一句话总结
- 剪枝优化:让暴力变优雅
- 题型分类速查表
- 回溯法核心心法
- 互动讨论
回溯法是什么?——决策树的遍历
回溯法本质上是在遍历一棵决策树。
想象你在玩一个迷宫游戏:每到一个岔路口,你选择一个方向走进去,如果发现是死路,就退回到上一个岔路口,换一个方向继续走。 “走到头发现不对就退回来” ——这就是回溯。
在算法题中,回溯法用于解决 “枚举所有可能性” 的问题:组合、排列、子集、分割、棋盘问题……这些问题的共同点是:解空间是一个树形结构,我们需要遍历整棵树找到所有满足条件的叶子节点。
回溯法不需要你把递归展开想清楚,只需要相信“递归四步法”这套流程。
递归四步法:写回溯前先问自己这四个问题
任何回溯题,都可以用这个骨架去套:
c
void backtrack(参数) {
// 1. 终止条件:什么时候“收网”?
if (满足条件) { 保存结果; return; }
for (选择 in 当前层可选列表) {
// 2. 剪枝:哪些路是死路,提前跳过
if (剪枝条件) continue;
// 3. 做选择(修改状态)
path[pathSize++] = nums[i];
// 4. 进入下一层递归(参数根据组合/排列调整)
backtrack(下一层参数);
// 5. 撤销选择(恢复现场)
pathSize--;
}
}
写代码时,反复确认这四步是否完备:
| 步骤 | 核心问题 | 关键动作 |
|---|---|---|
| 1. 终止条件 | 什么时候记录答案? | 判断是否满足条件 → 保存结果 → return |
| 2. 剪枝 | 哪些分支可以提前跳过? | 排序后判断、合法性检查 |
| 3. 做选择 | 当前层选哪个? | 修改状态(path、used、sum 等) |
| 4. 下一层 | 下一层从哪开始? | 根据组合/排列调整 start 或 used |
| 5. 撤销选择 | 回溯后怎么恢复? | 恢复状态(path 回退、used 复位) |
这五步缺一不可,写回溯时按顺序填空,就能覆盖所有逻辑。
两大核心分支:组合 vs 排列
写回溯前,先判断这题是组合(无序)还是排列(有序) ,这决定了你用 start 还是 used。
| 类型 | 特点 | 核心参数 | 去重依赖 |
|---|---|---|---|
| 组合/子集(无序) | [1,2] = [2,1] | start(每次只能往后选) | i > start && nums[i] == nums[i-1] |
| 排列(有序) | [1,2] ≠ [2,1] | used[](标记已选) | used[i] + !used[i-1] |
为什么组合用 start?
组合的无序性决定了:选完 [1,2] 后,不能再选 [2,1]。用 start 控制“每次只能从当前位置往后选”,天然保证了顺序不重复。
为什么排列用 used?
排列的有序性决定了:[1,2] 和 [2,1] 是两种不同的结果。用 used 标记哪些元素已经被选入当前路径,确保每个元素在一条路径中只出现一次。
四种骨架代码:套就完了
组合骨架(无重复元素)
适用于:元素不重复、组合长度固定、顺序无关。
c
void backtrack(int start) {
if (pathSize == k) { 保存; return; }
for (int i = start; i < n; i++) {
path[pathSize++] = nums[i];
backtrack(i + 1); // ★ 只能往后选
pathSize--;
}
}
关键点:backtrack(i + 1) 确保每次只往后选,不会出现 [2,1] 这样的重复组合。
组合骨架(有重复元素,需去重)
适用于:元素有重复、组合长度固定、顺序无关。
c
// 必须先排序
qsort(nums, n, sizeof(int), cmp);
void backtrack(int start) {
if (pathSize == k) { 保存; return; }
for (int i = start; i < n; i++) {
// ★ 核心去重:同一层跳过重复值
if (i > start && nums[i] == nums[i-1]) continue;
path[pathSize++] = nums[i];
backtrack(i + 1);
pathSize--;
}
}
关键点:i > start && nums[i] == nums[i-1]——同一层中,如果当前值和前一个值相同,跳过。去重的前提是数组必须先排序。
排列骨架(无重复元素)
适用于:元素不重复、全排列。
c
void backtrack() {
if (pathSize == n) { 保存; return; }
for (int i = 0; i < n; i++) {
if (used[i]) continue; // ★ 跳过已选
used[i] = 1;
path[pathSize++] = nums[i];
backtrack();
pathSize--;
used[i] = 0;
}
}
关键点:used[i] 防止同一个元素在一条路径中被重复使用。每次从 0 开始遍历,因为排列是有序的。
排列骨架(有重复元素,需去重)
适用于:元素有重复、全排列。
c
void backtrack() {
if (pathSize == n) { 保存; return; }
for (int i = 0; i < n; i++) {
if (used[i]) continue;
// ★ 同层去重:前一个相同元素未被使用,说明在同一层,跳过
if (i > 0 && nums[i] == nums[i-1] && !used[i-1]) continue;
used[i] = 1;
path[pathSize++] = nums[i];
backtrack();
pathSize--;
used[i] = 0;
}
}
关键点:!used[i-1] 是排列去重的灵魂。当 nums[i] == nums[i-1] 且 used[i-1] == 0 时,说明前一个相同元素在当前层已经被使用过并撤销了,此时当前元素在同一层重复,跳过。
去重逻辑一句话总结
“组合去重看
start,排列去重看used;同层重复要跳过,有重复必先排序。”
| 代码片段 | 含义 | 适用场景 |
|---|---|---|
i > start && nums[i] == nums[i-1] | 跳过同一层中的重复值 | 组合(有重复元素) |
!used[i-1] | 前一个相同元素已被撤销,说明在同一层 | 排列(有重复元素) |
used[i] | 当前元素已在路径中 | 排列(防止同路径重复) |
为什么必须排序? 去重的核心是“相同元素相邻”,只有排序后才能用 nums[i] == nums[i-1] 判断重复。
剪枝优化:让暴力变优雅
回溯法是暴力搜索,但通过剪枝可以让它“优雅地暴力”。
| 剪枝类型 | 代码形态 | 适用场景 |
|---|---|---|
| 排序剪枝 | if (sum + nums[i] > target) break; | 组合求和,数组已排序 |
| 数量剪枝 | i <= n - (k - pathSize) + 1 | 组合数固定长度 |
| 合法性剪枝 | if (!isValid(...)) continue; | N 皇后、数独、IP 地址分割 |
数量剪枝详解:
i <= n - (k - pathSize) + 1 表示“当前层至少需要留够剩余元素”。如果 pathSize 已经选了 m 个元素,还需要选 k - m 个,那么 i 的最大值是 n - (k - m)。
排序剪枝示例(LeetCode 39):
c
// 先排序
qsort(candidates, n, sizeof(int), cmp);
void backtrack(int start, int sum) {
if (sum == target) { 保存; return; }
for (int i = start; i < n; i++) {
if (sum + candidates[i] > target) break; // ★ 排序剪枝
path[pathSize++] = candidates[i];
backtrack(i, sum + candidates[i]); // 可重复用,i 不 +1
pathSize--;
}
}
题型分类速查表
| 题型 | 核心特征 | 去重要点 | 代表题 |
|---|---|---|---|
| 子集(无重) | 所有长度,start 控制 | 无 | 78 |
| 子集(有重) | 所有长度 + 去重 | i > start | 90 |
| 组合(无重) | 固定长度,start 控制 | 无 | 77 |
| 组合(可重复用) | 元素可重复取,i 不 +1 | 无 | 39 |
| 组合(有重) | 固定长度 + 去重 | i > start | 40 |
| 全排列(无重) | 全长度,used 控制 | 无 | 46 |
| 全排列(有重) | 全长度 + 去重 | !used[i-1] | 47 |
| 字符串分割 | 分割点回溯 | 合法性判断 | 131, 93 |
| 棋盘问题 | 位置合法性剪枝 | 对角/行列判断 | 51, 37 |
一张图看懂核心区别:
| 组合(无序) | 排列(有序) | |
|---|---|---|
| 核心控制 | start | used |
| 下一层起点 | i + 1(不能回头) | 0(从头选) |
| 去重条件 | i > start | !used[i-1] |
| 代表题 | 组合、子集 | 全排列 |
回溯法核心心法
做题时默念这段话:
“先判组合还是排列,决定选
start还是used;有重复必排序,同层重复要跳过;递归前做选择,递归后撤选择;剪枝靠排序,性能靠约束。”
遇到具体题时,按照这个流程走:
text
1. 这题是组合还是排列?
├── 组合 → 用 start,下一层 i+1
└── 排列 → 用 used,下一层从 0 开始
2. 元素有重复吗?
├── 有 → 先排序,加同层去重
└── 无 → 不用去重
3. 需要剪枝吗?
├── 求和/求目标 → 排序后加 break
├── 固定长度 → 加数量剪枝
└── 棋盘/分割 → 加合法性剪枝
互动讨论
💬 回溯和 DFS(深度优先搜索)有什么区别?
回溯 = DFS + 剪枝 + 状态恢复。DFS 是遍历方式,回溯是在 DFS 的基础上加入了“做选择”和“撤销选择”两个动作,并辅以剪枝优化。可以说回溯是一种带状态管理的 DFS。
💬 i > start 和 !used[i-1] 到底有什么区别?
两者都是“同层去重”,但针对的场景不同:
i > start用于组合——因为组合用start控制范围,i > start意味着“当前层不是第一个元素”!used[i-1]用于排列——因为排列用used控制,!used[i-1]意味着“前一个相同元素在当前层已被使用并撤销”
简单说:组合用 i > start,排列用 !used[i-1]。
💬 组合求和(LeetCode 39)为什么用 backtrack(i, sum + candidates[i]) 而不是 i+1?
因为题目允许同一个数字可以无限制重复被选取。所以下一层仍然从 i 开始(而不是 i+1),而不是从下一个元素开始。如果不允许重复,就用 i+1。
💬 什么时候用 break,什么时候用 continue?
break:当数组已排序,且后续元素都不满足条件时(如sum + nums[i] > target),直接跳出循环continue:当只是当前元素不满足条件,但后续元素可能满足时(如合法性判断),跳过当前继续下一个
💬 回溯法的时间复杂度怎么算?
回溯法的时间复杂度 = 解的数量 × 构建每个解的成本。例如全排列是 O(n × n!),组合是 O(C(n,k) × k)。由于是指数级复杂度,剪枝优化是让代码“跑得动”的关键。