zoukankan      html  css  js  c++  java
  • DFS常规解题套路

    本文为xdfApp团队成员文章,原文链接:https://blog.csdn.net/sinat_37380158/article/details/106866970

    作者介绍:韩沛沛, 北京邮电大学本科硕士毕业,在阿里大文娱(优酷)工作6年,后任马蜂窝后端技术专家, 现任新东方后端JAVA技术专家。

    0 前言

        昨天突然到来的代码训练营中,我被叫起来讲两周前的一道题,有点懵,有同学听完之后表示没太明白,可能我当时表述的比较着急所以没讲清楚。现在特别整理了一下DFS的解题模板,并挑选了一系列leetcode的相关题目(从easy到hard),希望大家看完之后能对DFS有个更好的认识。
    本文内容比较基础,只适用于对DFS了解不深的同学;不过欢迎所有的同学交流和指正,大家一起努力提高~

    1 DFS简介:

    引用自leetcode网站关于DFS的介绍:

        深度优先搜索算法(英语:Depth-First-Search,DFS)是一种用于遍历或搜索树或图的算法。沿着树的深度遍历树的节点,尽可能深的搜索树的分支。当节点v的所在边都己被探寻过,搜索将回溯到发现节点v的那条边的起始节点。这一过程一直进行到已发现从源节点可达的所有节点为止。如果还存在未被发现的节点,则选择其中一个作为源节点并重复以上过程,整个进程反复进行直到所有节点都被访问为止。属于盲目搜索。
        深度优先搜索是图论中的经典算法,利用深度优先搜索算法可以产生目标图的相应拓扑排序表,利用拓扑排序表可以方便的解决很多相关的图论问题,如最大路径问题等等。
        因发明「深度优先搜索算法」,约翰 · 霍普克洛夫特与罗伯特 · 塔扬在1986年共同获得计算机领域的最高奖:图灵奖。

    2 DFS模板

        DFS的一般模板(解题一般套路):

    //参数用来表示当前状态; 
    //返回值是我们dfs完成之后想要获取的数据,如果不需要返回值或者通过全局变量来记录状态的话ReturnType可以为void
    //函数名可以换成更有意义的名字
    ReturnType dfs(param1,params2,...) 
    {  
        if(终点状态 || 非法状态 || 需要剪枝)  
        {  
            ... //退出前处理
            return;  
        }  
        for(每一个当前状态相关的下一个状态)  
        {  
            if(该状态合法 && 该状态未被标记)  
            {  
                ...; // 当前状态应该做的处理(遍历前需要的处理)(根据实际情况来判断是否需要)
                标记当前状态;  
                dfs();  
                ...; // 当前状态应该做的处理(遍历后需要的处理)(根据实际情况来判断是否需要)
                (还原标记); //可选操作, 如果加上这句就是"回溯法"  
            }  
     
        }  
    }  

    3 DFS实战

        我们从一系列实战例题来逐步加深对DFS模板的理解。
        说明:实战部分的代码均为博主手敲,主要是用来和大家一起熟悉思路,可能不是最优雅的解法。

    实战一:简单DFS

    题目: LeetCode No.100 相同的树 (简单) 原题链接

    给定两个二叉树,编写一个函数来检验它们是否相同。如果两个树在结构上相同,并且节点具有相同的值,则认为它们是相同的。

    题目分析
    树的遍历,可以用dfs解决。从根结点出发,如果根结点相同 && 根结点的左子树相同 && 根结点的右子树相同,则可以判断两个二叉树相同。
    java代码:
    应用DFS模板很容易写出下面的代码:

    class Solution {
    // 当前状态(两个树同一位置的某个节点)可用参数p和q表示;返回值(是否相同)显然是boolean
        public boolean isSameTree(TreeNode p, TreeNode q) {
        // 终止状态 直接返回
        if (p == null && q == null) return true;
        if (p == null || q == null) retrun false;
        if (p.val != q.val) return false;
        // 当前状态相关的下一个状态有两个: 比较树的左子和右子
        // 因为当前节点不会再次遍历,省略当前状态的标记处理和标记还原操作
        boolean leftSame = isSameTree(p.left, q.left);
        boolean rightSame = isSameTree(p.right, q.right);
        // 遍历后需要的处理
        return leftSame && rightSame;
        }
    }

    实战二:稍复杂的DFS

    题目: LeetCode No.112 路径总和 (简单) 原题链接

    给定一个二叉树和一个目标和,判断该树中是否存在根节点到叶子节点的路径,这条路径上所有节点值相加等于目标和。
    说明: 叶子节点是指没有子节点的节点。

    题目分析
    树的遍历,可以用dfs解决。从根结点出发,如果 (根结点.val == 目标和 && 根结点为叶子节点) || (根结点.val + 根结点的左子树.val == 目标和) || (根结点.val + 根结点的右子树.val == 目标和),则可以判断存在满足题意的路径。
    java代码:
    应用DFS模板很容易写出下面的代码:

    class Solution {
    // 当前状态除了树的当前节点root,还有当前期望的和sum;
    // 返回值(是否存在路径)显然是boolean
        public boolean hasPathSum(TreeNode root, int sum) {
        // 终止状态 直接返回
        if (root.left == null && root.right == null && root.val == sum) {
            return true;
        }
        // 当前状态相关的下一个状态有两个: 比较树的左子和右子
        // 邻节点dfs之前应该做的处理(设定expectSum)
        int expectSum = sum - root.val;
        // 因为当前节点不会再次遍历,省略当前状态的标记处理和标记还原操作
        boolean leftRes = hasPathSum(root.left, expectSum);
        boolean rightRes = hasPathSum(root.right, expectSum);
        // 遍历后需要的处理
        return leftSame && rightSame;
    }

    相比较前一题,本题在dfs时除了关注树本身节点外还需要关注当前期望和sum,这里刚开始学习dfs的同学可能会觉得有一点绕,理解的关键还是要搞清楚dfs遍历时都有哪些数据在发生变化(刚开始初学时,如果不确定dfs方法需要哪些参数,可以把这些会发生变化的数据都当作方法参数) 。刚开始学习dfs的部分同学对于dfs执行的顺序也可能感到有点难理解,这个问题可以通过不断练习针对不同的输入调试跟踪dfs遍历的过程来解决。

    实战三:DFS+回溯

    题目: LeetCode No.113 路径总和 II (中等) 原题链接

    给定一个二叉树和一个目标和,找到所有从根节点到叶子节点路径总和等于给定目标和的路径。
    说明: 叶子节点是指没有子节点的节点。

    题目分析
    遍历树可以得到所有满足条件的路径,可以用dfs解决。
    从根结点出发对树进行完整遍历,如果 (当前节点为叶子结点 && 从叶子节点往上所有祖先.val之和 == 目标和),则将该路径加入到结果集合。
    仔细思考dfs的过程,和当前状态有关的变量可能有:当前的节点root、当前目标和sum、当前路径path、当前结果集res。其中与当前状态强相关的变量是:当前的节点root、当前目标和sum;起支持作用的变量是:当前路径path、当前结果集res。一般习惯将强相关的变量放到dfs的参数列表中;起支持作用的变量可以放到dfs参数列表中,也可以放到全局变量(之后dfs过程中能用到就好)。
    java代码:
    应用DFS模板很容易写出下面的代码:

    class Solution {
        // 用全局变量res来记录结果(当然也可以将res当作当前状态的一部分放到dfs的参数列表中)。
        private List<List<Integer>> res = new ArrayList<>();
        
        public List<List<Integer>> pathSum(TreeNode root, int sum) {
            if (root == null) {
                return res;
            }
            // 仔细思考dfs的状态,除了和当前的节点root、当前目标和sum有关,还和当前路径path有关。
            // (当然也可以将res当作当前状态的一部分放到dfs的参数列表中, 这里我们认为res只是一个结果收集器,与当前状态无关,放到全局变量中)
            List<Integer> path = new ArrayList<>();
            dfs(root, sum, path);
            return res;
        }
        
        // 因为在遍历过程中会做结果集的收集,dfs不需要返回值
        private void dfs(TreeNode root, int sum, List<Integer> path) {
            // 终点状态1, 直接返回
            if(root == null) {
                return;
            }
            
            // 终点状态2,需要做退出前处理(收集新路径)
            if (root.left == null && root.right == null && root.val == sum) {
                // 标记当前状态 - 路径加入当前节点
                path.add(root.val);
                // 结果加入当前路径
                // 因为path是全局唯一对象,用来记录遍历过程中当前状态的路径,所以不能直接将path放到结果集中,需要深拷贝
                res.add(new ArrayList<>(path));
                // (还原标记) - 为了不影响后续遍历,需要回溯去掉path里的当前节点
                path.remove(path.size() - 1);
                return;
            }
            // 当前状态相关的下一个状态有两个: 比较树的左子和右子
            // 邻节点dfs之前应该做的处理(设定expectSum)
            int expectSum = sum - root.val;
            // 标记当前状态 - 路径加入当前节点
            path.add(root.val);
            dfs(root.left, expectSum, path);
            dfs(root.right, expectSum, path);
            // (还原标记) - 为了不影响后续遍历,需要回溯去掉path里的当前节点
            path.remove(path.size() - 1);
        }
    }


    如果有对回溯不太熟悉的同学,在刚开始的时候可能感到有点难理解。其实回溯的本质很简单,用下面模板来解释:

    for(需要遍历的每一个item) {
        doSomething(item);  // 前行
        process(item);
        undoSomeThing(item); // 回退(回溯)
    }


    结合本例,在采集结果或者对非叶子结点dfs时,我们先将当前节点加入当前路径,等结果采集完毕或者子节点dfs结束后将当前节点从当前路径中去除,这样就能保证遍历下一个元素的时候,path里面永远是正确的当前路径内容。
    回溯也需要多加练习,才能掌握比较好。
    下面我们再通过一个题目来巩固dfs+回溯。

    实战四:DFS+回溯

    题目: LeetCode No.46 全排列(中等) 原题链接

    给定一个 没有重复 数字的序列,返回其所有可能的全排列。

    题目分析
    本题可以有很多种解法,当然也可以用dfs解决。用dfs也有多种思路,我们以每次选择一个新元素为例。

    java代码:
    应用DFS模板很容易写出下面的代码:

    class Solution {
        // 结果集;用全局变量res来记录结果
        List<List<Integer>> res = new ArrayList<>();
        // 仔细思考dfs遍历时的当前状态,可以用(数组nums、路径path、状态traveled)来表示。
        // 这里我们在做一个小的变化,将当前状态(路径、状态)也放到全局变量中
        // 当前状态 - 路径(当前遍历过的所有节点的路径)
        List<Integer> path = new ArrayList<>();
        // 当前状态 - 状态(当前遍历过哪些节点)
        Set<Integer> traveled = new HashSet<>();
    
        public List<List<Integer>> permute(int[] nums) {
            dfs(nums);
            return res;
        }
        private void dfs(int[] nums) {
            // 终点状态,需要做退出前处理(收集新路径)
            if (path.size() == nums.length) {
                // 需要深拷贝
                res.add(new ArrayList<>(path));
                return;
            }
            // for(每一个当前状态相关的下一个状态)。
            // 注意这里和之前树的dfs不一样,树的dfs很多时候不用考虑重复遍历,这里就需要考虑了(根据标记状态判断就可以去除重复遍历)
            for (int i=0;i<nums.length;i++) {
                // if(该状态被标记) 直接跳过
                if (traveled.contains(i)) {
                    continue;
                }
                // 标记当前状态:同时处理路径path和状态traveled
                traveled.add(i);
                path.add(nums[i]);
                dfs(nums);
                // (还原标记)/回溯:同时回溯路径path和状态traveled
                path.remove(path.size() - 1);
                traveled.remove(i);
            }
        }
    }

    从上面代码可以看出,对于dfs最重要的几点就是:确定如何来表示/切换当前状态,确定如何标记/回溯,确定终止/剪枝条件。
    推荐阅读:
    从全排列问题开始理解「回溯」算法(深度优先遍历 + 状态重置 + 剪枝)

    实战五:DFS+二维

    题目: LeetCode No.695 岛屿的最大面积(中等) 原题链接

    给定一个包含了一些 0 和 1 的非空二维数组 grid 。
    一个 岛屿 是由一些相邻的 1 (代表土地) 构成的组合,这里的「相邻」要求两个 1 必须在水平或者竖直方向上相邻。你可以假设 grid 的四个边缘都被 0(代表水)包围着。
    找到给定的二维数组中最大的岛屿面积。(如果没有岛屿,则返回面积为 0 。)
    示例 1:
    [[0,0,1,0,0,0,0,1,0,0,0,0,0],
    [0,0,0,0,0,0,0,1,1,1,0,0,0],
    [0,1,1,0,1,0,0,0,0,0,0,0,0],
    [0,1,0,0,1,1,0,0,1,0,1,0,0],
    [0,1,0,0,1,1,0,0,1,1,1,0,0],
    [0,0,0,0,0,0,0,0,0,0,1,0,0],
    [0,0,0,0,0,0,0,1,1,1,0,0,0],
    [0,0,0,0,0,0,0,1,1,0,0,0,0]]
    对于上面这个给定矩阵应返回 6。注意答案不应该是 11 ,因为岛屿只能包含水平或垂直的四个方向的 1 。
    示例 2:
    [[0,0,0,0,0,0,0,0]]
    对于上面这个给定的矩阵, 返回 0。

    题目分析
    本题可以有很多种解法,当然也可以用dfs解决。用dfs也有多种思路:比如每次选择一个新元素,比如每次交换相邻元素等。我们以每次选择一个新元素为例。

    java代码
    有了前面的基础,应用DFS模板很容易写出相应代码(肯定要比回溯简单)。

    class Solution {
        public int maxAreaOfIsland(int[][] grid) {
            if (grid == null || grid.length == 0 || grid[0].length == 0) {
                return 0;
            }
            int m = grid.length;
            int n = grid[0].length;
            int max = 0;
    
            // 对二维数组每一个岛屿进行dfs,dfs可以返回当前岛屿的面积,由此可得最大岛面积
            for (int i=0;i<m;i++) {
                for (int j=0;j<n;j++) {
                    if (grid[i][j] == 1) {
                        max = Math.max(dfs(grid, i, j), max);
                    }
                }
            }
            return max;
        }
        
        // dfs遍历时的当前状态比较明显,就是二维数组的某个元素,可以用(数组grid, 横坐标 i, 纵坐标 j)来表示。
        private int dfs(int[][] grid, int i, int j) {
            int m = grid.length;
            int n = grid[0].length;
            // 标记当前状态;已经标记过的元素后面不会再次访问
            grid[i][j] = -1;
            int res = 1;
            // 对当前状态的4个方向(如果有的话)分别进行dfs累加当前岛面积
            if (i > 0 && grid[i-1][j] == 1) {
                res += dfs(grid, i-1, j);
            }
            if (i < m - 1 && grid[i+1][j] == 1) {
                res += dfs(grid, i+1, j);
            }
            if (j > 0 && grid[i][j-1] == 1) {
                res += dfs(grid, i, j-1);
            }
            if (j < n - 1 && grid[i][j+1] == 1) {
                res += dfs(grid, i, j+1);
            }
            // 返回当前岛面积
            return res;
        }
    }

    上面dfs()方法返回了当前岛的面积。我们也可以思考一下场景来解决类似的更复杂问题。
    比如如果每次完成dfs时我们将当前岛的面积都记录下来,就可以得到所有岛屿的面积。
    比如不同的岛屿构成了不同的连通分量,我们可以判断任意两个点是否在同一个岛,也可以计算不同的岛屿间的最近距离。
    比如假设我们有能力将某一块海洋变成陆地(将二维数组中某一个值为0的元素变成1),变动哪块海洋之后能得到最大岛?

    实战六:DFS+二维+着色

    题目: LeetCode No.827 最大人工岛 (困难) 原题链接

    在二维地图上, 0代表海洋, 1代表陆地,我们最多只能将一格 0 海洋变成 1变成陆地。
    进行填海之后,地图上最大的岛屿面积是多少?(上、下、左、右四个方向相连的 1 可形成岛屿)
    示例 1:
    输入: [[1, 0], [0, 1]]
    输出: 3
    解释: 将一格0变成1,最终连通两个小岛得到面积为 3 的岛屿。
    示例 2:
    输入: [[1, 1], [1, 0]]
    输出: 4
    解释: 将一格0变成1,岛屿的面积扩大为 4。
    示例 3:
    输入: [[1, 1], [1, 1]]
    输出: 4
    解释: 没有0可以让我们变成1,面积依然为 4。
    说明:
    1 <= grid.length = grid[0].length <= 50
    0 <= grid[i][j] <= 1

    题目分析
    1 暴力解:很容易想到,对grid中每一个为0的元素将其变成1后进行dfs看其所在的岛屿面积,取其中最大岛屿面积即可,只是复杂度比较高,需要优化剪枝。dfs完了之后还要进行回溯(再将1变回0)。
    2 优化暴力解:显然暴力解中没必要改变所有为0的元素,只需要改变近海元素即可(近海元素:紧挨着1的0)。
    3 优化暴力解:对同一个岛一次dfs之后,就知道了该岛的面积,没必要多次重复对该岛dfs。
    4 基于以上分析,我们可以先对grid做一个整体dfs,来给各个岛屿着色(对应的元素都相同的编号),并用一个map来记录每个着色的岛屿面积;然后对所有的近海元素,将0变成1,再从四个方向上累加新链接上的不同岛屿(着色不同)的面积,即可得到变更后此近海元素对应的岛屿面积。整个过程中记录最大岛屿面积即可。

    java代码

    class Solution {
        // 用全局变量color来记录当前岛屿的着色(这里为了后面方便判断,颜色去了负值;其实取值多少无所谓,只要不同岛屿着色不同就行)
        int color = -100;
        // 用全局变量colorAreaMap来记录每个颜色对应的岛屿面积
        Map<Integer, Integer> colorAreaMap = new HashMap<>();
        
        public int largestIsland(int[][] grid) {
            // 假设res就是我们要求的填海后的最大岛面积,它有两种可能: 1 未填海之前的最大岛屿面积(比如grid全为1);2 填海之后的最大岛屿面积
            int res = 0;
            
            // 对二维数组内每一个岛屿进行dfs
            for (int i = 0; i < grid.length; i++) {
                for (int j = 0; j < grid[0].length; j++) {
                    // 如果发现新的陆地,改变颜色,后面邻接的陆地都将染色成新颜色
                    if (grid[i][j] == 1) {
                        color--;
                    }
                    // dfs对岛屿着色并返回岛屿面积
                    int area = dfs(grid, i, j);
                    if (area > 0) {
                        // 记录color对应的岛屿面积
                        colorAreaMap.put(color, area);
                        // 更新res
                        res = Math.max(res, area);
                    }
                }
            }
    
            // 对每个海域grid[i][j],寻找它相邻岛屿着色集合colorSet,计算填海后的岛屿面积
            for (int i = 0; i < grid.length; i++) {
                for (int j = 0; j < grid[0].length; j++) {
                    if (grid[i][j] == 0) {
                        // 海域grid[i][j]相邻的岛屿着色集合colorSet
                        Set<Integer> colorSet = new HashSet<>();
                        if (i > 0 && grid[i-1][j] < 0) {
                            colorSet.add(grid[i-1][j]);
                        }
                        if (i < grid.length - 1 && grid[i+1][j] < 0) {
                            colorSet.add(grid[i+1][j]);
                        }
                        if (j > 0 && grid[i][j-1] < 0) {
                            colorSet.add(grid[i][j-1]);
                        }
                        if (j < grid.length - 1 && grid[i][j+1] < 0) {
                            colorSet.add(grid[i][j+1]);
                        }
                        // 计算填海后的岛屿面积
                        int area = 1;
                        for (Integer c: colorSet) {
                            area += colorAreaMap.getOrDefault(c, 0);
                        }
                        res = Math.max(res, area);
                    }
                }
            }
            return res;
        }
    
        // dfs对岛屿着色并返回岛屿面积
        private int dfs(int[][] grid, int i, int j) {
            // 数组越界 或者 非陆地 或者 已遍历过,返回0
            if (i < 0 || j < 0 || i >= grid.length || j >= grid[0].length || grid[i][j] < 1) {
                return 0;
            }
            // 着色/染色
            if (grid[i][j] == 1) {
                grid[i][j] = color;
            }
            // 返回岛屿面积
            return 1 + dfs(grid, i-1, j) + dfs(grid, i+1, j) + dfs(grid, i, j-1) + dfs(grid, i, j+1);
        }
    
    }

    4 DFS周边

    DFS与BFS

    DFS深度优先遍历,BFS广度优先遍历,二者都常见于树/图的遍历。
    一般BFS常借助于队列实现,DFS常借助于栈/递归(系统栈)实现。从编码的角度讲,一般DFS要更容易实现。
    DFS经常和回溯法搭配使用,这是因为DFS在遍历的当前状态和下一状态一般是相邻的,我们可以轻松的从一个状态变更到另一个状态。BFS遍历时从浅层转到深层状态的变化很大,通常需要额外变量去保存这些信息,性能往往也没DFS好。

    DFS与UnionFind

    DFS:深度优先遍历,常用来解决树/图的遍历、连通性、路径等问题。
    UnionFind:并查集,一般用来解决图的连通性问题,不能解决路径相关问题。
    DFS功能强于UnionFind,但是并查集更易于理解,代码也相对固定,不失为一种解决问题的好方法。
    并查集将在下一期内容详细讲解。


    ————————————————
    版权声明:本文为CSDN博主「ppprog」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
    原文链接:https://blog.csdn.net/sinat_37380158/article/details/106866970

  • 相关阅读:
    话说Hibernate和ADO.NET —练习随笔小记
    二次开发WinWebMail邮件系统接口 企业邮件服务器解决方案
    一个Windows后台服务(.Net的C#版) 定时访问数据库循环发送手机短信
    SQL UPDATE 联合表更新的问题
    2009新的篇章,惠海→时代财富→广佛都市网
    在WebService中使用Session或Cookie实现WebService身份验证(客户端是Flex)
    门户网站的形成—CMS内容管理系统
    CSS实现0.5px的边框或线
    《后人诗》
    CentOS6下docker的安装和使用
  • 原文地址:https://www.cnblogs.com/xdfapp/p/13233420.html
Copyright © 2011-2022 走看看