1. 组合总和问题解析
组合总和(Combination Sum)是LeetCode上经典的回溯算法问题,编号为39题。题目要求给定一个无重复元素的整数数组candidates和一个目标数target,找出candidates中所有可以使数字和为target的组合。candidates中的数字可以无限制重复被选取。
1.1 问题核心理解
这道题的关键在于理解几个要点:
- 同一个数字可以重复使用
- 所有数字都是正整数(根据LeetCode的测试用例)
- 解集不能包含重复的组合
- 需要返回所有可能的组合,而不仅仅是组合的数量
例如,给定candidates = [2,3,6,7],target = 7,解集为: [ [2,2,3], [7] ]
1.2 回溯算法适用性分析
回溯算法特别适合解决这类组合问题,因为它能系统地搜索所有可能的解。与动态规划不同,回溯算法关注的是找到所有解而非最优解或解的数量。
回溯算法的基本框架包含三个关键部分:
- 选择:在当前步骤选择一个候选数字
- 约束:确定选择的数字是否满足条件(如不超过target)
- 目标:判断当前组合是否已经达到目标(sum === target)
2. JavaScript实现详解
2.1 基础回溯实现
以下是组合总和问题的基本JavaScript解法:
function combinationSum(candidates, target) { const result = []; function backtrack(start, current, sum) { if (sum === target) { result.push([...current]); return; } if (sum > target) return; for (let i = start; i < candidates.length; i++) { current.push(candidates[i]); backtrack(i, current, sum + candidates[i]); current.pop(); } } backtrack(0, [], 0); return result; }2.2 关键代码解析
- result数组:存储所有符合条件的组合
- backtrack函数:核心回溯函数,参数包括:
- start:控制遍历起始位置,避免重复组合
- current:当前组合
- sum:当前组合的和
- 终止条件:
- sum === target:找到有效组合
- sum > target:剪枝,停止继续搜索
- 回溯过程:
- 选择candidates[i]
- 递归尝试
- 撤销选择(回溯)
2.3 剪枝优化
可以对候选数组先排序,然后在循环中添加提前终止条件:
function combinationSum(candidates, target) { const result = []; candidates.sort((a, b) => a - b); // 排序 function backtrack(start, current, sum) { if (sum === target) { result.push([...current]); return; } for (let i = start; i < candidates.length; i++) { if (sum + candidates[i] > target) break; // 提前终止 current.push(candidates[i]); backtrack(i, current, sum + candidates[i]); current.pop(); } } backtrack(0, [], 0); return result; }排序后,当sum + candidates[i] > target时,可以直接break循环,因为后面的数字更大,肯定也会超过target。
3. 算法复杂度分析
3.1 时间复杂度
回溯算法的时间复杂度通常较难精确计算,但可以估算:
- 最坏情况下,每个数字都可能被多次使用,时间复杂度接近O(N^(T/M + 1)),其中:
- N是candidates长度
- T是target值
- M是candidates中的最小值
例如,candidates = [2,3,6,7], target = 7时:
- N = 4
- T = 7
- M = 2 时间复杂度约为O(4^(7/2 + 1)) = O(4^4.5)
3.2 空间复杂度
空间复杂度主要取决于递归调用栈的深度:
- 最坏情况下,递归深度为T/M(全部选最小的数)
- 每次递归需要存储当前组合
- 总体空间复杂度为O(T/M)
4. 常见变种与扩展
4.1 组合总和II(不可重复使用元素)
LeetCode 40题是组合总和的变种,要求每个数字在每个组合中只能使用一次。解法需要:
- 排序数组
- 跳过重复元素
- 递归时i+1而不是i
function combinationSum2(candidates, target) { const result = []; candidates.sort((a, b) => a - b); function backtrack(start, current, sum) { if (sum === target) { result.push([...current]); return; } for (let i = start; i < candidates.length; i++) { if (i > start && candidates[i] === candidates[i-1]) continue; // 跳过重复 if (sum + candidates[i] > target) break; current.push(candidates[i]); backtrack(i + 1, current, sum + candidates[i]); // i+1而不是i current.pop(); } } backtrack(0, [], 0); return result; }4.2 组合总和III(指定组合长度)
LeetCode 216题要求找出所有相加之和为n的k个数的组合,且满足:
- 只使用数字1到9
- 每个数字最多使用一次
function combinationSum3(k, n) { const result = []; function backtrack(start, current, sum) { if (current.length === k && sum === n) { result.push([...current]); return; } if (current.length >= k || sum >= n) return; for (let i = start; i <= 9; i++) { current.push(i); backtrack(i + 1, current, sum + i); current.pop(); } } backtrack(1, [], 0); return result; }5. 实战技巧与调试方法
5.1 调试回溯算法
回溯算法调试的关键点:
- 打印递归树:在每个递归调用开始和结束时打印当前状态
- 跟踪选择与撤销选择:确保每次递归后状态能正确恢复
- 使用小测试用例:先用简单的例子验证基本逻辑
示例调试代码:
function combinationSum(candidates, target) { const result = []; function backtrack(start, current, sum, depth = 0) { console.log(`${' '.repeat(depth)}> start=${start}, current=[${current}], sum=${sum}`); if (sum === target) { result.push([...current]); console.log(`${' '.repeat(depth)}Found: [${current}]`); return; } if (sum > target) { console.log(`${' '.repeat(depth)}Exceeded`); return; } for (let i = start; i < candidates.length; i++) { console.log(`${' '.repeat(depth)}Trying ${candidates[i]}`); current.push(candidates[i]); backtrack(i, current, sum + candidates[i], depth + 1); current.pop(); console.log(`${' '.repeat(depth)}Backtracked`); } } backtrack(0, [], 0); return result; }5.2 性能优化建议
- 提前排序:如前所述,排序后可以提前终止循环
- 记忆化:对于重复子问题可以考虑记忆化,但组合总和问题通常不需要
- 迭代实现:可以用栈模拟递归,减少函数调用开销
- 并行处理:对于大规模问题,可以考虑并行处理不同分支
6. JavaScript特定优化
6.1 避免数组拷贝
在将current加入result时,我们使用了[...current]创建新数组。对于大数组,这会有性能开销。可以改为在找到解时复制:
if (sum === target) { result.push(current.slice()); // 比[...current]稍高效 return; }6.2 尾递归优化
虽然JavaScript引擎不一定都支持尾调用优化,但可以尝试将回溯写成尾递归形式:
function combinationSum(candidates, target) { const result = []; candidates.sort((a, b) => a - b); function backtrack(start, current, sum) { if (sum === target) { result.push(current.slice()); return; } for (let i = start; i < candidates.length; i++) { if (sum + candidates[i] > target) break; current.push(candidates[i]); backtrack(i, current, sum + candidates[i]); current.pop(); } } backtrack(0, [], 0); return result; }6.3 使用迭代代替递归
为了避免递归深度过大导致的栈溢出,可以用显式栈实现迭代版本:
function combinationSum(candidates, target) { const result = []; candidates.sort((a, b) => a - b); const stack = [{start: 0, current: [], sum: 0}]; while (stack.length) { const {start, current, sum} = stack.pop(); if (sum === target) { result.push([...current]); continue; } for (let i = start; i < candidates.length; i++) { if (sum + candidates[i] > target) break; stack.push({ start: i, current: [...current, candidates[i]], sum: sum + candidates[i] }); } } return result; }7. 与其他算法对比
7.1 回溯 vs 动态规划
动态规划通常用于求解"有多少种方式"这类问题,而回溯用于找出"所有具体方案"。
对于组合总和问题:
- 动态规划解法可以计算组合数量,但难以记录具体组合
- 回溯算法可以找到所有组合,但在组合数量多时效率较低
7.2 回溯 vs BFS
广度优先搜索也可以解决组合问题,但通常需要更多内存存储中间状态:
function combinationSumBFS(candidates, target) { const result = []; const queue = [{start: 0, current: [], sum: 0}]; while (queue.length) { const {start, current, sum} = queue.shift(); if (sum === target) { result.push(current); continue; } if (sum > target) continue; for (let i = start; i < candidates.length; i++) { queue.push({ start: i, current: [...current, candidates[i]], sum: sum + candidates[i] }); } } return result; }BFS的优势在于可以找到最短解(虽然对组合总和问题不适用),但空间复杂度通常更高。
8. 实际应用场景
组合总和算法在实际中有多种应用:
- 购物车优惠组合:给定不同面值的优惠券,找出所有能达到满减条件的组合
- 资源分配:将有限资源分配给多个项目,每个项目有最小资源需求
- 密码破解:尝试不同字符组合来匹配目标哈希值
- 游戏设计:计算玩家使用不同技能组合达到特定伤害值的方式
以购物车优惠为例:
// 假设有以下优惠券:5元、10元、20元、50元 // 用户希望凑满100元减20元的优惠 const coupons = [5, 10, 20, 50]; const target = 100; const validCombinations = combinationSum(coupons, target); console.log('可用优惠券组合:', validCombinations);9. 常见错误与修正
9.1 重复组合问题
初学者常犯的错误是产生重复组合,如[2,2,3]和[2,3,2]被视为不同解。修正方法是传递start参数,确保总是从当前或之后的元素开始选择。
错误实现示例:
// 错误:会产生重复组合 function combinationSumWrong(candidates, target) { const result = []; function backtrack(current, sum) { if (sum === target) { result.push([...current]); return; } for (let i = 0; i < candidates.length; i++) { // 总是从0开始 if (sum + candidates[i] > target) continue; current.push(candidates[i]); backtrack(current, sum + candidates[i]); current.pop(); } } backtrack([], 0); return result; }9.2 无限递归问题
如果没有正确控制递归终止条件,可能导致无限递归或栈溢出。确保:
- 有明确的成功终止条件(sum === target)
- 有明确的失败终止条件(sum > target)
- 递归调用能逐步接近终止条件
9.3 状态恢复问题
回溯算法的关键在于每次递归后要恢复状态。常见错误是忘记pop:
// 错误:忘记pop会导致状态混乱 function combinationSumWrong2(candidates, target) { const result = []; function backtrack(start, current, sum) { if (sum === target) { result.push([...current]); return; } for (let i = start; i < candidates.length; i++) { current.push(candidates[i]); backtrack(i, current, sum + candidates[i]); // 忘记 current.pop(); } } backtrack(0, [], 0); return result; }10. 进阶挑战与扩展思考
10.1 大规模数据优化
当candidates很大或target很大时,基础回溯算法可能不够高效。可以考虑:
- 预处理:过滤掉大于target的候选数
- 分治:将问题分解为子问题
- 并行处理:使用Web Worker并行处理不同分支
10.2 非整数扩展
如果candidates包含非整数(如有小数),需要考虑浮点数精度问题:
function combinationSumFloat(candidates, target, precision = 1e-6) { const result = []; function backtrack(start, current, sum) { if (Math.abs(sum - target) < precision) { result.push([...current]); return; } if (sum > target + precision) return; for (let i = start; i < candidates.length; i++) { current.push(candidates[i]); backtrack(i, current, sum + candidates[i]); current.pop(); } } backtrack(0, [], 0); return result; }10.3 组合总和IV
LeetCode 377题是组合总和的另一种变种,考虑顺序不同的序列是不同的组合(实际上是排列问题)。这类问题通常更适合用动态规划解决。
function combinationSum4(nums, target) { const dp = new Array(target + 1).fill(0); dp[0] = 1; for (let i = 1; i <= target; i++) { for (const num of nums) { if (i >= num) { dp[i] += dp[i - num]; } } } return dp[target]; }