拆解 LeetCode 经典回溯题------39. 组合总和,这道题是回溯算法的入门必练题目,核心考察「无重复组合」与「元素可重复选取」的处理逻辑,学会这道题,能轻松应对一类回溯组合问题。
话不多说,先看题目本身,帮大家理清需求、避开陷阱。
一、题目解读:明确需求与边界
题目给出两个核心输入:无重复元素的整数数组 candidates,和目标整数 target。要求找出 candidates 中,所有能使数字和为 target 的不同组合,返回格式为列表,且组合顺序无要求。
这里有两个关键细节,也是解题的核心:
-
「同一个数字可无限制重复选取」:比如 candidates 有 2,target 为 4,那么 [2,2] 是合法组合。
-
「不同组合的判定」:至少一个数字的选取数量不同,即为不同组合。比如 [2,3] 和 [3,2] 视为同一组合(题目允许任意顺序返回),而 [2,2,3] 和 [2,3,3] 是不同组合。
另外题目给出约束:组合数少于 150 个,无需考虑极端情况下的性能优化,专注于回溯逻辑即可。
二、解题思路:为什么用回溯法?
这道题的本质是「从数组中挑选元素,允许重复选,凑出目标和」,属于「组合搜索」问题------我们需要遍历所有可能的选取方式,找到符合条件的组合,而回溯法正是处理这类「多路径搜索、需回退」问题的最优思路。
回溯法的核心逻辑的是「试探-回退-再试探」,可以类比为「走迷宫」:
-
试探:挑选一个元素,加入临时组合,计算当前和;
-
判断:如果当前和超过 target,说明此路径无效,直接回退;如果等于 target,将临时组合加入结果集,再回退;
-
回退:移除最后一个加入的元素,尝试下一个元素,继续试探。
这里有个关键优化点:如何避免出现重复组合(比如 [2,3] 和 [3,2])?
答案是「固定选取顺序」------让组合中的元素「非递减」排列(或非递增),具体做法是:在递归时,从当前元素的索引开始遍历,不再回头遍历前面的元素。这样就能保证,每个组合的元素顺序一致,不会出现重复。
三、完整代码与逐行解析
先给出完整可运行的 TypeScript 代码(与题目给出的代码一致,重点解析核心逻辑):
typescript
function combinationSum(candidates: number[], target: number): number[][] {
const res: number[][] = []; // 存储最终结果集
// 回溯函数:start=当前开始遍历的索引,temp=临时组合,sum=当前组合的和
const dfs = (start: number, temp: number[], sum: number) => {
// 终止条件1:当前和超过target,无效路径,直接返回
if (sum > target) {
return;
}
// 终止条件2:当前和等于target,将临时组合存入结果集(浅拷贝,避免引用污染)
else if (sum === target) {
res.push([...temp]);
return;
}
// 遍历:从start开始,避免重复组合
for (let i = start; i < candidates.length; i++) {
temp.push(candidates[i]); // 试探:加入当前元素
dfs(i, temp, sum + candidates[i]); // 递归:继续从i开始(允许重复选当前元素)
temp.pop(); // 回退:移除最后一个元素,尝试下一个选项
}
}
dfs(0, [], 0); // 初始调用:从索引0开始,临时组合为空,当前和为0
return res;
};
逐行解析核心细节
1. 结果集与回溯函数定义
定义res 数组存储最终的组合列表,回溯函数 dfs 接收三个参数:
-
start:当前开始遍历的索引,核心作用是「避免重复组合」,确保每次递归只从当前元素及之后的元素选取; -
temp:临时组合,用于存储当前正在试探的组合; -
sum:当前临时组合的数字和,用于快速判断是否达到目标。
2. 终止条件(回溯的"出口")
回溯函数必须有明确的终止条件,否则会陷入无限递归:
-
当
sum > target:当前组合的和已经超过目标,再继续添加元素只会更大,直接返回(剪枝,减少无效遍历); -
当
sum === target:当前组合符合要求,将其存入结果集。注意这里用[...temp]浅拷贝,因为 temp 是引用类型,后续会被修改,不拷贝会导致结果集中的组合被覆盖。
3. 遍历与回溯核心逻辑
for 循环从 start 开始遍历 candidates 数组,这是避免重复组合的关键:
-
temp.push(candidates[i]):将当前元素加入临时组合,进行「试探」; -
dfs(i, temp, sum + candidates[i]):递归调用,注意这里的 start 参数是i而非i+1------因为允许重复选取当前元素,所以下一次递归仍可以从当前元素开始; -
temp.pop():「回退」操作,移除最后一个加入的元素,让循环继续尝试下一个元素,实现"回溯"。
4. 初始调用
dfs(0, [], 0):初始状态下,从数组索引 0 开始遍历,临时组合为空,当前和为 0,正式启动回溯过程。
四、关键易错点提醒
这道题看似简单,但新手很容易踩坑,重点注意以下3点:
-
避免重复组合:必须从
start开始遍历,不能从 0 开始,否则会出现 [2,3] 和 [3,2] 这样的重复组合; -
临时组合浅拷贝:存入结果集时,一定要用
[...temp]拷贝,否则后续temp.pop()会修改结果集中的组合; -
剪枝优化:当
sum > target时直接返回,避免无效递归,提升效率(虽然题目约束组合数少于150,但剪枝是回溯题的必备思维)。
五、示例验证与拓展思考
示例验证
假设输入:candidates = [2,3,6,7], target = 7
按照代码逻辑,最终返回结果为 [[2,2,3], [7]],完全符合题目要求:
-
2,2,3\]:2+2+3=7,元素可重复选取;
拓展思考
这道题的变种很多,比如:
-
如果 candidates 有重复元素,如何避免重复组合?(LeetCode 40. 组合总和 II)
-
如果限制每个元素只能选取一次,如何修改代码?
核心思路不变,只需调整遍历逻辑(比如去重、start 改为 i+1)。
六、总结
LeetCode 39. 组合总和的核心是「回溯法 + 剪枝 + 避免重复组合」,解题关键在于:
-
用回溯法遍历所有可能的组合,实现「试探-回退」;
-
通过
start参数固定选取顺序,避免重复组合; -
及时剪枝(sum > target 时返回),提升效率。
这道题是回溯算法的入门经典,建议大家亲手敲一遍代码,修改参数(比如改变 candidates 和 target),观察回溯过程中的 temp 和 sum 变化,就能彻底理解回溯的逻辑。