39. 组合总和#
今天要挑战的这道题,表面是数字组合游戏,实则暗藏回溯算法的精髓。据说90%的面试者都会忽视关键细节,让我们用5分钟彻底攻克LeetCode 39题「组合总和」!
🛒 一个真实的生活场景#
小明在超市凑满减活动,手握100元优惠券发愁:
- “巧克力20元,薯片15元,可乐5元…”
- “同一商品可以拿多件”
- “怎样才能刚好凑满100元的所有组合?”
这场景竟与算法题完美对应!让我们揭开题目的真面目。
问题描述#
题目目标#
给定一个无重复元素的整数数组 candidates 和一个目标整数 target ,找出 candidates 中所有和为 target 的不同组合。数组中的同一个数字可以被无限次选取,但解集不能包含重复组合。
示例 1#
输入: candidates = [2,3,6,7], target = 7
输出: [[2,2,3],[7]]
说明: 把输入和输出放在一起看,会更容易直接抓住题目要求的变化。
💡 算法题解析#
题目要求:
给定无重复元素的数组和一个目标数,找出所有唯一组合,满足:
- 组合中数字和等于目标
- 同一数字可无限次使用
- 解集不能有重复组合(如[2,2,3]和[3,2,2]视为同一组合)
示例:
输入:candidates = [2,3,5], target = 8
输出:[[2,2,2,2],[2,3,3],[3,5]]
😱 新手易踩的三个坑#
- 暴力枚举:直接遍历所有子集,产生大量重复组合(时间复杂度O(2^n))
- 遗漏剪枝:不排序直接处理,无法提前终止无效搜索
- 路径回溯:忘记移除已添加元素,导致结果串扰
就像在超市盲目拿商品,既浪费时间又容易拿错!
🚀 高手的解题秘籍#
核心思路:回溯算法 + 剪枝优化#
// 主函数:返回所有和为 target 的组合
public List<List<Integer>> combinationSum(int[] candidates, int target) {
// result:保存最终所有满足条件的组合
List<List<Integer>> result = new ArrayList<>();
// 先排序:便于后续使用 candidates[i] > remain 时直接剪枝
Arrays.sort(candidates);
// 启动回溯:
// - path 初始为空,记录当前组合
// - remain 初始为 target,表示还需要凑出的和
// - start 为 0,表示从第 0 个候选数开始尝试
backtrack(result, new ArrayList<>(), candidates, target, 0);
// 返回所有合法组合
return result;
}
// 回溯函数:在当前 path 基础上,继续尝试拼出 remain
private void backtrack(List<List<Integer>> result, List<Integer> path,
int[] candidates, int remain, int start) {
// 剪枝:如果剩余值小于 0,说明当前路径和已超过 target,直接结束该分支
if (remain < 0) return;
// 终止条件:remain == 0 表示当前路径刚好凑出 target
if (remain == 0) {
// 加入路径副本,避免后续回溯修改影响结果
result.add(new ArrayList<>(path));
// 当前分支完成
return;
}
// 从 start 开始枚举候选数:
// 使用 start 而不是 0,可避免生成顺序不同但本质相同的重复组合
for (int i = start; i < candidates.length; i++) {
// 因为数组已排序,若当前数已大于 remain,后面的数只会更大,直接 break
if (candidates[i] > remain) break;
// 做选择:把当前候选数加入路径
path.add(candidates[i]);
// 递归下一层:
// - remain 减去当前选择值
// - start 传 i(不是 i+1),表示同一个元素可以重复选取
backtrack(result, path, candidates, remain - candidates[i], i);
// 撤销选择(回溯):移除最后加入的元素,尝试下一个候选数
path.remove(path.size() - 1);
}
}java算法图解#
- 排序数组:[2,3,5] → [2,3,5](为剪枝铺垫)
- 构建决策树:每个节点代表选择某个元素
- 剪枝策略:当累计值超过target时停止向下搜索
- 路径记录:用List保存当前选择路径
就像在超市:
- 先按价格排序商品(排序)
- 从便宜的开始拿(剪枝)
- 记录已选商品(路径)
- 凑满金额立即结算(终止条件)
🏆 复杂度优化关键#
- 排序剪枝:时间复杂度从O(2^n)降到O(n^(target/min))
- 避免重复:通过start参数控制选择范围,保证组合唯一性
- 空间优化:复用path列表,空间复杂度O(target/min)
💼 面试灵魂三问#
-
为什么需要排序?
- 实现剪枝优化,提前终止无效搜索路径
- 保证组合元素的递增顺序,避免重复组合
-
如何处理元素重复使用?
- 递归时传递start=i而非i+1,允许重复选择当前元素
-
算法适用哪些变种题?
- 组合总和II(不可重复使用元素)
- 电话号码字母组合
- 全排列问题
📌 核心技巧总结#
掌握回溯三板斧:
- 选择:记录当前决策
- 约束:通过剪枝减少搜索
- 撤销:回溯到上一步状态