LC 39 组合总和:回溯入门必刷题,我踩过的两个坑都在这了

刷回溯算法的路上,组合总和绝对是绕不开的经典题。我第一次写这题的时候,信心满满套普通组合的模板,结果跑出来一半都是重复组合,改到怀疑人生。今天就聊聊我是怎么从 "写不对" 到 "秒出模板" 的,顺便把坑都给你们踩平。

题目说的啥

简单说下题意。给你一个没有重复元素的数组,还有一个目标数 target。你可以从数组里随便挑数字,同一个数能无限次用,最后要找出所有加起来等于 target 的不同组合。注意,组合不看顺序,比如 2,33,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 掉不可能的分支。

排序还有个小好处:小的数字在前,能更快凑出目标值,提前找到结果。

核心逻辑和朴素版差不多,就加了两步:

  1. 先给数组排个序
  2. 遍历的时候,如果当前数 > 剩余目标值,直接 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 基本都是换汤不换药。

你第一次刷这题的时候,有没有踩过重复组合的坑?或者你有更巧妙的写法?评论区聊聊,我都会看的~觉得有用的话,点个赞让更多小伙伴看到呀。

相关推荐
数字杂技师1 小时前
每条推荐都很准,为什么我还是越刷越无聊?
算法
兰令水1 小时前
hot100【acm版】【2026.7.14打卡-java版本】
java·数据结构·算法·leetcode·面试
橘子星1 小时前
从零理解流式输出 —— 一个 Vue + DeepSeek 的前端实战
前端·javascript·人工智能
cpp_25012 小时前
P10098 [ROIR 2023] 地铁建设 (Day 2)
数据结构·c++·算法·贪心·二分答案·洛谷题解
এ慕ོ冬℘゜3 小时前
深入 JavaScript BOM:掌握 window 对象的窗口控制魔法
开发语言·javascript·ecmascript
阳明山水3 小时前
TimesFM与Moirai MoE零样本预测解析
人工智能·深度学习·算法·机器学习·架构
铅笔侠_小龙虾3 小时前
Rust 学习(2)-变量、常量与 shadowing
学习·算法·rust
AI科技星3 小时前
基于全域数学公理体系的三元极值题最简求解法【乖乖数学】
线性代数·算法·游戏·决策树·机器学习·乖乖数学·全域数学
Highcharts3 小时前
Highcharts 矩形树图 Treemap 示列|2025年多层级出口商品结构可视化
javascript·数据可视化