本文聚焦于LeetCode上面的:78(子集)、90(子集II)、77(组合)、216(组合总和III)、39(组合总和)、40(组合总和II)几道非常经典的题目,带领大家彻底拿下回溯和剪枝。
什么是回溯?
想象我们正在玩一个迷宫游戏:
- 我们面前有一个岔路口(选择)。
- 我们随便选一条路往前走(做选择)。
- 走到死胡同了,我们退回到上一个岔路口(撤销选择)。
- 尝试另一条没走过的路,直到找到迷宫出口。
这其实就是 回溯:一种通过"不断试错"来寻找所有解的算法:当发现当前选择的不是正确路径(或者已经找完这条路径的所有可能)时,就退回去,重新选择。
在代码里,这个"退回去"的过程,就是通过递归函数的自动"回退"来实现的。
回溯的核心框架
所有的回溯题,几乎都可以套用下面的模板:
javascript
function backtrack(路径, 选择列表) {
if (满足结束条件) {
result.push([...路径]); // 存放结果
return;
}
for (选择 in 选择列表) {
// 1. 做选择(前序遍历)
路径.push(选择);
// 2. 进入下一层决策树(递归)
backtrack(新的路径, 新的选择列表);
// 3. 撤销选择(后序遍历)
路径.pop();
}
}
关键点
- 路径:记录已经做出的选择。
- 选择列表:当前可以做的选择。
- 结束条件:到达决策树的底层,无法再做选择。
从子集问题入门(LeetCode 78)
题目
给你一个整数数组 nums,数组中的元素互不相同。返回该数组所有可能的子集(幂集)。
示例:nums = [1,2,3] 输出:[[], [1], [2], [1,2], [3], [1,3], [2,3], [1,2,3]]
思路解析
构建一棵树,每个节点代表一个子集。我们从空集(根节点)开始,每次从当前位置之后选一个数加入,保证不重复。
图解过程
选择组合)) end subgraph "第1层:选择第一个数" S -->|"选择 1"| L1_1["[1]
剩余可选: 2,3"] S -->|"选择 2"| L1_2["[2]
剩余可选: 3"] S -->|"选择 3"| L1_3["[3]
剩余可选: 无"] end subgraph "第2层:选择第二个数" L1_1 -->|"选择 2"| L2_1["[1,2]
剩余可选: 3"] L1_1 -->|"选择 3"| L2_2["[1,3]
剩余可选: 无"] L1_2 -->|"选择 3"| L2_3["[2,3]
剩余可选: 无"] L1_3 -->|"无可选"| L2_4(("终止")) end subgraph "第3层:选择第三个数" L2_1 -->|"选择 3"| L3_1["[1,2,3]
剩余可选: 无"] L2_2 -->|"无可选"| L3_2(("终止")) L2_3 -->|"无可选"| L3_3(("终止")) end subgraph "第4层:最终状态" L3_1 -->|"无可选"| L4_1(("完成")) end
代码实现
javascript
var subsets = function(nums) {
const result = [];
const backtrack = (start, path) => {
// 每个节点都是一个子集,直接加入结果
result.push([...path]);
// 从 start 开始遍历,避免重复
for (let i = start; i < nums.length; i++) {
// 做选择:把 nums[i] 加入路径
path.push(nums[i]);
// 递归:继续从下一个位置开始选择
backtrack(i + 1, path);
// 撤销选择:回溯的关键
path.pop();
}
};
backtrack(0, []);
return result;
};
答疑:为什么 result.push([...path]) 要放在递归之前?
因为我们要收集每一个节点,包括空集;在进入递归之前,当前路径已经代表了一个完整的子集,所以先记录下来。
什么是剪枝?
如果问题里面加了限制条件,比如"子集的和必须为某个值",或者"不能包含重复元素",此时,我们就不需要遍历整棵树。当我们提前知道某条分支不可能产生有效解时,就直接跳过它,这就叫剪枝。
剪枝能大大提高效率,避免无意义的递归。
组合总和问题(LeetCode 39)
题目
给你一个无重复元素的整数数组 candidates 和一个目标整数 target,找出 candidates 中可以使数字和为目标数 target 的所有不同组合,并以列表形式返回。candidates 中的同一个数字可以无限制重复被选取。
示例:candidates = [2,3,6,7], target = 7 输出:[[2,2,3],[7]]
思路解析
这题和上面的子集问题(LeetCode 78)很像,但又有两点不同:
- 有目标和限制:只有当路径和等于 target 才加入结果。
- 可以重复选同一个数:递归时,start 参数不一定要 i+1,可以是 i(表示可以重复选当前数)。
代码实现(含剪枝)
javascript
var combinationSum = function(candidates, target) {
const result = [];
const backtrack = (start, path, sum) => {
// 结束条件:找到了目标和
if (sum === target) {
result.push([...path]);
return;
}
// 剪枝:如果 sum 已经超过 target,后面就不用看了
if (sum > target) return;
for (let i = start; i < candidates.length; i++) {
// 做选择
path.push(candidates[i]);
// 递归:因为可以重复选,所以还是从 i 开始
backtrack(i, path, sum + candidates[i]);
// 撤销选择
path.pop();
}
};
backtrack(0, [], 0);
return result;
};
剪枝点
if (sum > target) return; 这句就是剪枝。当发现当前和已经超过目标,立刻停止往下递归(继续往后递归累加,和会越来越大,没有意义了),节省时间。
本题解法使用的是加法累加求和;也可以用减法求余数,大家有兴趣可以尝试这种解法!
去重问题的核心:排序 + used 数组(LeetCode 90)
题目
给你一个整数数组 nums,其中可能包含重复元素,请你返回该数组所有可能的子集(幂集)。解集不能包含重复的子集。
示例:nums = [1,2,2] 输出:[[], [1], [1,2], [1,2,2], [2], [2,2]] 注意:不能有两个 [1,2](虽然第一个2和第二个2不同,但值相同,视为重复)。
思路解析
当数组有重复元素时,如果还按之前的方法,会出现重复子集。比如 [1,2](第一个2)和 [1,2](第二个2)会被当成两个解,此时我们需要去重处理。
去重套路
- 排序:让相同的元素挨在一起。
- 剪枝:如果当前元素和前一个元素相同,并且前一个元素没有被使用过,就跳过。
为什么是"前一个元素没被使用过"就跳过?
- 在同一层(同一级递归)中,如果前一个相同的数没有在路径里,说明当前数和前一个数是同一层的重复选择,必须跳过。
- 如果在不同层(比如 [1,2,2] 中的第二个2是在下一层),那么前一个2是在路径中的(被使用过),这种情况不应该跳过。
代码实现
javascript
var subsetsWithDup = function(nums) {
const result = [];
nums.sort((a, b) => a - b); // 排序是去重的前提
const backtrack = (start, path) => {
result.push([...path]);
for (let i = start; i < nums.length; i++) {
// 剪枝去重:同一层中,如果当前值和前一个值相同,跳过
if (i > start && nums[i] === nums[i - 1]){
continue;
}
path.push(nums[i]);
backtrack(i + 1, path);
path.pop();
}
};
backtrack(0, []);
return result;
};
关键点
这段代码的关键点在于:if (i > start && nums[i] === nums[i - 1]){ continue; }
i > start保证了是在同一层(i 从 start 开始,如果 i 比 start 大,说明是这一层的第二个及之后的元素)。nums[i] === nums[i-1]判断是否重复:重复了,则直接跳过。
组合总和 II(LeetCode 40):去重 + 剪枝
题目
给定一个数组 candidates(可能有重复)和一个目标数 target,找出所有和为 target 的组合。candidates 中的每个数字在每个组合中只能使用一次。
示例:candidates = [10,1,2,7,6,1,5], target = 8 输出:[[1,1,6], [1,2,5], [1,7], [2,6]]
思路解析
这道题的本质其实是 39 题和 90 题的结合体:
- 每个数只能用一次:递归时传 i+1。
- 数组有重复:需要去重(同一层不能选相同的数)。
- 有目标和:需要剪枝(和超过 target 就停止)。
最终代码
javascript
var combinationSum2 = function(candidates, target) {
const result = [];
candidates.sort((a, b) => a - b); // 排序
const backtrack = (start, path, sum) => {
if (sum === target) {
result.push([...path]);
return;
}
// 剪枝
if (sum > target) {
return;
}
for (let i = start; i < candidates.length; i++) {
// 同一层去重
if (i > start && candidates[i] === candidates[i - 1]) {
continue;
}
path.push(candidates[i]);
backtrack(i + 1, path, sum + candidates[i]); // i+1 保证不重复用
path.pop();
}
};
backtrack(0, [], 0);
return result;
};
回溯题的核心套路
刷完这几道题,我们会发现它们就是套娃。记住这个总结,下次遇到新题直接套:
| 题目 | 特点 | 去重? | 递归参数 | 剪枝条件 |
|---|---|---|---|---|
| 78.子集 | 无重复,找所有子集 | 否 | start = i+1 | 无 |
| 90.子集II | 有重复,找所有子集 | 是(同一层) | start = i+1 | i>start && nums[i]===nums[i-1] |
| 77.组合 | n个数选k个 | 否 | start = i+1 | 路径长度==k |
| 216.组合总和III | 1-9选k个,和为n | 否 | start = i+1 | 和>n 或 长度>k |
| 39.组合总和 | 无重复,可重复选 | 否 | start = i | 和>target |
| 40.组合总和II | 有重复,不可重复选 | 是(同一层) | start = i+1 | 和>target 且 去重 |
解题要点
- 画树形图:把决策过程画出来,你就知道递归怎么走了。
- 确定结束条件:什么时候把路径加入结果?
- 确定选择列表:下一层递归从哪开始?
- 考虑剪枝:哪里可以提前终止?
- 考虑去重:有重复元素时,记得排序 + 同一层跳过。
结语
希望这篇文章能帮大家彻底搞懂回溯与剪枝。以后只要遇到"所有组合"、"所有子集"、"所有排列"这类问题,别忘了拿出这个万能模板试一试!当然,上述题目也不止一种解法,你还知道其他解法吗?欢迎在评论区留言分享!
对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!