从《两数之和》到深度优先搜索

深度优先搜索算法(Depth First Search,DFS ),简称 深搜 ,源自于图论,用于遍历或搜索树或图。 该算法在每达到一个节点后,会选择一条边并尽可能深地搜索树,当一个节点所有边己被探寻过,搜索将回溯到上一个节点选择另外边继续搜索。

下图展示了从节点 A 出发搜索 所有路径 过程。

先看下,力扣上面一道经典题目 ------ 两数之和

给定一个整数数组 nums 和一个整数目标值 target ,请你在该数组中找出 和为目标值 target 的那 两个 整数,并返回它们的数组下标。

对于这道题目,可以很快想到用两重循环分别枚举这两个整数下标,如果循环体中,这两个数和为 target,则其即为答案。

java 复制代码
public int[] twoSum(int[] nums, int target) {

    for (int i = 0; i < nums.length; i++) {  // 枚举第一个数
        for (int j = i+1; j < nums.length; j++) { // 枚举第二个数,为了避免重复第二个数下标大于第一个数下标
            if (nums[i] + nums[j] == target) {
                return new int[]{i, j};
            }
        }
    }
    return new int[]{};
}

当然,三数之和、四数之和都可以通过类似方法求解,多组合一个数就多一重循环嵌套。

如果此题再给定一个正整数 n ,并求在 nums 数组中找出 和为目标值 targetn 个整数呢?

难道写 n 重循环? 肯定是不现实的。那怎么办呢?

回顾下上面代码,我们用一重循环枚举一个数,如果多个数组合的话,就需要用多重循环嵌套,这样做比较直观易懂,但是也有一个明显缺点 ------ 循环嵌套层数在代码编写时就已经确定,程序运行时无法修改。

为了解决这个问题,我们考虑把枚举一个数的 单重循环定义到函数中,并在该函数循环中递归调用去枚举下一个数。这样,在函数循环体中发生的递归调用相当于循环嵌套,并且,我们可以用递归终止条件来动态控制函数递归深度,从而实现动态控制组合数量。

java 复制代码
// 在 nums 数组中找出和为目标值 target 的 n 个整数
public int[] nSum(int[] nums, int target, int n) {

    int[] ans = new int[n];  // 答案
    rec(-1, 0, nums, target, n, ans);
    return ans;
}

// 该函数通过循环遍历 nums 数组枚举第 k 个数值
// 参数 pre 表示, 第 k-1 个数选择的是 nums 数组中下标为 pre 的数
// 该函数返回布尔值, 表示对于前面已经选择的 k 个数, 后面能否能找到剩余数与其和为 target
public boolean rec(int pre, int k, int[] nums, int target, int n, int[] ans) { // ①

    // 如果枚举到第 n 个数, 说面前面已经枚举了 [0, n-1] 共 n 个数, 这 n 个数下标保存在 [ans[0], ans[n-1]]中
    // 此时, 需要递归终止,并计算已经枚举的前 n 个数和是否为 target , 以确定该方案是否可行
    if (k == n) {  // ②
        return Arrays.stream(ans).map(i -> nums[i]).sum() == target;
    }
    // 为了避免重复, 第 k 个数从上一个选择的数下标之后开始选
    for (int i = pre + 1; i < nums.length; i++) {  // ③
        ans[k] = i;  // 记录
        // 第 k 个 数选择 nums 数组中下表为 i 的数后, 递归函数去选择第 k+1 个数
        if (rec(i, k + 1, nums, target, n, ans)) { 
            // 如果方案可行, 不需要再枚举后面的数了
            return true;
        }
    }
    return false;
}

可以看出,这种方式,代码复杂了不少,而且不易理解,代码重点部分已圈出,并说明如下

我们发现 rec 函数携带参数比较多,这是由于一次 rec 函数调用枚举一个数,枚举多个数组合则需要函数递归调用,那上一个数枚举情况与枚举下一个数所需材料只能通过 参数 或者 全局变量 告知到下一个递归调用的函数。例如,在 rec 函数中通过 pre 参数告知上一个数选择的是 nums 数组中下标为 pre 的数,本次函数则从 pre 之后开始枚举。 通常,为了避免参数过多,我们把 不变的已知条件或者结果设置到全局变量 ,这样就可以在后续函数直接使用,而不需要通过参数传递,简化代码。例如 rec 函数中,我们就可以在 nSum 函数中把 nums / target / n / ans 设置到全局变量中,这样 rec 函数就只需要携带两个参数。

参数设计往往是这类递归问题中比较重要部分,好的参数设计可能达到事半功倍效果。例如,两数之和 直接使提交如下代码,会有两个测试点超时

java 复制代码
public int[] twoSum(int[] nums, int target) {
    return nSum(nums, target, 2);
}

因为每次搜有数枚举完后再用 Arrays.stream(ans).map(i -> nums[i]).sum() 计算所选数之和,相当于多了一次循环嵌套,这无疑增加了时间开销,整体时间复杂度为 <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( n 3 ) O(n^3) </math>O(n3) 。我们可以在 rec 函数中多增加一个参数 sum ,每当选择一个数后就把该数累加到 sum ,最后只需要判断 sum 是否和 target 相等即可。最后优化代码如下

java 复制代码
int nums[], target, n, ans[];
public int[] nSum(int[] nums, int target, int n) {
    this.ans = new int[n];
    this.nums = nums;
    this.target = target;
    this.n = n;
    rec(-1, 0, 0);
    return ans;
}

public boolean rec(int pre, int k, int sum) {
    if (k == n) {
        return sum == target;
    }
    for (int i = pre + 1; i < nums.length; i++) {
        ans[k] = i;
        if (rec(i, k + 1, sum + nums[i])) {
            return true;
        }
    }
    return false;
}

在循环嵌套代码中,我们在最内层循环中处理每层循环所枚举元素之和是否为 target ,如果肯定,则直接 return 枚举结果。但在函数递归调用中,我们通常把递归终止条件放函数开始。所以在最后一次函数调用枚举完最后一个数后,不会立即在该函数中判断方案可行性,而是 继续进行一次额外的函数递归调用,在该次函数调用中处理结果 ,并返回告知上层主调函数,终止递归。上层主调函数拿到结果后再决定是否还需要继续枚举。

即,如果枚举 n 个数组合, 循环通常会有 n+1 层函数调用,前 n 层函数调用用来枚举这 n 个数,最后一侧函数用来判断前面枚举的 n 个数是否满足条件。

如果在函数中,每次都从数组 nums 头开始枚举,那么可能会造成同一个 nums 数组中同一个数被选择多次和方案重复问题。比如,枚举三个数时,如下六种情况应该属于同一种方案

  1. nums[0], nums[1], nums[2]
  2. nums[0], nums[2], nums[1]
  3. nums[1], nums[0], nums[2]
  4. nums[1], nums[2], nums[0]
  5. nums[2], nums[0], nums[1]
  6. nums[2], nums[1], nums[0]

即此题应属多个数之间 组合 问题,而非排列问题。所以,为了避免重复,对于这几个数在数组中的位置,我们规定一种顺序 ------ 枚举下一个数时,需要在 nums 数组中应位于上一个数之后。这样,上面六种情况只有第一种情况符合条件。

在代码中,我们通过参数 pre 告诉下一次函数调用本次函数选择了 nums 数组那哪个数,下一次枚举时,从该位置之后开始。

像这种规定顺序去重方式应用还有很多,比如《全排列 II》。

下图展示了在 nums 数组 {2, 7, 11, 15} 中选择两个数,且这两个数和为 target=18 时,该方法运行情况。

那这和深搜有什么关系呢?

仔细回顾下,以上方法其实这就是深搜思想的运用。上例中对于每个数可以理解为图论中一个节点,函数中循环枚举 nums 数组相当于枚举图论中节点所有出边,这样就构建出一张 隐式图。当我们选定一个数并递归选择下一个数时,相当于图论中从一个节点的一条边出发达到下一个节点,当循环执行完饭回当上次函数调用是不是就相当于 "一个节点所有边己被探寻过,搜索将回溯到上一个节点选择另外边继续搜索"?

利用该算法,我们可以解决力扣如下题目

  1. 组合

给定两个整数 nk,返回范围 [1, n] 中所有可能的 k 个数的组合。 你可以按 任何顺序 返回答案

java 复制代码
List<List<Integer>> ans = new ArrayList<>();
int n, k, path[]; // 记录单个答案
public List<List<Integer>> combine(int n, int k) {
    this.n = n;
    this.k = k;
    this.path = new int[k];
    dfs(0, 0);
    return ans;
}
public void dfs(int pre, int c){
    if(c == k){
        // 将本次搜索到的答案添加到答案集中 
        ans.add(Arrays.stream(path).boxed().collect(Collectors.toList()));
        return;
    }
    for(int i = pre + 1; i <= n; i++){
        path[c] = i;
        dfs(i, c+1);
    }
}
  1. 电话号码的字母组合

给定一个仅包含数字 2-9 的字符串,返回所有它能表示的字母组合。答案可以按 任意顺序 返回。

java 复制代码
Map<Character, String> dict = new HashMap<>(); // 数字到字母的映射

{
    dict.put('2', "abc");
    dict.put('3', "def");
    dict.put('4', "ghi");
    dict.put('5', "jkl");
    dict.put('6', "mno");
    dict.put('7', "pqrs");
    dict.put('8', "tuv");
    dict.put('9', "wxyz");
}

String digits;
List<String> ans = new ArrayList<>();
Character[] path;

// 枚举 digits 第 k 个数字映射的字母
void dfs(int k) {

    if (k == digits.length()) { // 都已经枚举完成, 将结果添加到结果集并返回
        ans.add(Arrays.stream(path).map(c -> c.toString()).collect(Collectors.joining()));
        return;
    }

    String str = dict.get(digits.charAt(k)); // 第 k 个数字映射的字母构成的字符串
    for (int i = 0; i < str.length(); i++) {
        path[k] = str.charAt(i);
        dfs(k + 1);
    }
}

public List<String> letterCombinations(String digits) {
    this.digits = digits;
    this.path = new Character[digits.length()];
    if (!digits.isEmpty()) {
        dfs(0);
    }
    return ans;
}
  1. 格雷编码

给你一个整数 n ,返回任一有效的 n 位格雷码序列

java 复制代码
int[] ans;
boolean visited[];  //每个数字只能出现一次, visited[i] 表示数字 i 是否已经出现在格雷码序列中

// 求第 k 个格雷码
public boolean dfs(int k, String pre) {
    if (k == 1 << pre.length()) {  // 都已经枚举完成, 直接返回
        return true;
    }

    // 第 k 个格雷码在上一个格雷码基础上变换一个二进制位, 这里枚举上一个格雷码所有二进制位
    for (int i = 0; i < pre.length(); i++) {
        StringBuilder sb = new StringBuilder(pre);
        // 变换后的二进制位
        char c = pre.charAt(i) == '0' ? '1' : '0';
        sb.setCharAt(i, c);  // 变换上一个格雷码第 i 位
        String s = sb.toString();
        int intValue = Integer.parseInt(s, 2); // 新生成的格雷码
        if (visited[intValue] == false) { // ① 新生成的格雷码需要未出现过, 才能加入格雷码序列
            visited[intValue] = true; // 表示该格雷码已经生产
            ans[k] = intValue; // 加入格雷码序列
            boolean found = dfs(k + 1, s);  // 生成下一个格雷码
            if (found) {
                return true;
            }
            visited[intValue] = false;  // ② 回溯
        }

    }
    return false;
}

public List<Integer> grayCode(int n) {
    String st = "0".repeat(n);
    this.ans = new int[1 << n];
    ans[0] = 0;
    this.visited = new boolean[1 << n];
    visited[0] = true;
    dfs(1, st);
    return Arrays.stream(ans).boxed().collect(Collectors.toList());
}
  1. 字母大小写全排列

给定一个字符串 s ,通过将字符串 s 中的每个字母转变大小写,我们可以获得一个新的字符串。 返回 所有可能得到的字符串集合 。以 任意顺序 返回输出。

java 复制代码
List<String> ans = new ArrayList<>();

// 枚举第下标为 k 字符变化情况
public void dfs(int k, String pre) {

    if (k == pre.length()) {  // [0, k-1] 字符大小写都已经变换完成, 将结果存入到结果集, 并返回
        ans.add(pre);
        return;
    }

    dfs(k + 1, pre); // 第 ① 种情况, 下标为 k 字符大小写不变
    
    // 下标为 k 字符要发生大小写变化前提是 { 该字符是字母 }
    if (Character.isUpperCase(pre.charAt(k)) || Character.isLowerCase(pre.charAt(k))) {
        // 下标为 k 字符大小写变化后的字符
        char ch = Character.isUpperCase(pre.charAt(k)) ? Character.toLowerCase(pre.charAt(k))
                : Character.toUpperCase(pre.charAt(k));
        StringBuilder sb = new StringBuilder(pre);
        sb.setCharAt(k, ch);
        dfs(k + 1, sb.toString());  // 第 ② 种情况, 下标为 k 字符大小写变化
    }

}

public List<String> letterCasePermutation(String s) {
    dfs(0, s);
    return ans;
}
相关推荐
白榆maple10 分钟前
(蓝桥杯C/C++)——基础算法(下)
算法
JSU_曾是此间年少15 分钟前
数据结构——线性表与链表
数据结构·c++·算法
此生只爱蛋1 小时前
【手撕排序2】快速排序
c语言·c++·算法·排序算法
咕咕吖2 小时前
对称二叉树(力扣101)
算法·leetcode·职场和发展
九圣残炎2 小时前
【从零开始的LeetCode-算法】1456. 定长子串中元音的最大数目
java·算法·leetcode
lulu_gh_yu2 小时前
数据结构之排序补充
c语言·开发语言·数据结构·c++·学习·算法·排序算法
丫头,冲鸭!!!3 小时前
B树(B-Tree)和B+树(B+ Tree)
笔记·算法
Re.不晚3 小时前
Java入门15——抽象类
java·开发语言·学习·算法·intellij-idea
为什么这亚子4 小时前
九、Go语言快速入门之map
运维·开发语言·后端·算法·云原生·golang·云计算
4 小时前
开源竞争-数据驱动成长-11/05-大专生的思考
人工智能·笔记·学习·算法·机器学习