刷回溯算法的路上,组合总和绝对是绕不开的经典题。我第一次写这题的时候,信心满满套普通组合的模板,结果跑出来一半都是重复组合,改到怀疑人生。今天就聊聊我是怎么从 "写不对" 到 "秒出模板" 的,顺便把坑都给你们踩平。
题目说的啥
简单说下题意。给你一个没有重复元素的数组,还有一个目标数 target。你可以从数组里随便挑数字,同一个数能无限次用,最后要找出所有加起来等于 target 的不同组合。注意,组合不看顺序,比如 2,3 和 3,2 算同一种,只有数字选的数量不同才算不同组合。
先整个朴素版:能跑通再说
最开始我没搞那么多花里胡哨的,先写了个最直白的回溯版本。思路特别好懂:我们一步步选数字,选完就递归继续选,选超了就回头。为了不出现重复组合,我加了个小限制:每次只从当前下标往后选,不回头碰前面的数。比如你选了下标 1 的数字,下一次就只能从下标 1、2、3... 里挑,自然就不会出现 "先选 3 再选 2" 这种反过来的重复情况。
直接上我第一版写的代码:
javascript
var combinationSum = function (candidates, target) {
const n = candidates.length;
const ans = [];
const path = [];
// i:当前开始选的下标
// cur:当前路径的总和
var dfs = function (i, cur) {
// 总和超了,这条路走不通,直接返回
if (cur > target) {
return;
}
// 凑成目标值,存一份结果
if (cur === target) {
ans.push(path.slice());
}
// 从 i 开始遍历,保证不回头选,避免重复组合
for (let j = i; j < n; j++) {
path.push(candidates[j]);
// 数字可以重复选,所以下一次还是从 j 开始
dfs(j, cur + candidates[j]);
// 回溯,撤销选择
path.pop();
}
}
dfs(0, 0);
return ans;
};
对了提句题外话:path.pop() 其实不用传参数,我一开始顺手写上了,不影响运行,但属于没必要的写法。
这个版本提交是能过的,毕竟题目保证组合数少于 150 个,数据量不大。但我总觉得有点别扭 ------ 明明有些数字加进去肯定会超,还要硬着头皮选进去、递归、再判断返回,多走了好多冤枉路。有没有办法提前把这些无效路径砍掉?
优化版:排序 + 剪枝,少走冤枉路
优化思路其实很顺:如果数组是有序的,那当前这个数字已经超过剩余需要的值了,后面的数字只会更大,肯定也超,直接不用往下试了。说白了就是加个剪枝,提前 break 掉不可能的分支。
排序还有个小好处:小的数字在前,能更快凑出目标值,提前找到结果。
核心逻辑和朴素版差不多,就加了两步:
- 先给数组排个序
- 遍历的时候,如果当前数 > 剩余目标值,直接 break 跳出循环
优化后的代码长这样:
javascript
var combinationSum = function(candidates, target) {
const res = [];
const path = [];
// 先排序!不然后面的剪枝逻辑会错
candidates.sort((a, b) => a - b);
const backtrack = (startIndex, currentSum) => {
// 凑成目标值,存入结果
if (currentSum === target) {
res.push([...path]);
return;
}
for (let i = startIndex; i < candidates.length; i++) {
// 当前数已经超过剩余需要的值,后面更大,直接剪枝
if (candidates[i] > target - currentSum) break;
path.push(candidates[i]);
// 重点:传 i 不是 i+1
// 因为数字可以重复选,所以下一次还能从当前数开始
backtrack(i, currentSum + candidates[i]);
// 回溯,撤销刚才的选择
path.pop();
}
}
backtrack(0, 0);
return res;
};
拿个例子走一遍,彻底搞懂流程
可能光看代码有点抽象,咱们就拿示例 1 亲手走一遍,你一下就通透了。例子是 candidates = 2,3,6,7,target = 7,排完序数组不变。
最开始调用 backtrack(0, 0),路径是空的,总和是 0。
第一轮循环从下标 0 开始:第一个数是 2,2 小于剩余的 7,没问题。把 2 放进路径,现在路径是 [2],总和变成 2,递归调用 backtrack(0, 2)。
进入递归,继续从下标 0 开始选:还是 2,2 小于剩余的 7-2=5,没问题。路径变成 [2,2],总和 4,递归 backtrack(0, 4)。
再进一层递归,依旧从下标 0 开始:2 小于剩余的 7-4=3,继续选。路径变成 [2,2,2],总和 6,递归 backtrack(0, 6)。
这一层总和是 6,离目标还差 1。从下标 0 开始看,第一个数 2,2 > 1,触发剪枝,直接 break 跳出循环,这层直接结束,往回走。把 2 从路径里弹出去,回到 [2,2],继续循环到下标 1。下标 1 的数是 3,3 刚好等于剩余的 3,没问题。把 3 加进去,路径变成 [2,2,3],总和变成 7。这时候总和等于 target,直接把这份路径存进结果里,然后返回。
然后继续回溯,把 3 弹出去,回到 [2,2],看下标 2 的 6。6 比剩余的 3 大,又触发剪枝,break 跳出循环,这层也结束了。
就这样一层一层往回退、试下一个数、剪枝、存结果,最后还会走到下标 3 的 7,刚好凑成 7,也存进结果。最后得到的就是 [[2,2,3], [7]],和示例完全一致。
你看,排序之后,很多明显走不通的分支,我们直接就跳过了,不用再傻乎乎地选进去再判断返回。

两个必踩的坑
说两个我亲手踩过的坑,你们别再掉进去了。第一个,递归的时候顺手写成了 i+1。那是普通组合题的写法(元素不能重复选),这题允许重复选,所以传 i 就行。第二个,没排序就写 break 剪枝。数组无序的话,当前数大,后面可能还有更小的,break 就会漏掉正确答案,一定要先排序。
复杂度
复杂度不用太死记。时间上最坏情况就是遍历所有可行组合,大概是 O (S),S 是所有结果组合的长度总和。空间主要是递归栈的深度,最坏就是全选最小的元素,深度是 O (target)。
最后唠两句
总的来说,这题是回溯算法的入门必做题。核心就两点:用起始索引控制选数范围来去重,用排序加剪枝提升效率。把这个套路摸透了,后面的组合总和 II、III 基本都是换汤不换药。
你第一次刷这题的时候,有没有踩过重复组合的坑?或者你有更巧妙的写法?评论区聊聊,我都会看的~觉得有用的话,点个赞让更多小伙伴看到呀。