1. 组合总数问题概述
组合总数(Combination Sum)是算法面试中频繁出现的经典回溯问题。给定一个无重复元素的整数数组candidates和一个目标数target,找出candidates中所有可以使数字和为target的唯一组合。同一个数字可以无限制重复被选取,解集不能包含重复的组合。
这个问题之所以重要,是因为它完美展现了回溯算法的核心思想——"尝试与回退"。我在多次面试中遇到这个问题,也曾在实际开发中遇到过类似的商品组合推荐场景。掌握这个问题的解法,不仅能帮助我们理解回溯算法,还能培养解决组合优化问题的思维模式。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 问题分析与解法思路
2.1 问题拆解与理解
以示例candidates = [2,3,6,7], target = 7为例:
- 有效组合包括[2,2,3]和[7]
- 注意[2,3,2]虽然和相同,但与[2,2,3]被视为同一组合
- 数字可以重复使用(如2用了两次)
- 需要返回所有可能的唯一组合
2.2 回溯算法框架
回溯法通常遵循以下模板:
python复制def backtrack(路径, 选择列表):
if 满足结束条件:
结果.append(路径)
return
for 选择 in 选择列表:
做选择
backtrack(路径, 选择列表)
撤销选择
对于组合总数问题,我们需要:
- 排序数组(便于剪枝)
- 定义回溯函数,维护当前路径和剩余目标值
- 在每次递归时,从当前位置开始尝试(避免重复组合)
- 当剩余目标为0时记录结果
3. 完整解法实现
3.1 Python实现代码
python复制def combinationSum(candidates, target):
res = []
candidates.sort() # 排序便于剪枝
def backtrack(start, path, remain):
if remain == 0:
res.append(path.copy())
return
for i in range(start, len(candidates)):
if candidates[i] > remain: # 剪枝
break
path.append(candidates[i])
backtrack(i, path, remain - candidates[i]) # 注意从i开始而非i+1
path.pop()
backtrack(0, [], target)
return res
3.2 关键点解析
- 排序的重要性:排序后可以提前终止不可能的分支(当当前数 > 剩余目标时)
- start参数:确保组合的唯一性,避免[2,3]和[3,2]这样的重复
- 递归终止条件:remain == 0表示找到有效组合
- 回溯操作:path.pop()撤销选择,回到上一步状态
4. 复杂度分析与优化
4.1 时间复杂度
最坏情况下(如candidates = [1], target = n):
- 时间复杂度为O(N^(T/M+1)),其中N是候选数个数,T是目标值,M是最小候选数
- 空间复杂度主要取决于递归栈深度,最坏为O(T/M)
4.2 剪枝优化技巧
- 提前排序+提前终止:如上述实现所示
- 去重处理:虽然题目说明无重复元素,但若有重复,应先排序并在循环中添加:
python复制if i > start and candidates[i] == candidates[i-1]:
continue
5. 变种问题与实际应用
5.1 常见变种
- 组合总数II:每个数字只能使用一次
- 组合总数III:只使用1-9的数字,且组合长度为k
- 组合总数IV:考虑顺序不同的组合为不同解(实际变为排列问题)
5.2 实际应用场景
- 电商促销:给定商品价格列表,找出总价正好等于优惠券面值的商品组合
- 资源分配:在预算限制下选择项目组合
- 密码破解:尝试各种字符组合匹配目标哈希值
6. 常见错误与调试技巧
6.1 典型错误案例
- 忘记排序:导致剪枝失效,性能下降
- 错误使用i+1:会导致数字不能重复使用
- 浅拷贝问题:直接res.append(path)会得到空列表,必须用path.copy()
6.2 调试建议
- 添加打印语句观察递归过程:
python复制print(f"Start: {start}, Path: {path}, Remain: {remain}")
- 使用小规模测试用例逐步验证
- 可视化递归树帮助理解
7. 扩展思考与进阶学习
7.1 动态规划解法
虽然回溯是更直观的解法,但这个问题也可以用DP解决:
python复制def combinationSumDP(candidates, target):
dp = [[] for _ in range(target+1)]
for c in candidates:
for t in range(c, target+1):
if t == c:
dp[t].append([c])
for combo in dp[t-c]:
dp[t].append(combo + [c])
return dp[target]
7.2 性能对比
- 回溯法:更适合需要所有解的情况
- DP:更适合只需要解的数量或优化问题
- 当target较大而candidates较小时,DP可能更高效
我在实际项目中发现,当target超过1000时,DP解法通常表现更好。但回溯法的优势在于可以轻松添加各种约束条件,如组合长度限制等。
