一、DFS 搜索 + 回溯
Leetcode39:组合总和(可重复)
做法一:
DFS 回溯解法 的一个简化版本。
public static void dfs1(int[] candidates,int target,List<List<Integer>> ans,List<Integer> combine,int idx){
//越界剪枝:所有数字都考虑完了,直接结束
if (idx == candidates.length){
return;
}
//找到一个合法组合,将其深拷贝后加入结果。
if (target == 0){
ans.add(new ArrayList<Integer>(combine));
return;
}
//分支1:不拿 candidates[idx],直接考察下一个数字。
//注意:因为允许重复,所以下一层仍可从 idx+1 开始(不是 i+1)。
//直接跳过
dfs1(candidates,target,ans,combine,idx+1);
/*分支2:拿 candidates[idx],消耗掉一部分 target。
因为可重复取,下一层递归仍传 idx(不是 idx+1)。
回溯:撤销选择,回到父状态。*/
//选择当前数
if (target - candidates[idx] >= 0){
combine.add(candidates[idx]);
dfs1(candidates,target-candidates[idx],ans,combine,idx);
combine.remove(combine.size()-1);
}
}
🧠 算法思路(整体框架)
-
DFS 搜索空间 每个数字有两种决策:
- 跳过(不选)
- 选中(消耗 target,继续从同一位置开始,允许重复)
-
剪枝
target < 0:提前结束(当前代码隐藏了剪枝,通过if (target - candidates[idx] >= 0))。idx == candidates.length:越界剪枝。
-
回溯模板
if (终止条件) 收集结果 for(选择列表) 做选择; def(下一状态); 撤销选择;本代码用两个显式递归分支代替
for循环,本质相同。
做法二:
第二种 DFS 回溯模板:
“for 循环 + 递归” 写法(允许重复取元素)。
核心思路:每次递归从下标 i 开始(而不是 i+1),从而实现“可重复”。
public static void dfs2(List<List<Integer>> ans,List<Integer> combine, int index,int sum,int[] candidates,int target){
/*
剪枝边界:
sum == target → 找到合法组合
sum > target → 超了,直接剪枝返回
*/
if (sum >= target) {
if (sum == target) {
ans.add(new ArrayList<>(combine));
}
return;
}
/*
for 循环枚举从 index 到末尾的所有数字
关键:递归传 i 而非 i+1,允许再次选同一数字
回溯:撤销 candidates[i],恢复状态
*/
for (int i = index; i < candidates.length ; i++) {
combine.add(candidates[i]);
dfs2(ans,combine,i,sum + candidates[i],candidates,target);
combine.remove(combine.size() - 1);
}
}
🧠 算法整体思路
-
DFS 框架
dfs(ans, combine, start, sum, candidates, target): if sum == target → 收集结果 if sum > target → 剪枝 for i = start .. end 选 candidates[i] dfs(..., i, sum + candidates[i], ...) // 允许重复 撤销选择 -
允许重复 vs 不允许重复
- 允许重复:递归传
i - 不允许重复:递归传
i+1(LeetCode 40)
- 允许重复:递归传
-
剪枝优化点
- 由于
candidates已升序,可在 for 循环里加if (sum + candidates[i] > target) break;提前终止后续无效循环(当前代码没写,可自行添加)。
- 由于
为什么需要回溯?
✅ 一句话先回答
回溯是为了“撤销”上一次的选择,让路径回到父状态,从而能够继续尝试其它可能,避免状态污染。
🎯 为什么会“状态污染”?
在 DFS / 回溯 / 递归树 中,所有递归调用共享同一个可变对象(如 List<Integer> combine)。
如果不撤回刚才放进去的元素,下一次循环就会基于错误的历史路径继续搜索,导致:
- 重复元素(不该出现的数字)
- 路径长度爆炸(无限递归)
- 结果错误(出现重复组合或缺失组合)
🔍 举个最小例子
假设 candidates = [2,3,5],target = 8。
① 不撤销的后果
combine.add(2); // combine = [2]
dfs(...); // 递归下去 → [2,2,2,2] 得到 8
// 回来时 combine 仍是 [2,2,2,2]
combine.add(3); // 错误!其实应该是 [2,2,2,3] 而不是 [2,2,2,2,3]
路径被“脏数据”污染,后续搜索全乱套。
② 正确:回溯撤销
combine.add(2); // 选 2
dfs(...); // 递归下去
combine.removeLast();// 撤销 2 → combine 回到上一层状态 [2,2,2]
combine.add(3); // 现在可以安全地选 3,得到 [2,2,2,3]
每次递归调用完都把刚才的选择“擦掉”,保证兄弟分支看到的是干净的父状态。
✅ 一句话总结
回溯 = “试错后擦干净黑板再写下一道题”,没有撤销就无法在共享的可变对象上正确枚举所有可能。
Leetcode39:组合总和(不可重复)
对上面的方法二进行一些小改动
public static List<List<Integer>> combinationSum(int[] candidates, int target) {
List< List<Integer> > ans = new ArrayList<>();
List<Integer> combine = new ArrayList<>();
//可重复
dfs1(candidates,target,ans,combine,0);
dfs2(ans, combine, 0, 0, candidates, target);
//不可重复
Arrays.sort(candidates); // ① 排序,为了去重
dfs3(ans, combine, 0, 0, candidates, target);
return ans;
}
// ② 仅改动 dfs2:加“同层去重” + “提前剪枝”
private static void dfs3(List<List<Integer>> ans,
List<Integer> combine,
int index,
int sum,
int[] candidates,
int target) {
if (sum >= target) {
if (sum == target) {
ans.add(new ArrayList<>(combine));
}
return;
}
for (int i = index; i < candidates.length ; i++) {
// ③ 同层去重:i > index 说明是同一层的重复数字
if (i > index && candidates[i] == candidates[i - 1]) continue;
// ④ 提前剪枝:排序后若当前数 > 剩余目标,后面更大,直接 break
if (sum + candidates[i] > target) break;
combine.add(candidates[i]);
// ⑤ 同一个数不可重复使用 i->i+1
dfs2(ans,combine,i+1,sum + candidates[i],candidates,target);
combine.remove(combine.size() - 1);
}
}
✅ 改动点一览
| 位置 | 改动内容 | 目的 |
|---|---|---|
combinationSum2 | 加 Arrays.sort(candidates) | 便于去重与剪枝 |
dfs2 循环内 | 加 if (i > index && candidates[i] == candidates[i-1]) continue | 同一层跳过重复数字 |
dfs2 循环内 | 加 if (sum + candidates[i] > target) break | 提前剪枝 |
| 递归调用 | 保持 i + 1 |