回溯法的“递归四步法”与去重模板:组合、排列、剪枝一网打尽

0 阅读9分钟

回溯法的“递归四步法”与去重模板:组合、排列、剪枝一网打尽

摘要:回溯法本质是遍历一棵决策树。本文从递归四步法出发,拆解组合与排列的本质区别,总结去重逻辑的一句话口诀,提供剪枝优化策略和题型分类速查表,让你遇到回溯题不再靠“背代码”。

📑 目录

  • 回溯法是什么?——决策树的遍历
  • 递归四步法:写回溯前先问自己这四个问题
  • 两大核心分支:组合 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 > start90
组合(无重)固定长度,start 控制77
组合(可重复用)元素可重复取,i 不 +139
组合(有重)固定长度 + 去重i > start40
全排列(无重)全长度,used 控制46
全排列(有重)全长度 + 去重!used[i-1]47
字符串分割分割点回溯合法性判断131, 93
棋盘问题位置合法性剪枝对角/行列判断51, 37

一张图看懂核心区别

组合(无序)排列(有序)
核心控制startused
下一层起点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)。由于是指数级复杂度,剪枝优化是让代码“跑得动”的关键。