DFS常规解题套路

Posted xdfapp

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了DFS常规解题套路相关的知识,希望对你有一定的参考价值。

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

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

以上是关于DFS常规解题套路的主要内容,如果未能解决你的问题,请参考以下文章

BFS 算法解题套路框架

算法笔记 DFS的千层套路 HERODING的算法之路

关于dfs的套路

1097 画矩阵

HDU 1016(DFS_B题)解题报告

常见解题套路