之前整理过链表的快慢指针。做那类题时,我习惯先画出两个指针的位置,再想循环结束后它们会停在哪里。今天学习回溯算法,我也用了类似的办法:先画出每一步选了什么,再看代码。
刚看到回溯代码时,最容易让我停下来的地方是:明明递归已经调用了下一层,为什么回来之后还要把刚加入的元素删掉?如果只背"递归三部曲",下次换一道题,还是容易写乱。这篇文章就从这个问题开始,记录一下我对回溯的理解。
回溯在做什么
假设有数字 1、2、3,要从中选两个数字,列出所有组合。可以先选 1,再选 2,得到 [1, 2]。这一组记录下来以后,还要回到只选了 1 的状态,再试试 3,得到 [1, 3]。
text
开始 []
├── 选 1 → [1]
│ ├── 选 2 → [1, 2] 保存
│ └── 选 3 → [1, 3] 保存
└── 选 2 → [2]
└── 选 3 → [2, 3] 保存
探索完 [1, 2] 后,需要把 2 从当前路径中移除,才能尝试 [1, 3]。探索完以 1 开头的所有情况后,还要移除 1,才能从 2 开始。
回溯通常用深度优先搜索逐条尝试,并在离开一条分支时恢复修改过的状态。画成树,就是沿着一条分支往下走;走完以后退回上一个节点,再走另一条分支。递归负责进入和返回,撤销选择负责让共享的路径回到上一层的样子。
一段回溯代码通常在维护什么
以找组合为例,可以先记住三个东西:
path:当前已经选了哪些数字。- 可选范围:接下来还能从哪些数字中选。
- 结束条件:什么时候得到一组结果,或者什么时候已经不可能得到结果。
代码的核心顺序通常是:
text
做选择 → 进入下一层 → 撤销这次选择 → 尝试下一个选择
这里的"撤销"不是删除已经保存的答案,而是清理正在使用的 path。如果不清理,上一条分支选过的数字就会留到下一条分支里。
回溯也不意味着所有问题的写法都一样。有的题不允许重复使用元素,有的题可以重复使用;有的题要找所有结果,有的题找到一个可行结果就可以结束。先确定题目的规则,再决定怎么控制可选范围。
例一:组合,为什么下一层从 i + 1 开始
力扣第 77 题"组合"的要求是:从 1 到 n 中选出 k 个不同的数字,返回所有组合。例如 n = 4、k = 2,结果包括 [1, 2] 和 [1, 3]。
组合不考虑顺序:[1, 2] 和 [2, 1] 表示同一组数字。因此,选过 1 以后,只需要从它后面的数字继续选,不用再回头选前面的数字。
java
import java.util.ArrayList;
import java.util.List;
class Solution {
public List<List<Integer>> combine(int n, int k) {
List<List<Integer>> result = new ArrayList<>();
List<Integer> path = new ArrayList<>();
backtrack(n, k, 1, path, result);
return result;
}
private void backtrack(int n, int k, int start,
List<Integer> path,
List<List<Integer>> result) {
if (path.size() == k) {
result.add(new ArrayList<>(path));
return;
}
for (int i = start; i <= n; i++) {
path.add(i); // 做选择
backtrack(n, k, i + 1, path, result); // 继续选择
path.remove(path.size() - 1); // 撤销选择
}
}
}
比如当前 path 是 [1],这一层选了 2,递归进入下一层后,path 变成 [1, 2]。长度达到 k,保存结果并返回。返回到上一层时,path.remove(...) 移除末尾的 2,路径重新变回 [1],循环才可以继续尝试 3。
我现在会把 start 理解成"这一层最早可以从哪里开始选"。递归时传 i + 1,一方面避免重复选择当前数字,另一方面也避免出现 [1, 2] 和 [2, 1] 这样的重复组合。
为什么保存时要复制 path
代码里写的是:
java
result.add(new ArrayList<>(path));
因为整个搜索过程反复修改的是同一个 path 对象。如果直接写 result.add(path),保存进去的会是同一个列表的引用;后续撤销选择时,这个列表也会跟着变化。复制一份,才是把当时的内容留下来。
可以提前停止吗
可以。假如还需要选 3 个数字,但后面只剩 2 个数字,这条分支就不可能凑够,可以不继续搜索。这种提前排除无效分支的做法叫剪枝。
上面代码中的循环条件可以改为:
java
for (int i = start; i <= n - (k - path.size()) + 1; i++) {
其中 k - path.size() 是还需要选择的数量。这个边界不是必须先死记下来;可以先写出不剪枝的版本,再用几个小例子验证:从 i 到 n 剩下的数字,是否还够选。剪枝减少的是无效搜索,不能把可能产生正确答案的分支也剪掉。
例二:全排列,为什么需要 used
力扣第 46 题"全排列"给出一组互不相同的数字,要求列出所有排列。例如 [1, 2] 和 [2, 1] 是两个不同结果。
这和组合不同:每一层都需要重新考虑所有数字,但同一条路径中不能重复使用已经选过的数字。因此可以用 used 数组记录哪些位置已经被选择。
java
import java.util.ArrayList;
import java.util.List;
class Solution {
public List<List<Integer>> permute(int[] nums) {
List<List<Integer>> result = new ArrayList<>();
List<Integer> path = new ArrayList<>();
boolean[] used = new boolean[nums.length];
backtrack(nums, used, path, result);
return result;
}
private void backtrack(int[] nums, boolean[] used,
List<Integer> path,
List<List<Integer>> result) {
if (path.size() == nums.length) {
result.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, result);
path.remove(path.size() - 1);
used[i] = false;
}
}
}
这里需要恢复两处状态:从 path 中移除数字,并把对应的 used[i] 改回 false。只恢复其中一个,后续分支看到的状态就不完整。
这份代码针对题目给出的"数字互不相同"的条件。如果数组中有重复数字,还要考虑去重,不能直接照搬。
组合和排列,先看题目在意什么
我以前容易看到"选数字"就想套同一份代码。区分这两类题,可以先问一句:换个顺序,算不算新结果?
| 问题 | 组合 | 排列 |
|---|---|---|
[1, 2] 和 [2, 1] |
同一组 | 两种结果 |
| 下一层从哪里选 | 通常从当前位置之后开始 | 通常重新遍历所有位置 |
| 如何避免同一路径重复使用 | 缩小起始范围 | 用 used 标记已选位置 |
这张表只适用于上面两道题。比如有的组合题允许一个数字重复使用,下一层就不一定从 i + 1 开始。遇到新题时,还是要先读清楚规则。
做题时我会检查的几个地方
第一,看结束条件。是路径长度达到要求就保存,还是还要检查总和、合法性?结果保存之后要不要立即返回?这些都取决于题目。
第二,看每一层的选择范围。组合里用 start,排列里用 used;如果题目允许重复使用,范围又会不同。
第三,看状态有没有恢复。除了 path,还可能有标记数组、当前和、棋盘格子等。哪一层修改了共享状态,返回时就要考虑怎样恢复,尤其要留意提前返回的分支。
第四,看结果有没有复制。保存的是当前路径的快照,还是一个后面还会继续变化的对象?Java 里这个区别很容易影响最后的结果。
最后再看性能。回溯可能要探索很多分支,能否剪枝要看题目。上面的全排列有 n! 个结果,每个结果需要复制 n 个元素,时间复杂度为 O(n × n!);不算保存结果所需的空间,路径、标记数组和递归栈使用 O(n) 空间。组合题的搜索分支也会随 n 增长很快,具体数量与 k 和剪枝方式有关。
写在最后
作为准备做后端开发的学生,学这类算法时我也会想:工作中不一定每天都要手写全排列,为什么还要花时间画搜索树?对我来说,收获是开始习惯把问题拆成状态和选择,再检查每一步会给后续带来什么影响。遇到需要枚举方案或组合测试条件的问题时,这种思路也能帮我整理边界;实际项目是否适合用回溯,还要看数据规模和性能要求。
这次学习里,我最想记住的是那句 path.remove(...)。它看起来只删掉了一个元素,实际上是在告诉自己:这一条路已经试完了,恢复现场,才能安心去试下一条。