【LeetCode Hot100 刷题日记 (58/100)】39. 组合总和 —— 回溯算法经典应用🔥

5 阅读5分钟

📌 题目链接:39. 组合总和 - 力扣(LeetCode)

🔍 难度:中等 | 🏷️ 标签:数组、回溯、递归、组合问题

⏱️ 目标时间复杂度:O(S) (S 为所有可行解的长度之和)

💾 空间复杂度:O(target) (递归栈深度)


🔥在 LeetCode Hot100 的开篇第一题,我们迎来一道极具代表性的 组合类回溯问题39. 组合总和(Combination Sum) 。这道题不仅是回溯思想的入门典范,更是面试中高频考察的“模板题”之一。

如果你能彻底吃透本题,那么后续诸如「组合总和 II」「组合总和 III」「子集」「全排列」等题目都将迎刃而解!


🧩 题目分析

题意简述
给定一个无重复元素的正整数数组 candidates 和一个目标值 target,要求找出所有数字和等于 target 的组合

  • 同一个数字可以无限次重复使用
  • 组合之间不能重复(顺序不同但元素相同视为同一组合);
  • 返回所有满足条件的组合列表。

关键点提炼

  • ✅ 元素可重复使用 → 递归时 index 不 +1
  • ✅ 无重复组合 → 通过固定选择顺序(如只向后选)避免重复
  • ✅ 所有解 → 需遍历完整搜索空间(回溯)

典型应用场景
背包问题变种、资源分配、密码组合生成等需要“枚举所有可行方案”的场景。


🧠 核心算法及代码讲解

本题的核心算法是 回溯(Backtracking) ,属于 DFS(深度优先搜索)的一种特例,常用于求解所有满足条件的路径/组合/排列

🔄 回溯三要素

  1. 路径(Path) :当前已选择的数字组合(combine);

  2. 选择列表(Choices) :从当前索引 idx 开始到末尾的所有候选数;

  3. 结束条件(Base Case)

    • target == 0 → 找到一组有效解,加入答案;
    • idx == candidates.size() → 候选用完,返回;
    • target < 0 → 剪枝(隐式处理,见下文)。

🌳 搜索树结构说明

candidates = [2,3,6,7], target = 7 为例:

                []
          /      |       \        \
        [2]     [3]      [6]      [7]
       /  \     / \       |        |
    [2,2]... [3,3]...   (6+6>7)   [7]✅
    /
[2,2,2]...
    \
  [2,2,3]

注意:每次选择后仍从当前索引开始(因可重复),但不回头选前面的数(避免 [2,3][3,2] 重复)。

💡 优化剪枝技巧(虽非必需,但面试加分!)

  • 排序 + 提前终止:若先对 candidates 排序,则当 candidates[idx] > target 时,后续所有数都更大,可直接 break
  • 本题官方解未显式排序,但实际可通过 if (target - candidates[idx] >= 0) 实现基础剪枝。

✅ C++ 核心函数详解(带行注释)

void dfs(vector<int>& candidates, int target, vector<vector<int>>& ans, vector<int>& combine, int idx) {
    // 🛑 终止条件1:候选数组已遍历完
    if (idx == candidates.size()) {
        return;
    }
    // 🎯 终止条件2:找到一组有效解
    if (target == 0) {
        ans.emplace_back(combine); // 将当前组合拷贝进答案
        return;
    }
    
    // ❌ 选择1:跳过当前数字(不选 candidates[idx])
    dfs(candidates, target, ans, combine, idx + 1);
    
    // ✅ 选择2:选择当前数字(可重复,所以 idx 不变)
    if (target - candidates[idx] >= 0) { // 剪枝:避免负数
        combine.emplace_back(candidates[idx]); // 做出选择
        dfs(candidates, target - candidates[idx], ans, combine, idx); // 递归
        combine.pop_back(); // 撤销选择(回溯!)
    }
}

🔑 关键细节

  • combine 是引用传递,因此必须 pop_back() 恢复现场;
  • 若用值传递(如 JS 中的 [...combine, x]),则无需手动回溯,但空间开销更大。

🧭 解题思路(分步拆解)

  1. 初始化:创建答案容器 ans 和当前路径 combine

  2. 启动回溯:从索引 0 开始,目标为 target

  3. 递归决策

    • 分支一:不选当前数 → idx + 1
    • 分支二:选当前数(若 target >= candidates[idx])→ target - candidates[idx]idx 不变;
  4. 收集结果:当 target == 0 时,将 combine 加入 ans

  5. 返回答案:最终返回 ans

💬 为什么不会重复?
因为我们始终从当前索引或之后选择,永远不会“回头”选更小的数,从而天然去重。


📊 算法分析

项目分析
时间复杂度O(S) ,其中 S 是所有可行解的长度之和。最坏情况下接近 O(N × 2^N),但实际因剪枝远小于此。
空间复杂度O(target) ,递归栈最大深度为 target / min(candidates),即最多选 target 个最小值。
是否稳定是,输出顺序由输入顺序决定(因按索引顺序选择)。
可扩展性极强!稍作修改即可解决: • 元素不可重复 → idx + 1 • 限制组合长度 → 加 depth 参数 • 允许重复元素 → 先排序 + 跳过同层重复

🎯 面试高频追问

  • Q:如果 candidates 有重复元素怎么办?
    A:先排序,然后在同一层递归中跳过重复值(如 i > idx && candidates[i] == candidates[i-1])。
  • Q:如何优化性能?
    A:排序后提前 break;记忆化(但本题状态难压缩);迭代写法(避免栈溢出)。

💻 完整代码

✅ C++ 实现

#include <bits/stdc++.h>
using namespace std;
using ll = long long;

class Solution {
public:
    void dfs(vector<int>& candidates, int target, vector<vector<int>>& ans, vector<int>& combine, int idx) {
        if (idx == candidates.size()) {
            return;
        }
        if (target == 0) {
            ans.emplace_back(combine);
            return;
        }
        // 直接跳过
        dfs(candidates, target, ans, combine, idx + 1);
        // 选择当前数
        if (target - candidates[idx] >= 0) {
            combine.emplace_back(candidates[idx]);
            dfs(candidates, target - candidates[idx], ans, combine, idx);
            combine.pop_back();
        }
    }

    vector<vector<int>> combinationSum(vector<int>& candidates, int target) {
        vector<vector<int>> ans;
        vector<int> combine;
        dfs(candidates, target, ans, combine, 0);
        return ans;
    }
};

// 测试
signed main(){
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    cout.tie(nullptr);

    Solution sol;
    vector<int> candidates = {2,3,6,7};
    int target = 7;
    auto res = sol.combinationSum(candidates, target);
    for (auto& v : res) {
        for (int x : v) cout << x << " ";
        cout << "\n";
    }
    // 输出: 2 2 3 \n 7

    return 0;
}

✅ JavaScript 实现(函数式风格,自动回溯)

var combinationSum = function(candidates, target) {
    const ans = [];
    const dfs = (target, combine, idx) => {
        if (idx === candidates.length) {
            return;
        }
        if (target === 0) {
            ans.push(combine);
            return;
        }
        // 跳过当前数
        dfs(target, combine, idx + 1);
        // 选择当前数(可重复)
        if (target - candidates[idx] >= 0) {
            dfs(target - candidates[idx], [...combine, candidates[idx]], idx); // 自动创建新数组,无需 pop
        }
    };

    dfs(target, [], 0);
    return ans;
};

// 测试
console.log(combinationSum([2,3,6,7], 7)); // [[2,2,3],[7]]
console.log(combinationSum([2,3,5], 8));   // [[2,2,2,2],[2,3,3],[3,5]]
console.log(combinationSum([2], 1));       // []

🌟 本期完结,下期见!🔥

👉 点赞收藏加关注,新文更新不迷路。关注专栏【算法】LeetCode Hot100刷题日记,持续为你拆解每一道热题的底层逻辑与面试技巧!

💬 欢迎留言交流你的解法或疑问!一起进步,冲向 Offer!💪

📌 记住:当你在刷题时,不要只看答案,要像写这篇文章一样,深入思考每一步背后的原理、优化空间和面试价值。这才是真正提升算法能力的方式!