39. 组合总和

0 阅读4分钟

给你一个 无重复元素 的整数数组 candidates 和一个目标整数 target ,找出 candidates 中可以使数字和为目标数 target 的 所有 **不同组合 ,并以列表形式返回。你可以按 任意顺序 返回这些组合。

candidates 中的 同一个 数字可以 无限制重复被选取 。如果至少一个数字的被选数量不同,则两种组合是不同的。 

对于给定的输入,保证和为 target 的不同组合数少于 150 个。

示例 1:

输入: candidates = [2,3,6,7], target = 7
输出: [[2,2,3],[7]]
解释:
2 和 3 可以形成一组候选,2 + 2 + 3 = 7 。注意 2 可以使用多次。
7 也是一个候选, 7 = 7 。
仅有这两种组合。

示例 2:

输入: candidates = [2,3,5], target = 8
输出: [[2,2,2,2],[2,3,3],[3,5]]

示例 3:

输入: candidates = [2], target = 1
输出: []

提示:

  • 1 <= candidates.length <= 30
  • 2 <= candidates[i] <= 40
  • candidates 的所有元素 互不相同
  • 1 <= target <= 40

1. 生活案例:自助找零机

想象你正在一台自助找零机前,你需要凑齐 7块钱target)。

  • 你的钱包(candidates) :里面有无尽的 2块3块6块7块 面值的硬币。

  • 规则

    1. 你可以重复拿同一种硬币(比如拿三次 2 块)。
    2. 你需要列出所有能刚好凑成 7 块的组合。
  • 过程

    1. 你先拿一个 2块,还差 5 块;再拿一个 2块,还差 3 块;再拿一个 2块,还差 1 块。
    2. 剩下的硬币里最小也是 2 块,拿了就超标了(8块)。
    3. 于是你退回一步(回溯),不拿第三个 2 块了,试着换成一个 3块
    4. 刚好凑成 2+2+3=72+2+3=7!记录下来。
    5. 你继续退回,尝试其他的组合,直到所有可能性都试遍。

2. 代码实现与详细注释

这是你图片中的代码,我为你加上了结合“找零”逻辑的详细中文注释:

JavaScript

/**
 * @param {number[]} candidates - 可选的硬币面值
 * @param {number} target - 目标金额
 * @return {number[][]}
 */
var combinationSum = function(candidates, target) {
    let res = [];
    
    // 【优化点】:先从小到大排序,这样如果加了一个数已经超了,后面的数就不用看了
    candidates.sort((a, b) => a - b);

    /**
     * @param {number} start - 从哪种面值的硬币开始选(防止重复组合,如 [2,3][3,2])
     * @param {number[]} path - 已经放进兜里的硬币组合
     * @param {number} sum - 兜里硬币的总面值
     */
    let dfs = (start, path, sum) => {
        // 【成功出口】:刚好凑齐
        if (sum === target) {
            res.push([...path]); // 记录方案
            return;
        }

        // 尝试从当前的 start 位置开始选硬币
        for (let i = start; i < candidates.length; i++) {
            // 【剪枝优化】:如果当前的钱加上这枚硬币已经超了,后面的硬币更大,直接放弃
            if (sum + candidates[i] > target) break;

            // 1. 【做选择】:把这枚硬币放进兜里
            path.push(candidates[i]);

            // 2. 【递归】:继续选下一枚。
            // 注意:这里传的是 i 而不是 i + 1,表示【可以重复使用】当前这枚硬币
            dfs(i, path, sum + candidates[i]);

            // 3. 【回溯】:把最后放进去的硬币拿出来,尝试换别的
            path.pop();
        }
    }

    dfs(0, [], 0); // 从第 0 种硬币、空兜、总和 0 开始
    return res;
};

3. 核心原理解析

为什么递归传的是 i 而不是 i + 1

这是本题的关键!

  • 如果传 i + 1:代表每种硬币只能用一次(LeetCode 第 40 题)。
  • i:代表你在选完这枚硬币后,下一轮还可以选这枚硬币,从而实现了“无限重复选取”。

为什么要先 sort 排序?

如果不排序,你必须把整个树走完才能知道超没超标。

排序后,一旦发现 sum + candidates[i] > target,根据从小到大的特性,i 之后的所有硬币肯定也都会超标。这时候直接 break 循环(这就是所谓的剪枝),能极大地节省计算时间。

复杂度分析

  • 时间复杂度O(S)O(S),其中 SS 是所有可行解的长度之和。回溯算法的时间复杂度通常较高,但剪枝能显著提升表现。
  • 空间复杂度O(target)O(target)。主要是递归的栈深度。

总结

这道题是理解回溯中“去重”和“重复利用”逻辑的最好教材。只要记住:为了不选重复的组合,我们不走回头路(通过 start 控制);为了能重复拿,我们允许原地踏步(通过 dfs(i, ...) 控制)。