本文为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