DFS 搜索 + 回溯

94 阅读4分钟

一、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);
        }
    }

🧠 算法思路(整体框架)

  1. DFS 搜索空间 每个数字有两种决策:

    • 跳过(不选)
    • 选中(消耗 target,继续从同一位置开始,允许重复)
  2. 剪枝

    • target < 0:提前结束(当前代码隐藏了剪枝,通过 if (target - candidates[idx] >= 0))。
    • idx == candidates.length:越界剪枝。
  3. 回溯模板

    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);
        }
    }

🧠 算法整体思路

  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], ...)  // 允许重复
            撤销选择
    
  2. 允许重复 vs 不允许重复

    • 允许重复:递归传 i
    • 不允许重复:递归传 i+1(LeetCode 40)
  3. 剪枝优化点

    • 由于 candidates 已升序,可在 for 循环里加 if (sum + candidates[i] > target) break; 提前终止后续无效循环(当前代码没写,可自行添加)。

为什么需要回溯?

✅ 一句话先回答

回溯是为了“撤销”上一次的选择,让路径回到父状态,从而能够继续尝试其它可能,避免状态污染。


🎯 为什么会“状态污染”?

DFS / 回溯 / 递归树 中,所有递归调用共享同一个可变对象(如 List<Integer> combine)。 如果不撤回刚才放进去的元素,下一次循环就会基于错误的历史路径继续搜索,导致:

  1. 重复元素(不该出现的数字)
  2. 路径长度爆炸(无限递归)
  3. 结果错误(出现重复组合或缺失组合)

🔍 举个最小例子

假设 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);
        }
    }

✅ 改动点一览

位置改动内容目的
combinationSum2Arrays.sort(candidates)便于去重与剪枝
dfs2 循环内if (i > index && candidates[i] == candidates[i-1]) continue同一层跳过重复数字
dfs2 循环内if (sum + candidates[i] > target) break提前剪枝
递归调用保持 i + 1