组合总和(Combination Sum)是LeetCode上经典的算法问题,编号39。这道题要求我们从一个无重复元素的整数数组中找出所有能使数字和为目标值的组合,且同一个数字可以无限次重复使用。这类问题在实际开发中有着广泛的应用场景,比如货币找零、资源分配、测试用例生成等。
提示:理解组合总和问题的关键在于把握"完全背包"的特性和回溯算法的应用。与排列问题不同,组合问题不关心元素的顺序,[2,2,3]和[2,3,2]被视为同一组合。
组合总和问题具有以下几个显著特征:
以示例1为例:
code复制输入:candidates = [2,3,6,7], target = 7
输出:[[2,2,3],[7]]
这里2+2+3=7,7=7,都是有效的组合。注意[2,3,2]不会被单独列出,因为它与[2,2,3]被视为相同的组合。
回溯算法是一种通过探索所有可能的候选解来找出所有解的算法。如果候选解被确认不是一个解(或者至少不是最后一个解),回溯算法会通过在上一步进行一些变化来丢弃该解,即"回溯"并尝试其他可能性。
对于组合总和问题,回溯算法的三个关键要素是:
回溯算法有一个通用的模板结构,组合总和问题的解法也遵循这个模板:
java复制void backtrack(路径, 选择列表) {
if (满足结束条件) {
result.add(路径);
return;
}
for (选择 : 选择列表) {
做选择;
backtrack(路径, 新选择列表);
撤销选择;
}
}
在实际应用中,我们需要根据具体问题调整这个模板。对于组合总和问题,关键调整点在于:
我们先来看一个基础的Java实现,不包含任何优化:
java复制class Solution {
public List<List<Integer>> combinationSum(int[] candidates, int target) {
List<List<Integer>> result = new ArrayList<>();
backtrack(candidates, target, 0, new ArrayList<>(), result);
return result;
}
private void backtrack(int[] candidates, int target, int start,
List<Integer> path, List<List<Integer>> result) {
if (target == 0) {
result.add(new ArrayList<>(path));
return;
}
for (int i = start; i < candidates.length; i++) {
if (candidates[i] > target) {
continue;
}
path.add(candidates[i]);
backtrack(candidates, target - candidates[i], i, path, result);
path.remove(path.size() - 1);
}
}
}
这个版本的核心逻辑是:
我们可以通过预排序和更积极的剪枝来优化算法:
java复制class Solution {
public List<List<Integer>> combinationSum(int[] candidates, int target) {
Arrays.sort(candidates); // 关键优化:预排序
List<List<Integer>> result = new ArrayList<>();
backtrack(candidates, target, 0, new ArrayList<>(), result);
return result;
}
private void backtrack(int[] candidates, int target, int start,
List<Integer> path, List<List<Integer>> result) {
if (target == 0) {
result.add(new ArrayList<>(path));
return;
}
for (int i = start; i < candidates.length; i++) {
if (candidates[i] > target) {
break; // 关键优化:由于已排序,可以直接终止循环
}
path.add(candidates[i]);
backtrack(candidates, target - candidates[i], i, path, result);
path.remove(path.size() - 1);
}
}
}
这个优化版本的主要改进是:
注意:虽然排序需要O(nlogn)的时间,但它能让剪枝更有效,总体上能提高算法效率。
start参数是避免生成重复组合的关键。它确保了组合是按非递减顺序生成的,从而天然避免了像[2,3,2]这样的重复组合。
具体来说:
这种机制保证了组合的唯一性,不需要额外的去重操作。
以candidates = [2,3,6,7], target = 7为例,递归树的结构如下:
code复制 []
/ | | \
[2][3][6][7]
/| | \
[2,2][2,3] [7]✓
/ | \
[2,2,2][2,2,3]✓
|
[2,2,2,2]✗ (target=-1)
在这个树中:
剪枝是回溯算法优化的关键。对于组合总和问题,有两种主要的剪枝方式:
基础剪枝:当candidates[i] > target时跳过当前元素
java复制if (candidates[i] > target) continue;
排序后强剪枝:先排序数组,当candidates[i] > target时直接终止循环
java复制if (candidates[i] > target) break;
第二种方式更高效,因为数组已排序,后续元素肯定都大于target,可以直接终止整个循环。
组合总和问题的时间复杂度分析比较特殊,因为它取决于解的数量。
理论最坏情况:O(N^(T/M)),其中:
实际复杂度:O(S),其中S是所有解的长度之和
举例说明:对于candidates = [2], target = 40,只有一个解[2,2,...,2](20个2),时间复杂度是O(20)=O(target)
空间复杂度主要由递归调用栈的深度决定:
因此,标准答案的空间复杂度为O(target)。
动态规划适合求解的数量或最优解,但本题要求输出所有解:
这是LeetCode 40(组合总和 II)的问题。解决方案是:
java复制if (i > start && candidates[i] == candidates[i-1]) continue;
这是0-1背包或多重背包问题:
在添加路径到结果集时,必须创建新的ArrayList:
java复制result.add(new ArrayList<>(path));
如果不这样做,所有组合将指向同一个path对象,最终结果会全部等于最后一次path的状态。
组合总和算法在实际开发中有多种应用:
虽然实际中很少直接枚举所有组合(因为数量可能爆炸),但组合优化思想广泛应用于各种资源分配问题。
为了加深对组合总和问题的理解,建议练习以下相关题目:
| 题号 | 题目 | 难度 | 关键点 |
|---|---|---|---|
| 40 | 组合总和 II | 中等 | 含重复元素,需去重 |
| 216 | 组合总和 III | 中等 | 限定组合大小和元素范围 |
| 377 | 组合总和 IV | 中等 | 求排列数(顺序不同算不同) |
| 77 | 组合 | 中等 | 固定大小组合 |
| 78 | 子集 | 中等 | 无目标和的组合 |
| 518 | 零钱兑换 II | 中等 | 完全背包计数(DP解法) |
| 322 | 零钱兑换 | 中等 | 完全背包最值(DP解法) |
学习路径建议:
可以预先计算数组中的最小值,提前终止明显无效的输入:
java复制int min = Arrays.stream(candidates).min().getAsInt();
if (min > target) return result; // 无解
不过题目中candidates[i] ≥ 2且target ≥ 1,这种优化收益有限。
可以减少对象创建的开销:
java复制private void backtrack(int[] candidates, int target, int start,
int[] path, int len, List<List<Integer>> result) {
if (target == 0) {
result.add(Arrays.stream(path, 0, len).boxed().collect(Collectors.toList()));
return;
}
for (int i = start; i < candidates.length; i++) {
if (candidates[i] > target) break;
path[len] = candidates[i];
backtrack(candidates, target - candidates[i], i, path, len + 1, result);
}
}
调用方式:
java复制int[] path = new int[target]; // 最大可能长度
backtrack(candidates, target, 0, path, 0, result);
优点是无add/remove开销,缺点是代码复杂且需要转换为List。
虽然Java不支持尾递归优化,但了解这个概念有助于写出更好的递归代码。不过对于组合总和问题,递归后还有操作(path.remove),所以不是尾递归。
面试中可能会要求手写代码并解释关键点。回答要点:
解释时间复杂度的特殊性:
讨论问题变化时的处理方式:
展示如何修改代码来限制元素使用次数:
在实际解决组合总和问题时,我总结了以下几点经验:
先写模板再填充:先写出回溯的基本模板结构,再根据问题特点填充细节,这样不容易遗漏关键部分。
画递归树辅助理解:对于复杂的回溯问题,画出递归树能帮助理解算法的执行过程,特别是剪枝的位置和效果。
小数据量测试:先用小的测试用例手动验证算法,确保基本逻辑正确后再处理更大规模的数据。
注意Java集合的特性:特别是ArrayList的深拷贝问题,这是容易出错的地方。
剪枝要适度:不是所有问题都需要强剪枝,有时候简单的剪枝就足够了,过度优化可能使代码难以维护。
比较不同解法:尝试用不同的方法解决同一问题(如回溯和DP),比较它们的优缺点,加深对问题本质的理解。
关注边界条件:特别是target为0、candidates为空或只有一个元素等情况,这些往往是bug的温床。
性能分析实践:实际测量不同实现的运行时间,理解理论分析与实际表现的差异。