回溯算法学到第四篇,我估计你已经挨过组合问题的毒打,开始接触子集和排列了。如果你正好卡在"组合还行、子集和排列一写就乱""去重不知道怎么下手"这个阶段,这篇文章应该能帮你把思路掰顺。我先说结论:回溯算法的代码模板翻来覆去就那么十几行,真正拉开差距的地方在"结果收集的时机"和"去重的维度",这两个点恰好是第四部分的核心考点,也是后面做棋盘类问题的基础。
我不太喜欢把 four 个题分开硬讲,因为子集和排列背后是同一套递归遍历逻辑,硬拆开反而让人混乱。下面我会按"看视角、挖细节、抓本质"的方式,把 78 子集、90 子集 II、46 全排列、47 全排列 II 四道题放到一条线里讲清楚,顺带把 used 数组的两种用法、树层去重和树枝去重的区别说明白。
1. 从组合到子集和排列:做题视野必须切换的三个变化
如果你一路从组合总和那一章刷过来,应该已经熟悉了"递归纵向深入收集路径,for 循环横向遍历选择列表"这种写法。到子集和排列这一篇,模板没变,但有三个变化必须从思维层面切换过来,否则代码写着写着就会懵。
1.1 第一个变化:收集结果的位置完全不同
组合问题通常只在递归终止条件处收集结果,也就是说我们需要的是"叶子节点上的路径"。子集问题则要求收集所有节点上的状态,空集、单元素集合、完整集合全都要。
这就是为什么子集问题的代码里,result.add(new ArrayList<>(path))往往放在递归函数的最开头,而不是终止条件里。我强调过很多次:在所有节点收集结果的情况下,递归函数进来第一次就要保存当前路径,再去判断要不要剪枝返回。很多新手会习惯性地把收集动作放在终止条件里面,导致子集结果少一堆中间状态,这是子集系列做错题最典型的原因。
同一个模板,收集位置的差异,直接导致题目结果的差异。所以你在看题解的时候,第一件事永远是定位"结果在哪里被收集",这比背模板有用得多。
1.2 第二个变化:for 循环的起始位置不一定从 0 开始
组合问题要求元素不重复选择,且不强调顺序,所以 for 循环通常是从startIndex开始的。子集问题和组合问题在这点上是共通的,因为子集也不强调元素顺序,而且不能包含重复的组合。
但排列问题彻底变了。排列强调顺序,[1,2,3]和[3,2,1]是两个不同的排列,所以每一层递归都需要从头开始遍历所有元素。从startIndex变成从0开始,就是组合/子集与排列在代码层面最直观的区别。
你会发现,这种看似微小的改动背后,是对题意理解的不同。排列需要记录的是"这个元素在当前这条递归路径上是否已经被使用过",而不是"我从哪个位置开始可以选"。如果没转过这个弯,排列的代码永远写得像组合,出来的结果长度永远不对。
1.3 第三个变化:去重逻辑的维度从隐式变为显式
组合类题目里,因为用startIndex控制了遍历方向,天然避免了[1,2]和[2,1]同时出现的问题,属于隐式去重。
子集 II 和全排列 II 给了包含重复元素的数组,问题就不一样了。同一层横向遍历的时候,如果前一个相同的元素已经在这个位置被处理过,那么当前这个相同的分支应该跳过。这就是显式去重,需要借助boolean[] used或HashSet来标记。
理解树层去重和树枝去重是这一篇最重要的分水岭,建议你在看下面的代码之前先把这两个概念在脑子里盘一遍:同一层 for 循环里的横向去重叫树层去重,同一条递归路径上的纵向去重叫树枝去重。两个去重用同一个used数组来完成,但判断条件完全不同。
2. 子集问题:收集树上所有节点的那层窗户纸
先看最基础的 78 题子集。给定数组[1,2,3],要求返回所有子集,包括空集。这个题可以说没有任何算法难度,但二叉树遍历概念在这里有个非常精妙的对照:组合问题是收集二叉树根到叶子路径,子集问题是收集二叉树所有节点的状态。
2.1 核心模板:收集动作放在递归函数开头
代码框架如下,我用 Java 写,你们换成 Python 其实也一样:
class Solution { List<List<Integer>> result = new ArrayList<>(); LinkedList<Integer> path = new LinkedList<>(); public List<List<Integer>> subsets(int[] nums) { backtrack(nums, 0); return result; } private void backtrack(int[] nums, int startIndex) { // 进来就收集当前 path 的状态 result.add(new ArrayList<>(path)); // 终止条件:startIndex 越界 if (startIndex >= nums.length) { return; } for (int i = startIndex; i < nums.length; i++) { path.add(nums[i]); backtrack(nums, i + 1); path.removeLast(); } } }我在看这段代码的时候,建议你先自己跑一遍[1,2,3]的递归节奏。第一层进来先收集空集,然后 for 循环依次把 1、2、3 加进 path;每加一个数字,进入下一层递归时又先收集当前 path。所以收集顺序是:空集、[1]、[1,2]、[1,2,3]、[1,3]、[2]、[2,3]、[3],正好八个结果,一个不少。
2.2 为什么不需要在终止条件里收集
很多题解会在终止条件里写result.add(...),然后发现少了空集和其他中间节点,于是再加一个空集特判。这种"补丁式"写法容易让人漏掉中间状态,而且思维是反的。
正确的理解方式应该是:path 的每一个状态都是一个合法子集,递归函数每进入一层,path 就多了一个新元素,此时这个新状态当然要立刻保存。所以收集动作必须放在递归一进来的时候,逻辑上才顺。终止条件的作用只是让递归不再往下走,它不负责收集结果。
这个理解方式到 90 题子集 II 会更加重要,那时候去重逻辑会叠加在收集动作之上,位置一旦理解错了,去重判断的写法也跟着崩。
2.3 子集 II 的树层去重:先排序,再判断"前一个兄弟"是否被用过
90 题在 78 基础上加了一个条件:数组中可能存在重复元素,但要求去重后的子集结果。比如[1,2,2],如果直接套用上面的代码,会出现两个[1,2],只不过一个是第一个 2,一个是第二个 2,肉眼看着一样,但程序认为它们不同路径。
解决办法是先排序,把相同的元素挤到相邻位置,再在 for 循环里加一个树层去重的判断。代码如下:
class Solution { List<List<Integer>> result = new ArrayList<>(); LinkedList<Integer> path = new LinkedList<>(); boolean[] used; public List<List<Integer>> subsetsWithDup(int[] nums) { Arrays.sort(nums); used = new boolean[nums.length]; backtrack(nums, 0); return result; } private void backtrack(int[] nums, int startIndex) { result.add(new ArrayList<>(path)); if (startIndex >= nums.length) { return; } for (int i = startIndex; i < nums.length; i++) { // 树层去重:同一层横向遍历时,前一个相同元素没被使用过,说明它是同一层之前的兄弟分支 if (i > 0 && nums[i] == nums[i - 1] && !used[i - 1]) { continue; } used[i] = true; path.add(nums[i]); backtrack(nums, i + 1); path.removeLast(); used[i] = false; } } }这段代码里最难懂的就是!used[i - 1]这个条件。我拆开解释一下:
- 当
nums = [1,2,2],第一层 for 循环遍历到i = 2时,发现nums[2] == nums[1],此时used[1]是什么状态? - 如果我们从上一次递归回溯回来了,
used[1]已经被置回false,说明元素 2 的第一次使用已经结束,正在尝试同一层的第二个相同元素,这是树层的重复,必须跳过。 - 如果
used[1]是true,说明我们正处于一条递归路径内部,nums[1]已经被当前路径的上层用到,此时不构成同层重复,可以继续往下走。
同一个used[i - 1]的布尔值,就区分了"树枝去重"和"树层去重"两种场景,这是回溯算法里去重最精妙、也最容易被误解的地方。
2.4 一个更简洁的树层去重写法:i 与 startIndex 的关系
我还见过一种不用used数组的写法,利用 for 循环里的i > startIndex来判断树层去重,代码更短:
for (int i = startIndex; i < nums.length; i++) { if (i > startIndex && nums[i] == nums[i - 1]) { continue; } // ... }这个写法的原理是:i == startIndex说明是进入当前层的第一个分支,不可能是本层的重复;只有当i已经大于startIndex,才可能是本层遍历到了后面的重复元素。这种写法在组合问题和子集问题里都能用,但到了全排列 II 就不行了,因为排列的 for 循环从头开始,没有startIndex的概念,只能依赖used数组。
建议你把两种写法都练一遍,这样你对"去重到底在去什么"的理解会更深,而不是单纯背一个条件。
3. 全排列的递归结构和组合的根本区别
如果说子集问题是改变了"结果收集的位置",那全排列问题就是彻底改变了"选择范围"。这个区别需要在代码层面扎扎实实地感受一遍,否则后面做 N 皇后、数独这类棋盘回溯时一定绕晕。
3.1 为什么排列的 for 循环必须从 0 开始
排列问题每层递归都在重新选择"当前还没被用过的元素"。比如[1,2,3]的全排列,第一个位置选了 1,第二个位置可以选 2 和 3;第一个位置选了 2,第二个位置可以选 1 和 3。
每次的选择范围都是"整个数组减去已经用过的元素",所以 for 循环必须从 0 开始扫描所有元素,再通过used数组过滤掉已经在当前递归路径上使用过的元素。
这个理解非常关键:排列的used数组是"路径状态"的记录,而不是"同一层状态"的记录。它在纵向递归的每一层都被检查,确保同一个元素不会在一条路径里被用两次。这和子集 II 那个used数组的用途并不一样,虽然用的是同一个数据结构。
3.2 全排列基础模板:一行代码挡住同路径重复
46 题全排列的模板如下:
class Solution { List<List<Integer>> result = new ArrayList<>(); LinkedList<Integer> path = new LinkedList<>(); boolean[] used; public List<List<Integer>> permute(int[] nums) { used = new boolean[nums.length]; backtrack(nums); return result; } private void backtrack(int[] nums) { // 终止条件:path 的长度等于 nums 长度,说明所有元素都用过了 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); path.removeLast(); used[i] = false; } } }这里终止条件不再是startIndex >= nums.length,而是path.size() == nums.length。因为排列不在乎你选到哪个位置,它在乎的是当前路径是否已经包含了所有元素。
再看一下used数组在递归过程中的状态变化。以[1,2,3]为例,第一层选了 1,used[0] = true,进入下一层后 for 循环从 0 开始扫,发现used[0] = true直接跳过,于是选 2,然后进入第三层的扫描,跳过 1 和 2,选 3。此时 path 长度为 3,收集结果后逐层回溯,把used依次置回false,开启下一条路径。
3.3 全排列 II 的去重是"树层"和"树枝"的混合体
47 题全排列 II 是回溯算法里最经典的去重综合题。它要求在包含重复元素的情况下输出不重复的全排列,比如[1,1,2]要去重后只剩三种全排列,而不是六种。
看代码:
class Solution { List<List<Integer>> result = new ArrayList<>(); LinkedList<Integer> path = new LinkedList<>(); boolean[] used; public List<List<Integer>> permuteUnique(int[] nums) { Arrays.sort(nums); used = new boolean[nums.length]; backtrack(nums); return result; } private void backtrack(int[] nums) { if (path.size() == nums.length) { result.add(new ArrayList<>(path)); return; } for (int i = 0; i < nums.length; i++) { // 树枝去重:当前路径上已使用,跳过 if (used[i]) { continue; } // 树层去重:前一个相同元素在递归结束后回到未使用状态,跳过 if (i > 0 && nums[i] == nums[i - 1] && !used[i - 1]) { continue; } used[i] = true; path.add(nums[i]); backtrack(nums); path.removeLast(); used[i] = false; } } }这里有两个if,负责两种完全不同的去重:
第一个if (used[i])是树枝去重。它保证同一条递归路径上不会出现同一个位置的元素被重复使用。这是排列问题的标配过滤条件,没有它,任何排列题都会死循环。
第二个if (i > 0 && nums[i] == nums[i - 1] && !used[i - 1])是树层去重。它负责处理数组中相同的元素,避免出现两个长得一样的排列分支。
很多初学者一开始写全排列 II,只在for循环里写if (i > 0 && nums[i] == nums[i - 1]) continue,结果发现排列数量少了。为什么?因为没有used[i - 1]这个状态参与判断。比如[1,1,2],当第一个 1 还在当前路径上被使用的时候,第二个 1 进来,nums[i] == nums[i - 1]为真,直接跳过了。可实际上当前路径上第一个 1 还在用,第二个 1 是可以选的,因为它们是数组里两个不同的位置。所以必须加!used[i - 1]这个条件,让"正在被使用的同值元素"和"已经结束使用的同值元素"区分开。
建议你手动走一遍[1,1,2]的全排列递归,把 used 数组的状态变化写出来。这一步做完,你对树层去重的理解会上升一个台阶,后面写任何去重题目都不怕了。
3.4 为什么排序是全排列 II 的前提
我见过不少人在全排列 II 里跳过Arrays.sort(nums),然后发现在[2,1,1]这样的输入下,去重条件根本不生效。因为去重判断依赖nums[i] == nums[i - 1],如果两个相同元素不相邻,i - 1位置的元素和i位置的元素不相等,就触发不了去重分支。
排序的作用是把相同的元素聚到一起,让去重条件变成"只要当前元素和前面一个元素相同,就可以考虑去重"。
有一个常见的疑问是:排序会不会改变全排列的结果?答案是排列本来就不在乎输入的顺序,因为全排列会把所有可能的顺序都列出来,排序后的输入和排序前的输入产生的全排列结果是等价的,只是输出的顺序顺序不同而已。子集问题同理也不在乎输入顺序。
4. 组合、子集、排列三者的模板差异对照与选型思路
学到这里,你可以把自己脑子里的回溯模板做一个系统对照了。下面这个表格对我的学生相当有用,建议你也存一份:
| 对比维度 | 组合问题 | 子集问题 | 排列问题 |
|---|---|---|---|
| 结果收集位置 | 终止条件处 | 递归函数开头 | 终止条件处 |
| for 循环起点 | startIndex | startIndex | 0 |
| 终止条件 | path 长度 == k 或 startIndex 越界 | startIndex 越界 | path.size() == nums.length |
| 是否需要 used 数组 | 非必须 | 非必须 | 必须 |
| 去重写法(含重复元素时) | 可用 i > startIndex 判断 | 可用 i > startIndex 判断 | 必须用 used 数组的 !used[i - 1] |
| 结果是否强调顺序 | 否 | 否 | 是 |
这个表格最值得关注的是最后一行:组合和子集不强调顺序,排列强调顺序。这个本质区别决定了 for 循环的起点,决定了是否需要used数组,也决定了终止条件的写法。
如果你在做题的时候总是混淆,可以尝试从题意出发推演这三个特征:
- 不强调顺序、每个元素只能选一次 -> 用
startIndex,不需要used - 需要收集所有中间节点 -> 结果收集放递归开头
- 每个排列都要穷尽所有元素且顺序不同 -> for 循环从 0 开始,必须用
used数组维护路径状态
这三种选择不是背出来的,而是从题意里推出来的。我经常和学生说,刷回溯题时问自己一个问题:"这道题的选择列表每一层是否一致?"如果一致,就是排列思路,需要used数组;如果不一致(后面层只能选剩余元素),就是组合/子集思路,用startIndex控制范围。
5. 四个经典题的完整代码模板和常见报错分析
理论说完,我把这四个题的完整模板汇总在一起,方便你直接对照练习。代码以 Java 为例,核心逻辑在其他语言里完全一样。
5.1 78. 子集
class Solution { List<List<Integer>> result = new ArrayList<>(); LinkedList<Integer> path = new LinkedList<>(); public List<List<Integer>> subsets(int[] nums) { backtrack(nums, 0); return result; } private void backtrack(int[] nums, int startIndex) { result.add(new ArrayList<>(path)); // 收集所有节点 if (startIndex >= nums.length) return; for (int i = startIndex; i < nums.length; i++) { path.add(nums[i]); backtrack(nums, i + 1); path.removeLast(); } } }常见报错一:结果缺少空集。空集是在第一层递归进来时收集的,如果你把result.add放到了if之后,空集就丢了。
常见报错二:startIndex >= nums.length这个终止条件其实不加也行,因为for (int i = startIndex; ...)在startIndex越界时循环自然不执行。但加上更清晰,也有利于你理解递归边界。
5.2 90. 子集 II
class Solution { List<List<Integer>> result = new ArrayList<>(); LinkedList<Integer> path = new LinkedList<>(); public List<List<Integer>> subsetsWithDup(int[] nums) { Arrays.sort(nums); backtrack(nums, 0); return result; } private void backtrack(int[] nums, int startIndex) { result.add(new ArrayList<>(path)); if (startIndex >= nums.length) return; for (int i = startIndex; i < nums.length; i++) { if (i > startIndex && nums[i] == nums[i - 1]) { continue; } path.add(nums[i]); backtrack(nums, i + 1); path.removeLast(); } } }这里我选用i > startIndex的写法而不是used数组的写法,因为子集问题里startIndex足够承担去重的判断依据,代码更简洁。但这种简洁只适用于组合/子集,排列不能这样干。
常见报错:排序放在backtrack外层,写成了Arrays.sort(nums)放到backtrack内部。这样会每次递归都排序,效率颠簸。排序只应在进入递归前做一次,用Arrays.sort(nums)移到backtrack外面即可。
5.3 46. 全排列
class Solution { List<List<Integer>> result = new ArrayList<>(); LinkedList<Integer> path = new LinkedList<>(); boolean[] used; public List<List<Integer>> permute(int[] nums) { used = new boolean[nums.length]; backtrack(nums); return result; } private void backtrack(int[] nums) { 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); path.removeLast(); used[i] = false; } } }常见报错:used数组在递归中忘记对称回退。在path.removeLast()之后必须写used[i] = false,否则一条路径走完,used里全是true,后面的递归发现所有元素都被用过,直接输出不了任何结果。
这个对称回退是整个回溯算法的精髓,你务必养成"加操作写哪里,撤销操作写哪里"的肌肉记忆:path.add和used[i] = true一起出现,path.removeLast和used[i] = false必须紧随其后。
5.4 47. 全排列 II
class Solution { List<List<Integer>> result = new ArrayList<>(); LinkedList<Integer> path = new LinkedList<>(); boolean[] used; public List<List<Integer>> permuteUnique(int[] nums) { Arrays.sort(nums); used = new boolean[nums.length]; backtrack(nums); return result; } private void backtrack(int[] nums) { if (path.size() == nums.length) { result.add(new ArrayList<>(path)); return; } for (int i = 0; i < nums.length; i++) { if (used[i]) continue; // 树枝去重 if (i > 0 && nums[i] == nums[i - 1] && !used[i - 1]) { continue; // 树层去重 } used[i] = true; path.add(nums[i]); backtrack(nums); path.removeLast(); used[i] = false; } } }常见报错一:两个去重顺序写反了。有人把树层去重放在树枝去重前面,导致used[i] == true时还要先比较nums[i] == nums[i - 1],逻辑上先执行了一些无关判断,不会出错但影响理解。规范做法是先used[i]的树枝过滤,再!used[i - 1]的树层过滤。
常见报错二:忘记排序。如果输入是[1,2,1]而不是[1,1,2],去重条件nums[i] == nums[i - 1]可能会漏判断,因为相同元素 1 在数组中不相邻。排序后1,1,2相邻,条件必然触发。
5.5 一个调试小技巧:打印 path 和 used
我在带学员的时候,遇到回溯题写不对,强烈建议你打印调试信息:
System.out.println("递归进入: path=" + path + ", used=" + Arrays.toString(used) + ", i=" + i);打印几层之后你会突然看懂:used数组什么时候为true,什么时候被回溯成false,树层去重为什么需要!used[i - 1]。这种观察比单纯看题解高效得多,因为你会亲眼看到递归的"撤销"动作是如何影响后续递归的。
6. 回顾客再从做题到理解本质:回溯算法的思维模型
刷到这四道题,你可能已经注意到,回溯算法其实是一种暴力穷举的优化实现方式。它不像是动态规划那样有精妙的状态转移,也不像贪心那样有局部最优的推导,它就是用递归老老实实把每种可能都试一遍,只不过在试的过程中可以提前截断不合理的分支。
6.1 把回溯看作"路径上做标记"的遍历过程
我常用一个比喻:回溯算法像是在一个大型迷宫里面走路,每到一个岔路口就选一条路,沿路撒下标记避免走重复的路。如果走到底发现是死胡同或者不符合条件,就沿原路返回,取回标记,再尝试另一条路。
这就是 path 和 used 数组的真实角色。path 存的是当前走过的路线,used 数组是这条路线上已经用过的标记。递归层数对应迷宫中的每一步,for 循环是这一步可以尝试的所有选项。
一旦你用这个视角去理解,四个题的差异就转化为三个问题:
- 岔路口的选择范围是什么?组合/子集是"从 startIndex 之后选",排列是"从所有未使用元素中选"
- 走到什么时候算结束?组合/子集是"选满 k 个或遍历完",排列是"所有元素都被用上了"
- 结果在什么时候被记录?组合/排列是"到达终点时",子集是"每经过一个节点时"
6.2 模板固定为三种变式,按题意取用
如果只让我用一个框架去概括回溯,我会这样写:
void backtrack(选择列表, 当前路径, 路径状态) { 根据需要收集结果或判断终止; 终止条件成立则返回; for (选择 in 选择列表) { 剪枝条件成立则跳过; 做选择; // 更新 path 和 used backtrack(...); 撤销选择; // 回退 path 和 used } }这个框架是万能骨架,具体题目就是往里面填三样东西:结果收集位置、终止条件形式、剪枝条件。填完这三样,剩下的全是一样的模板代码。
6.3 关于"剪枝"的常见误区
回溯里经常听到"剪枝",但很多初学者把剪枝理解为一种高深的优化,实际上剪枝分两种:
- 可行性剪枝:某种选择必然不满足条件,直接跳过。比如全排列里
used[i]为true的元素不能再选,这就是可行性剪枝。 - 重复性剪枝:某种选择会产生和之前相同的结果,直接跳过。比如全排列 II 里的树层去重,这就是重复性剪枝。
两种剪枝的写法差异非常清晰:可行性剪枝侧重"能不能选",重复性剪枝侧重"选了会不会重复"。你在写代码的时候,先问自己是哪种剪枝,再去推导判断条件,会清晰很多。
6.4 做题时最应该训练的三种思维路径
每天刷题训练,我建议你刻意练三个习惯:
第一个习惯,拿到题目先判断选择列表的形状。组合有固定的startIndex,排列要遍历整个数组,这一判断决定了代码的主干。
第二个习惯,动手画递归树。不用画全,画前两层就行,但要标出哪些分支是重复的、哪些分支是被剪掉的。很多人觉得画递归树浪费时间,其实画三棵树比看十道题解析管用。
第三个习惯,对比题解里的去重条件和你的区别。每次做错去重,不要只看正确答案,要找出"自己的条件和正确答案之间差了什么判断",写清楚差的这个判断到底防住了什么场景。我和你说,大部分人做回溯的最大问题不是不会模板,而是不会精准表达"去重到底防住了什么"。
7. 这一周我踩过的坑和给后来者的经验
写到这里,我分享一下自己在教学中反复遇到的典型陷阱,希望能帮你少走弯路。
7.1 复制粘贴模板的时候,最容易漏掉的是"回溯撤销"那一句
我发现不少学员刷题时,写path.add很顺手,写used[i] = true也很顺手,但一到path.removeLast()后面就忘了写used[i] = false。然后整个程序输出了空列表或者结果漏掉一大半。排查半天,最后发现就是漏了这一行。
建议你在纸上画一个模板骨架,列上"做选择三件事"和"撤销选择三件事",每次写代码都对着检查。这是一个极其简单的习惯,能帮你降低一半的 debug 时间。
7.2 去重条件写在数组没排序之前,等于白写
不仅是全排列 II,包括组合总和 II、子集 II,只要题目强调"结果不能包含重复组合/排列",第一步就是排序。就算题目没明说排序,也要看是否包含重复元素。如果有重复元素,排序基本跑不掉。
我见过一个学生,把Arrays.sort(nums)写在backtrack函数内部,导致每次递归都重新排序,然后他还疑惑为什么 LeetCode 上超时。排序只做一次,放在入口函数里,这点也建议养成习惯。
7.3 递归参数越少越好,能传的尽量用成员变量
在回溯类题目里,path、result、used这些状态量建议定义为类成员变量。如果硬要作为函数参数传递,代码签名会非常臃肿,也容易因为引用副本的问题导致状态不同步,平白增加 bug 的概率。
尤其当你在公司面试或机试的时候,时间紧张,用成员变量省去参数传递的思考成本,把精力放在核心逻辑上,这是一笔稳赚不赔的买卖。
7.4 从递归树退出时,打印状态能救命
回到调试技巧,如果你实在想不明白某个分支为什么没被剪掉,或者某个结果为什么重复了,建议你在递归函数开头加一行打印。我平时调试回溯题都用这一招,效果立竿见影。
输出样例:
==> 进入 backtrack, path=[1], used=[true, false, false] i=0, used[0]为true, 跳过树枝重复 i=1, nums[1]==nums[0]且used[0]为false, 跳过树层重复 i=2, 选择数字2, path=[1,2]看到这种输出,你会直观理解树层去重和树枝去重的执行顺序和判断时机。这对于面试现场手写代码尤其有好处,因为一旦逻辑跑偏,你可以快速定位是哪个 if 条件出了问题。
8. 下一步怎么走:把模板内化成肌肉记忆
回溯算法的第四部分,其实就是从"会写组合题的模板"到"会用模板解决所有经典回溯场景"的过渡。组合、子集、排列这三个场景的模板差异我上面已经梳理得很彻底,接下来你可以从两个方向继续加深:
- 多做几道组合、子集、排列的变体题,尤其是带有重复元素约束的。比如组合总和 II、递增子序列、重新安排行程,都是从这四道题的基础上延伸出来的。
- 开始接触棋盘类回溯,比如 N 皇后、数独、解数独。这些题的递归结构仍然离不开今天的模板,但约束条件的判断会更复杂,你需要具备"把约束条件翻译成剪枝代码"的能力。
这两个方向都需要以今天的四道题作为地基。地基如果打不牢,后面做棋盘题时很容易因为一个小条件写错导致死循环或者结果不对。
我在实际教学中的体会是:回溯算法不怕笨,就怕不画图、不调试、不比较。把今天这几道题的递归树手动画一遍,把代码里的打印信息跑一遍,把used数组的状态变化捋一遍,你的回溯内功会比只刷十道题还扎实。
最后分享一个小诀窍:刷回溯题时把"代码模板"打印出来贴在屏幕旁边,然后每天挑一道题不看题解、只对照模板写。连续一周之后,你会发现模板已经深深印在脑子里,遇到新题第一反应不再是"回溯怎么写",而是"这题的选择列表长什么样、去重需要防哪个维度"。这个转变是我认为整个回溯算法学习中最值得庆祝的瞬间。