blog/content/post/算法题/dfs回溯.md
2025-05-08 01:00:35 +08:00

14 KiB
Raw Blame History

title description date slug categories tags math
dfs回溯 dfs回溯问题组合问题、排列问题、回文子串分割的所有方案 2025-05-07T01:23:00+08:00 dfs回溯
算法题
dfs
回溯
递归
算法
回文串
true

组合问题

问题描述

给定一个无重复元素的数组num和一整数m,返回所有可能的m个数的组合。

代码实现

咱们的目标是用深度优先搜索DFS配合回溯策略来找出所有可能的数字组合。

combination 方法都干了啥:

  1. 准备一个result列表: 这个列表 (List<List<Integer>>) 用来装所有找出来的组合。每个组合它自己也是一个整数列表。
  2. 再准备一个path列表: 这个列表 (List<Integer>) 临时记录咱们在DFS过程中当前正在凑的这个组合长啥样。
  3. 调用dfs干活: dfs是真正干搜索和凑组合的递归函数。
    • num: 就是输入的那个原始数组。
    • m: 咱们要凑的组合里有几个数。
    • 0: 从数组的第0个位置开始选数。
    • path: 当前凑到一半的组合。
    • result: 最终装结果的那个列表。
  4. 返回result: 等dfs跑完了,result里面就装满了所有大小为m的组合,把它返回就行。

dfs (深度优先搜索) 是回溯算法的精髓所在。

  1. 啥时候停呢?(递归的出口):

    • if(path.size()==m): 如果当前path列表里的数字个数,正好等于咱们想要的组合大小m,那说明一个组合凑齐了。
    • result.add(new ArrayList<Integer>(path)): 赶紧把这个凑好的path存到result里。特别注意:这里一定要 new ArrayList<>(path) 创建一个新的列表再存进去。为啥呢?因为path这个列表在后面的回溯步骤里还会被修改(删掉元素)。如果不创建副本,那result里存的所有组合都会指向同一个不断变化的path对象,最后结果就全乱套了。
    • return;: 找到了一个组合,这条递归的路就走到头了,返回。
  2. 递归探索和"反悔"(回溯)的过程:

    • for(int i=start; i<num.length; i++): 这个循环呢,就是从数组numstart位置开始,一个一个往后看。start参数有啥用?
      • 它保证在凑一个组合的时候,同一个数字不会被重复用。
      • 它还能避免生成重复的组合。打个比方,如果我们已经考虑过以num[0]开头的组合了,那后面选数的时候,就没必要再回头去选num[0]之前的数来凑新组合了。因为那样凑出来的组合,只是顺序不一样(比如[1,2][2,1],假设原数组是[1,2,3,...]),但组合本身是不讲究元素顺序的。
    • path.add(num[i]): 做选择:先把当前看到的num[i]这个数加到path里,表示"我选它了!"。
    • dfs(num, m, i+1, path, result): 继续往下走:递归调用dfs,让它接着凑。
      • 这里的i+1是关键!它告诉下一层dfs"你从我选的这个num[i]下一个位置开始选数吧。" 这样做能确保组合里的数是按原数组里的顺序(或者说,索引是递增的)选出来的,自然也就避免了像[1,2][2,1]这样的重复组合。
    • path.remove(path.size()-1): 反悔操作(回溯):当上面那行dfs调用结束返回了,说明从num[i]开始、包含m个数的所有组合都已经找遍了。这时候,就得把刚才加入pathnum[i]给删掉。这个操作就像是"撤销"了刚才的选择,程序退回到选num[i]之前的状态。这样,for循环的下一次迭代(i++)才能去尝试选num[i]后面的其他数字,从而探索其他的组合可能性。

举个例子看看咋工作的: 假设 num = [1, 2, 3], m = 2

  1. combination([1,2,3], 2) 会调用 dfs([1,2,3], 2, 0, [], result)
  2. dfs(start=0):
    • i=0 (选元素 1):
      • path.add(1),现在 path = [1]
      • 调用 dfs([1,2,3], 2, 1, [1], result)
        • dfs(start=1):
          • i=1 (选元素 2):
            • path.add(2),现在 path = [1, 2]
            • path.size() == m (2 == 2) 满足,于是 result.add([1,2])。这条路结束,返回。
            • path.removeLast()path 变回 [1] (回溯)
          • i=2 (选元素 3):
            • path.add(3),现在 path = [1, 3]
            • path.size() == m (2 == 2) 满足,于是 result.add([1,3])。这条路结束,返回。
            • path.removeLast()path 变回 [1] (回溯)
          • i 到头了 (i=2 是最后一个可选的)。dfs(start=1)结束,返回。
      • path.removeLast()path 变回 [] (回溯)
    • i=1 (选元素 2):
      • path.add(2),现在 path = [2]
      • 调用 dfs([1,2,3], 2, 2, [2], result)
        • dfs(start=2):
          • i=2 (选元素 3):
            • path.add(3),现在 path = [2, 3]
            • path.size() == m (2 == 2) 满足,于是 result.add([2,3])。这条路结束,返回。
            • path.removeLast()path 变回 [2] (回溯)
          • i 到头了。dfs(start=2)结束,返回。
      • path.removeLast()path 变回 [] (回溯)
    • i=2 (选元素 3):
      • path.add(3),现在 path = [3]
      • 调用 dfs([1,2,3], 2, 3, [3], result)
        • dfs(start=3):
          • path.size() (1) 不等于 m (2)。
          • for 循环条件 i < num.length (3 < 3) 不满足,循环不执行。dfs(start=3)结束,返回。
      • path.removeLast()path 变回 [] (回溯)
    • i 到头了。dfs(start=0)结束,返回。
  3. combination 方法返回 result,里面装着 [[1,2], [1,3], [2,3]]
// 组合
public static List<List<Integer>> combination(int[] num, int m){
    List<List<Integer>> result = new ArrayList<>();
    List<Integer> path = new ArrayList<>();
    dfs(num,m,0,path,result);
    
    return result;
    
}

// 深度优先搜索
public static void dfs(int[] num, int m, int start, List<Integer> path, List<List<Integer>> result) {
    if(path.size()==m) {
        result.add(new ArrayList<Integer>(path));
        return;
    }
    
    for(int i=start;i<num.length;i++) {
        path.add(num[i]);
        dfs(num, m, i+1, path, result);
        path.remove(path.size()-1);
    }
}

复杂度分析

  • 时间复杂度:O(C(n,m)\cdot m)
  • 空间复杂度:O(m)

排列问题

问题描述

给定一个无重复元素的数组num和一整数m,返回所有可能的m个数的排列。

代码实现

这里与组合数不同我们不需要start参数因为排列问题中每个位置的元素都可以是任意一个未被选过的元素。作为替代我们使用一个visited数组来跟踪每个元素是否已经被使用过。

主要的步骤和组合数一样,都是先在path中添加元素,然后递归调用backtrack,最后回溯,从path中删除元素。不过注意在添加元素前,先把visited数组中对应的元素设置为true,表示这个元素已经被使用过,然后回溯时再把它设置为false,表示这个元素没有被使用过。

// 排列
public static List<List<Integer>> permutation(int[] num, int m){
    boolean[] visited = new boolean[num.length];
    List<Integer> path = new ArrayList<>();
    List<List<Integer>> result = new ArrayList<>();
    backtrack(num,m,visited,path,result);
    return result;
}

// 回溯
public static void backtrack(int[] num, int m, boolean[] visited, List<Integer> path, List<List<Integer>> result) {
    if(m==path.size()) {
        result.add(new ArrayList<>(path));
        return;
    }
    for(int i=0;i<num.length;i++) {
        if(visited[i]) continue;
        visited[i] = true;
        path.add(num[i]);
        backtrack(num, m, visited, path, result);
        path.remove(path.size()-1);
        visited[i] = false;
    }
}

复杂度分析

  • 时间复杂度:O(P(n,m)\cdot m)
  • 空间复杂度:O(m)

经典例题:分割回文串

问题描述

给定一个字符串s,返回所有可能的分割方式,使得每个子串都是回文串。这里认为单个字符也是回文串。

代码实现

这个问题咱们同样用回溯法或者叫深度优先搜索DFS来解决。目标就是把一个字符串 s 切割成好几段,而且每一段都必须是个回文串。回溯算法呢,就是通过在字符串的不同位置尝试"剪一刀",来找出所有可能的切割方案。

主要的思路是这样的:

  • 想象咱们现在站在字符串的某个位置 start
  • 从这个 start 位置开始,往后瞅,试试切下来一段子串。
  • 看看切下来的这段子串是不是回文的。
  • 要是回文的,太好了!把它记到咱们当前的路径 path 里。然后呢?对剩下还没切的那部分字符串,从刚才切完的地方往后,重复一样的操作。
  • 等从更深一层的探索回来之后,就得"反悔"一下,把刚才记到 path 里的那段子串删掉。这样才能试试别的切法,比如从同一个 start 位置开始,切一个更长点的回文串出来。

下面来看看 dfs 函数和它的小助手 isPalindrome 是怎么干活的:

  1. dfs(String s, int start, List<String> path, List<List<String>> results) 这个函数是核心

    • s:就是咱们要切的那个原字符串。
    • start:这是个数字,告诉我们当前要从字符串 s 的第几个字符开始找下一段回文子串。
    • path:一个临时的 List<String>,像个小本本,记录着咱们当前这一趟切下来的一串回文子串是哪些。
    • results:一个 List<List<String>>,这是最终的成果篮子,所有成功的切法(每一套切法都是一个回文子串列表)都会装到这里面。
  2. 啥时候算切完了呢?(递归的出口)

    • start 这个数字等于字符串 s 的总长度 s.length() 的时候,就说明咱们已经从头到尾把整个字符串 s 都成功切成了回文串啦!
    • 这时候,path 小本本里记的就是一套完整的、成功的切割方案。赶紧把它存到 results 成果篮子里。特别注意:这里一定要 new ArrayList<>(path) 创建一个新的列表再存进去。为啥呢?因为path这个小本本在后面的回溯步骤里还会被修改(删掉里面的子串)。如果不创建副本,那results里存的所有方案都会指向同一个不断变化的path对象,最后结果就全乱套了。
  3. 探索和"反悔"(回溯)的过程是咋样的

    • 函数里有个 for 循环,它会从当前的 start 位置开始,一直到字符串末尾,尝试所有可能的切割点 i
    • 对于每一个可能的切割点 i,咱们就先切出一段子串 substr = s.substring(start, i + 1)。这段 substr 就是从当前 start 位置开始,到 i 位置(包括 i 本身)结束的这么一段。
    • 第一步,判断一下是不是回文:喊小助手 isPalindrome(substr) 来帮忙看看这段 substr 是不是个回文串。
    • 如果 substr 确实是回文串,那接下来
      • 做选择 (选它!):把它加到咱们的 path 小本本里:path.add(substr)
      • 继续往下切 (深入探索):递归调用 dfs(s, i + 1, path, results)。注意,新的起始点变成了 i + 1,因为 substr 已经把从 starti 这些字符给占了,下一次就从 i 的下一个位置开始切。
      • 反悔操作 (撤销选择/回溯):当上面那行 dfs 调用结束返回了,说明基于当前这个 substr 开头的所有后续切法都已经试完了。为了能试试其他的可能性(比如,从同一个 start 位置开始,尝试切一个更长的回文子串;或者,在更早的切割步骤里,选择一个不一样的初始子串),咱们就得把刚才加入 path 的那个 substr 给删掉:path.remove(path.size() - 1)。这个操作就像是"撤销"了刚才的选择,程序退回到选 substr 之前的状态。
  4. isPalindrome(String s)

    • 这个函数很简单,就是专门判断一个字符串 s 是不是回文的。
    • 我们用"双指针"的方法:一个指针指着字符串的开头,另一个指着字符串的末尾。两个指针同时往中间跑,一边跑一边比较它们指着的字符是不是一样。如果跑到中间或者相遇了,所有对应位置的字符都一样,那这个字符串就是回文的。
    • (题目通常会说,空字符串或单个字符的串也算回文——代码里也通常会处理这种情况)。

通过这种"一路切下去,不合适就退回来换条路再切"的深度优先搜索和回溯机制,这个算法就能把所有可能的切割方式都系统地试一遍,然后把那些每一段都是回文串的成功方案都收集起来。

public static List<List<String>> divide(String s){
    List<String> path = new ArrayList<>();
    List<List<String>> results = new ArrayList<>();
    dfs(s, 0, path, results);
    return results;
}

public static void dfs(String s, int start, List<String> path, List<List<String>> results) {
    if(start == s.length()) { // 此时start应该已经移到字符串末字符的下一个位置了所以不是length-1
        results.add(new ArrayList<>(path)); // 必须添加一个副本而不是引用本身
    }
            
    for(int i=start;i<s.length();i++) {
        String substr = s.substring(start, i+1);
        if(isPalindrome(substr)) {
            path.add(substr);
            dfs(s, i+1, path, results);
            path.remove(path.size()-1);
        }
    }
    
    
}

public static boolean isPalindrome(String s) {
    // 空字符串
    if (s == null || s.length() == 0) {
            return true; 
    }
    int start = 0, end = s.length() - 1;
    while (start < end) {
        if (s.charAt(start) != s.charAt(end)) {
            return false;
        }
        start++;
        end--;
    }
    return true;
}

复杂度分析

  • 时间复杂度:通常O(2^n)
  • 空间复杂度:$O(n)$、$O(n^2)$,取决于字符串的存储方式