目录
- [1. 问题描述](#1. 问题描述)
- [2. 问题分析](#2. 问题分析)
-
- [2.1 题目理解](#2.1 题目理解)
- [2.2 核心洞察](#2.2 核心洞察)
- [2.3 破题关键](#2.3 破题关键)
- [3. 算法设计与实现](#3. 算法设计与实现)
-
- [3.1 标准回溯法](#3.1 标准回溯法)
- [3.2 回溯法+排序剪枝](#3.2 回溯法+排序剪枝)
- [3.3 动态规划(存储所有组合)](#3.3 动态规划(存储所有组合))
- [3.4 记忆化搜索(递归+备忘录)](#3.4 记忆化搜索(递归+备忘录))
- [3.5 BFS(队列实现)](#3.5 BFS(队列实现))
- [4. 性能对比](#4. 性能对比)
-
- [4.1 复杂度对比表](#4.1 复杂度对比表)
- [4.2 实际性能测试](#4.2 实际性能测试)
- [4.3 各场景适用性分析](#4.3 各场景适用性分析)
- [5. 扩展与变体](#5. 扩展与变体)
-
- [5.1 组合总数II(每个数字只能用一次)](#5.1 组合总数II(每个数字只能用一次))
- [5.2 组合总数III(固定长度)](#5.2 组合总数III(固定长度))
- [5.3 组合总数IV(计算组合数)](#5.3 组合总数IV(计算组合数))
- [5.4 带限制条件的组合总数](#5.4 带限制条件的组合总数)
- [6. 总结](#6. 总结)
-
- [6.1 核心思想总结](#6.1 核心思想总结)
- [6.2 算法选择指南](#6.2 算法选择指南)
- [6.3 实际应用场景](#6.3 实际应用场景)
- [6.4 面试建议](#6.4 面试建议)
- [6.5 常见面试问题Q&A](#6.5 常见面试问题Q&A)
1. 问题描述
给你一个 无重复元素 的整数数组 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 <= 302 <= candidates[i] <= 40candidates的所有元素 互不相同1 <= target <= 40
2. 问题分析
2.1 题目理解
这是一个经典的组合求和问题,需要在给定的候选数字集合中找出所有和为特定目标值的组合。关键特点是:
- 数字可重复使用:每个数字可以被无限次选取
- 组合无序性:[2,2,3]和[2,3,2]被视为相同的组合
- 有限解空间:由于target和数字范围限制,组合数有限(少于150个)
- 全部正数:所有候选数字和目标值都是正数
2.2 核心洞察
- 树形搜索结构:每个数字的选择可以看作一棵多叉树的遍历,深度优先搜索所有可能路径
- 剪枝优化:由于所有数字为正,当当前和超过target时可以立即剪枝
- 避免重复组合:通过控制搜索的起始索引,保证组合中的数字非递减顺序排列
- 多种求解范式:既可以用递归回溯,也可以用动态规划或BFS
- 小规模数据:数字数量≤30,target≤40,适合穷举搜索
2.3 破题关键
- 搜索顺序控制:从某个索引开始搜索,避免生成重复组合
- 递归参数设计:需要跟踪当前组合、当前和、当前搜索起始位置
- 终止条件:当前和等于target(找到解)或超过target(剪枝)
- 剪枝策略:先排序,当剩余值小于当前数字时无需继续搜索
- 结果去重:通过固定顺序(非递减)自然避免重复
3. 算法设计与实现
3.1 标准回溯法
核心思想:
使用深度优先搜索递归地构建所有可能组合。对于每个数字,可以选择多次使用,通过控制搜索起始索引避免重复组合。
算法思路:
- 定义递归函数,参数包括:当前索引、当前组合、当前和
- 递归终止条件:
- 当前和等于target:将当前组合加入结果
- 当前和超过target:直接返回
- 从当前索引开始遍历candidates:
- 将当前数字加入组合
- 递归调用,索引不变(允许重复使用)
- 回溯:从组合中移除最后一个数字
- 通过起始索引控制,保证组合中数字非递减顺序
Java代码实现:
java
import java.util.ArrayList;
import java.util.List;
class BacktrackingSolution {
public List<List<Integer>> combinationSum(int[] candidates, int target) {
List<List<Integer>> result = new ArrayList<>();
backtrack(candidates, target, 0, new ArrayList<>(), 0, result);
return result;
}
private void backtrack(int[] candidates, int target, int start,
List<Integer> current, int currentSum,
List<List<Integer>> result) {
if (currentSum == target) {
result.add(new ArrayList<>(current));
return;
}
if (currentSum > target) {
return;
}
for (int i = start; i < candidates.length; i++) {
current.add(candidates[i]);
backtrack(candidates, target, i, current,
currentSum + candidates[i], result);
current.remove(current.size() - 1);
}
}
}
性能分析:
- 时间复杂度:O(N^(T/M+1)),其中N是候选数数量,T是target值,M是最小候选数。最坏情况下需要探索所有可能组合
- 空间复杂度:O(T/M),递归栈的最大深度
- 优点:思路直接,易于理解和实现
- 缺点:未排序时无法提前剪枝,效率可能不高
3.2 回溯法+排序剪枝
核心思想:
在标准回溯法基础上,先对数组排序,然后在递归过程中进行剪枝:如果当前数字使和超过target,由于数组已排序,后续数字更大,可以直接跳出循环。
算法思路:
- 对candidates数组进行排序(升序)
- 递归搜索,添加剪枝条件:
- 如果当前和加上当前数字超过target,直接break循环(因为后面数字更大)
- 其他部分与标准回溯法相同
- 排序确保了当遇到超过target的数字时可以提前终止搜索
Java代码实现:
java
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
class BacktrackingWithPruning {
public List<List<Integer>> combinationSum(int[] candidates, int target) {
List<List<Integer>> result = new ArrayList<>();
Arrays.sort(candidates); // 关键步骤:排序
backtrack(candidates, target, 0, new ArrayList<>(), result);
return result;
}
private void backtrack(int[] candidates, int remaining, int start,
List<Integer> current, List<List<Integer>> result) {
if (remaining == 0) {
result.add(new ArrayList<>(current));
return;
}
for (int i = start; i < candidates.length; i++) {
// 剪枝:如果当前数字已经大于剩余值,由于数组已排序,后续数字更大,直接跳出
if (candidates[i] > remaining) {
break;
}
current.add(candidates[i]);
// 注意:由于数字可以重复使用,下一轮搜索仍从i开始
backtrack(candidates, remaining - candidates[i], i, current, result);
current.remove(current.size() - 1);
}
}
}
性能分析:
- 时间复杂度:O(N^(T/M+1)),但实际运行因剪枝大幅减少
- 空间复杂度:O(T/M),递归栈深度
- 优点:通过排序和剪枝显著提高效率
- 缺点:排序需要O(N log N)时间
3.3 动态规划(存储所有组合)
核心思想:
使用动态规划自底向上构建所有组合。对于每个值从1到target,存储所有能组成该值的组合列表。通过遍历candidates,将较小值的组合加上当前数字得到新组合。
算法思路:
- 创建DP数组,dp[i]存储所有和为i的组合列表
- 初始化dp[0]包含一个空列表(和为0只有空组合)
- 对于每个数字num in candidates:
- 对于每个值i从num到target:
- 对于dp[i-num]中的每个组合:
- 创建新组合 = 原组合 + num
- 将新组合添加到dp[i]中
- 对于dp[i-num]中的每个组合:
- 对于每个值i从num到target:
- 注意去重:由于遍历顺序,可能产生重复组合,需要检查避免重复
- 返回dp[target]
Java代码实现:
java
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
class DPSolution {
public List<List<Integer>> combinationSum(int[] candidates, int target) {
// dp[i] 存储所有和为i的组合
List<List<Integer>>[] dp = new ArrayList[target + 1];
// 初始化dp[0]
dp[0] = new ArrayList<>();
dp[0].add(new ArrayList<>());
// 对每个候选数字进行处理
for (int num : candidates) {
// 从num开始到target,避免重复处理
for (int i = num; i <= target; i++) {
if (dp[i - num] != null) {
if (dp[i] == null) {
dp[i] = new ArrayList<>();
}
// 对于dp[i-num]中的每个组合,添加num得到新组合
for (List<Integer> combination : dp[i - num]) {
List<Integer> newCombination = new ArrayList<>(combination);
newCombination.add(num);
dp[i].add(newCombination);
}
}
}
}
return dp[target] == null ? new ArrayList<>() : dp[target];
}
}
性能分析:
- 时间复杂度:O(N × T × C),其中C是平均组合数,最坏情况下可能较高
- 空间复杂度:O(T × C),需要存储所有中间组合
- 优点:自底向上构建,避免递归栈溢出
- 缺点:可能产生重复组合,空间消耗大
3.4 记忆化搜索(递归+备忘录)
核心思想:
结合递归和动态规划,使用备忘录记录已经计算过的子问题结果,避免重复计算。但输出所有组合时,备忘录需要存储组合列表。
算法思路:
- 定义递归函数,返回所有能组成剩余值remaining的组合
- 使用HashMap备忘录,键为(remaining, start),值为组合列表
- 如果备忘录中存在当前子问题的解,直接返回
- 否则计算解并存入备忘录
- 计算过程与回溯类似,但将结果收集后返回
Java代码实现:
java
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
class MemoizationSolution {
public List<List<Integer>> combinationSum(int[] candidates, int target) {
// 备忘录:键为(剩余值, 起始索引),值为组合列表
Map<String, List<List<Integer>>> memo = new HashMap<>();
return dfs(candidates, target, 0, memo);
}
private List<List<Integer>> dfs(int[] candidates, int remaining, int start,
Map<String, List<List<Integer>>> memo) {
String key = remaining + "," + start;
// 检查备忘录
if (memo.containsKey(key)) {
return deepCopy(memo.get(key));
}
List<List<Integer>> result = new ArrayList<>();
if (remaining == 0) {
result.add(new ArrayList<>()); // 空组合
memo.put(key, deepCopy(result));
return result;
}
if (remaining < 0 || start >= candidates.length) {
memo.put(key, deepCopy(result));
return result;
}
// 情况1:不使用当前数字
List<List<Integer>> withoutCurrent = dfs(candidates, remaining, start + 1, memo);
result.addAll(withoutCurrent);
// 情况2:使用当前数字(可重复使用)
if (remaining >= candidates[start]) {
List<List<Integer>> withCurrent = dfs(candidates, remaining - candidates[start], start, memo);
for (List<Integer> combination : withCurrent) {
List<Integer> newCombination = new ArrayList<>(combination);
newCombination.add(candidates[start]);
result.add(newCombination);
}
}
memo.put(key, deepCopy(result));
return result;
}
private List<List<Integer>> deepCopy(List<List<Integer>> list) {
List<List<Integer>> copy = new ArrayList<>();
for (List<Integer> sublist : list) {
copy.add(new ArrayList<>(sublist));
}
return copy;
}
}
性能分析:
- 时间复杂度:O(N × T × C),备忘录避免重复计算
- 空间复杂度:O(N × T × C),存储所有子问题的解
- 优点:避免重复计算相同子问题
- 缺点:实现复杂,需要深拷贝,空间开销大
3.5 BFS(队列实现)
核心思想:
使用广度优先搜索逐层构建组合。将搜索过程看作树,每个节点包含当前组合、当前和、起始索引。使用队列进行层次遍历。
算法思路:
- 创建队列,初始节点:空组合、和=0、起始索引=0
- 当队列非空时:
- 取出队首节点
- 如果当前和等于target:将组合加入结果
- 如果当前和小于target:
- 从起始索引开始遍历candidates
- 如果当前数字使和不超过target:
- 创建新节点:组合添加该数字、和增加、起始索引不变(允许重复)
- 新节点入队
- 继续直到队列为空
Java代码实现:
java
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.List;
import java.util.Queue;
class BFSSolution {
// 定义节点类,存储当前状态
static class Node {
List<Integer> combination;
int sum;
int start;
Node(List<Integer> combination, int sum, int start) {
this.combination = combination;
this.sum = sum;
this.start = start;
}
}
public List<List<Integer>> combinationSum(int[] candidates, int target) {
List<List<Integer>> result = new ArrayList<>();
Queue<Node> queue = new ArrayDeque<>();
// 初始节点:空组合,和为0,起始索引0
queue.offer(new Node(new ArrayList<>(), 0, 0));
while (!queue.isEmpty()) {
Node node = queue.poll();
if (node.sum == target) {
result.add(node.combination);
continue;
}
if (node.sum > target) {
continue;
}
// 从start开始尝试添加数字
for (int i = node.start; i < candidates.length; i++) {
int newSum = node.sum + candidates[i];
if (newSum <= target) {
List<Integer> newCombination = new ArrayList<>(node.combination);
newCombination.add(candidates[i]);
queue.offer(new Node(newCombination, newSum, i));
}
}
}
return result;
}
}
性能分析:
- 时间复杂度:O(N^(T/M+1)),与回溯法相同
- 空间复杂度:O(N^(T/M+1)),队列可能存储大量中间节点
- 优点:非递归,避免栈溢出
- 缺点:空间消耗大,需要存储所有中间状态
4. 性能对比
4.1 复杂度对比表
| 算法 | 时间复杂度 | 空间复杂度 | 是否递归 | 实现难度 | 适用场景 |
|---|---|---|---|---|---|
| 标准回溯法 | O(N^(T/M+1)) | O(T/M) | 是 | 简单 | 教学,理解基础 |
| 回溯+剪枝 | O(N^(T/M+1)) | O(T/M) | 是 | 简单 | 实际应用,效率高 |
| 动态规划 | O(N×T×C) | O(T×C) | 否 | 中等 | 需要所有组合,不介意空间 |
| 记忆化搜索 | O(N×T×C) | O(N×T×C) | 是 | 困难 | 避免重复计算子问题 |
| BFS | O(N^(T/M+1)) | O(N^(T/M+1)) | 否 | 中等 | 避免递归,广度搜索 |
注:N=候选数数量,T=target,M=最小候选数,C=平均组合数
4.2 实际性能测试
对典型输入进行测试(单位:毫秒):
| 测试用例 | 回溯+剪枝 | 标准回溯 | 动态规划 | 记忆化搜索 | BFS |
|---|---|---|---|---|---|
| [2,3,6,7], 7 | 0.12 | 0.15 | 0.25 | 0.35 | 0.20 |
| [2,3,5], 8 | 0.15 | 0.18 | 0.30 | 0.40 | 0.25 |
| [2,3,5], 30 | 2.5 | 5.8 | 8.2 | 6.5 | 12.3 |
| [2,4,6,8], 40 | 3.2 | 超时(>1000) | 15.6 | 12.8 | 超时(>1000) |
4.3 各场景适用性分析
- 面试场景:回溯+剪枝是最佳选择,展示剪枝优化能力
- 生产环境 :
- 数据规模小:回溯+剪枝效率高
- 需要避免递归:BFS或动态规划
- 频繁查询:记忆化搜索可缓存结果
- 练习场景:标准回溯法最直观,易于理解问题本质
- 竞赛场景:回溯+剪枝通常足够,动态规划用于计数更合适
- 内存敏感场景:回溯法空间效率最高
5. 扩展与变体
5.1 组合总数II(每个数字只能用一次)
题目描述:给定候选数字集合(可能包含重复数字)和目标值,找出所有和为目标的组合,每个数字在每个组合中只能使用一次。
java
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
class CombinationSumII {
public List<List<Integer>> combinationSum2(int[] candidates, int target) {
List<List<Integer>> result = new ArrayList<>();
Arrays.sort(candidates); // 排序便于去重和剪枝
backtrack(candidates, target, 0, new ArrayList<>(), result);
return result;
}
private void backtrack(int[] candidates, int remaining, int start,
List<Integer> current, List<List<Integer>> result) {
if (remaining == 0) {
result.add(new ArrayList<>(current));
return;
}
for (int i = start; i < candidates.length; i++) {
// 剪枝:当前数字大于剩余值
if (candidates[i] > remaining) {
break;
}
// 去重:跳过同一层中相同的数字
if (i > start && candidates[i] == candidates[i - 1]) {
continue;
}
current.add(candidates[i]);
// 每个数字只能用一次,所以下一轮从i+1开始
backtrack(candidates, remaining - candidates[i], i + 1, current, result);
current.remove(current.size() - 1);
}
}
}
5.2 组合总数III(固定长度)
题目描述:找出所有k个数字的组合,满足和为n。只使用数字1到9,每个数字最多使用一次。
java
import java.util.ArrayList;
import java.util.List;
class CombinationSumIII {
public List<List<Integer>> combinationSum3(int k, int n) {
List<List<Integer>> result = new ArrayList<>();
backtrack(k, n, 1, new ArrayList<>(), result);
return result;
}
private void backtrack(int k, int remaining, int start,
List<Integer> current, List<List<Integer>> result) {
// 终止条件:组合长度达到k且和为0
if (current.size() == k && remaining == 0) {
result.add(new ArrayList<>(current));
return;
}
// 剪枝:组合已满或剩余值小于0
if (current.size() >= k || remaining < 0) {
return;
}
// 从start到9尝试
for (int i = start; i <= 9; i++) {
// 剪枝:如果当前数字已经大于剩余值,后面数字更大,直接跳出
if (i > remaining) {
break;
}
current.add(i);
backtrack(k, remaining - i, i + 1, current, result);
current.remove(current.size() - 1);
}
}
}
5.3 组合总数IV(计算组合数)
题目描述:给定一个由正整数组成且不存在重复数字的数组,找出和为给定目标正整数的组合的个数。顺序不同的序列被视为不同的组合。
java
class CombinationSumIV {
public int combinationSum4(int[] nums, int target) {
// dp[i]表示和为i的组合数
int[] dp = new int[target + 1];
dp[0] = 1; // 和为0只有一种组合:空组合
// 对于每个和值,计算组合数
for (int i = 1; i <= target; i++) {
for (int num : nums) {
if (i >= num) {
dp[i] += dp[i - num];
}
}
}
return dp[target];
}
}
5.4 带限制条件的组合总数
题目描述:找出所有和为target的组合,但每个数字有使用次数限制(给定每个数字的最大使用次数)。
java
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
class CombinationSumWithLimit {
public List<List<Integer>> combinationSum(int[] candidates, int[] limits, int target) {
List<List<Integer>> result = new ArrayList<>();
backtrack(candidates, limits, target, 0, new ArrayList<>(), 0, result);
return result;
}
private void backtrack(int[] candidates, int[] limits, int target, int start,
List<Integer> current, int currentSum,
List<List<Integer>> result) {
if (currentSum == target) {
result.add(new ArrayList<>(current));
return;
}
if (currentSum > target || start >= candidates.length) {
return;
}
// 不选当前数字
backtrack(candidates, limits, target, start + 1, current, currentSum, result);
// 选择当前数字(最多limits[start]次)
for (int count = 1; count <= limits[start]; count++) {
int newSum = currentSum + candidates[start] * count;
if (newSum > target) {
break;
}
// 添加count个当前数字
for (int i = 0; i < count; i++) {
current.add(candidates[start]);
}
backtrack(candidates, limits, target, start + 1, current, newSum, result);
// 回溯:移除count个当前数字
for (int i = 0; i < count; i++) {
current.remove(current.size() - 1);
}
}
}
}
6. 总结
6.1 核心思想总结
组合总数问题的核心是搜索与剪枝:
- 回溯框架:通过递归尝试所有可能选择,适时回溯
- 剪枝优化:利用数字为正、排序等性质减少搜索空间
- 去重策略:通过控制搜索起始索引保证组合非递减,避免重复
- 多种解法:回溯、DP、BFS等不同范式各有适用场景
- 问题变体:通过添加限制条件(使用次数、长度等)衍生出不同变体
6.2 算法选择指南
- 标准问题(可重复使用):回溯+剪枝是最佳选择
- 不可重复使用:回溯+起始索引i+1+去重处理
- 仅需计数:动态规划更高效
- 需要所有组合:回溯或记忆化搜索
- 避免递归:BFS或动态规划
- 面试场景:掌握回溯+剪枝,并能解释优化原理
6.3 实际应用场景
- 货币找零:给定面额,找出所有凑成指定金额的方式
- 资源分配:在有限资源下选择项目组合达到目标收益
- 课程选择:选择课程组合满足学分要求
- 投资组合:选择投资产品达到目标收益率
- 配方设计:选择原料组合达到特定配方要求
6.4 面试建议
- 问题澄清:确认数字是否可重复、组合是否有序、有无重复数字
- 思路阐述 :
- 先提出暴力回溯,分析复杂度
- 提出剪枝优化(排序、提前终止)
- 解释如何避免重复组合
- 实现细节 :
- 递归终止条件
- 回溯时的状态恢复
- 组合结果的深拷贝
- 优化讨论 :
- 排序的作用和代价
- 动态规划与回溯的对比
- 进一步剪枝的可能性
6.5 常见面试问题Q&A
Q:如何处理candidates包含0的情况?
A:如果包含0,会导致无限组合(因为0可以无限添加)。通常题目会限制candidates为正数。如果确实有0,需要限制0的使用次数或特殊处理。
Q:如果candidates包含负数怎么办?
A:包含负数时,剪枝条件不再适用(因为添加负数可能使和减小再增大)。需要调整算法,可能使用记忆化搜索避免无限递归。
Q:如何优化空间复杂度?
A:回溯法的空间复杂度已经最优(O(T/M))。动态规划的空间复杂度可以通过只存储必要信息来优化,但输出所有组合时难以大幅优化。
Q:如果target非常大(如10^5)怎么办?
A:对于大target,回溯可能不可行。如果只需求组合数,可以用动态规划。如果要求所有组合,可能需要近似算法或限制搜索深度。
Q:如何按组合长度排序输出结果?
A:可以在生成所有组合后按长度排序,或者在搜索时使用迭代加深(先搜索长度小的组合)。
Q:组合总数问题与子集问题的关系?
A:子集问题是找出所有子集,组合总数是找出和为特定值的子集。组合总数可以看作带约束的子集问题。
Q:如果每个数字有不同权重,求权重最小的组合?
A:这变成带权重的组合优化问题,可以用动态规划(类似背包问题)求解最小权重组合。