深度优先搜索与岛屿问题

深度优先搜索概述

深度优先搜索(缩写DFS)是一种在开发爬虫早期使用较多的方法。属于图算法的一种,也是对一个连通图进行遍历的算法。其思想是:从一个顶点 v 开始,沿着一条路线一直走到底,如果发现不能到达目标,那就返回到走不通节点的上一个节点,然后尝试从另一条路开始走到底,每个节点只可以访问一次。这种尽量往深处走的概念即是深度优先的概念。

DFS 的基本思路

深度优先遍历图的方法是:
(1)从图中某顶点 v 出发,访问顶点 v;
(2)依次从 v 的未被访问的邻接点(adjacentadjacent)出发,对图进行深度优先遍历;直至图中和 v 有路径相通的顶点都被访问;
(3)若此时图中尚有顶点未被访问,则从一个未被访问的顶点出发,重新进行深度优先遍历,直到图中所有顶点均被访问过为止。 当然,当人们刚刚掌握深度优先搜索的时候常常用它来走迷宫.事实上我们还有别的方法,那就是广度优先搜索(BFS).

DFS 特点以及和 BFS 的比较

(1)DFS 可以用递归来写,也可以用栈来写。
(2)DFS 在回溯时要取消原先的标记,而 BFS 不存在回溯也就不存在取消标记这一问题。(即避免节点出现在别的搜索路径中)。
(3)DFS 难以寻找最优解,仅仅只能寻找有解。其优点就是相比 BFS 内存消耗小。

DFS 举例

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

深度优先搜索代码

/**
 * DFS核心伪代码
 * 前置条件是visit数组全部设置成false
 * @param n 当前开始搜索的节点
 * @param d 当前到达的深度
 * @return 是否有解
 */
bool DFS(Node n, int d){
	// 一旦搜索深度到达一个结束状态,就返回true
    if (isEnd(n, d)){
        return true;
    }
 	// 遍历 n 相邻的节点 nextNode
    for (Node nextNode in n){
        if (!visit[nextNode]){
        	// 例如搜索到 nextNode 了,那么 nextNode 要设置成已访问
        	// 在下一步搜索中,nextNode 不能再次出现
            visit[nextNode] = true;
            if (DFS(nextNode, d+1)){// 如果搜索出有解
                // 做些其他事情,例如记录结果深度等
                return true;
            }
 
            // 重新设置成false,因为它有可能出现在下一次搜索的别的路径中
            visit[nextNode] = false;
        }
    }
    return false;//本次搜索无解
}

我们所熟悉的 DFS(深度优先搜索)问题通常是在树或者图结构上进行的。而我们今天要讨论的 DFS 问题,是在一种「网格」结构中进行的。岛屿问题是这类网格 DFS 问题的典型代表。网格结构遍历起来要比二叉树复杂一些,如果没有掌握一定的方法,DFS 代码容易写得冗长繁杂。

深度优先搜索与回溯的区别

1、深度优先搜索

DFS 英文名,Depth First Search,中文名深度优先搜索,是图的一种搜索算法,每一个可能的分支路径深入到不能再深入为止,且每个节点只能访问一次。
深度优先搜索算法跟图结构紧密相关,任何涉及深度度优先搜索的问题,都伴随着图。
深度度优先搜索的能够在图结构里搜索到通往特定终点的一条或者多条特定路径

2、回溯

回溯算法是系统地搜索问题的解的方法。
某个问题的所有可能解的称为问题的解空间,若解空间是有限的,则可将解空间映射成树结构。
回溯法是能够在树结构里搜索到通往特定终点的一条或者多条特定路径。
回溯法以深度优先搜索的方式搜索解空间,并且在搜索过程中用剪枝函数避免无效搜索
任何解空间可以映射成树结构的问题,都可以使用回溯法;任何解空间不能映射成树结构的问题,都不可以使用回溯法

回溯算法与深度优先搜索的区别

(1)两者关系

回溯算法 = 树的深度优先搜索 + 剪枝函数

(2)二者区别

回溯的基本思想是:为了求得问题的解,先选择某一种可能情况向前搜索,在搜索过程中,一旦发现原来的选择是错误的,就退回一步重新选择,继续向前探索,如此反复进行,直到得到解或证明无解。回溯的关键不在于递归,而在于“状态”。在回溯算法向前的每一步,你都会去设置某个状态,而当向前走走不通的时候回退,此时需要把之前设置的状态撤销掉。
DFS 只是找某个或某些满足条件的东西而已,找到就返回,找不到拉倒,没状态啥事。

深度优先搜索求解的时候,当找到目标结点之后,还要回头寻找初始结点到目标结点的解路径(即不记录中间过程)。而回溯法找到目标结点之后,搜索路径就是一条从初始结点到目标结点的解路径。回溯法实际上是状态空间搜索中,深度优先搜索的一种改进,是更实用的一种搜索求解方法。

网格类问题的 DFS 遍历方法

1、网格问题的基本概念

我们首先明确一下岛屿问题中的网格结构是如何定义的,以方便我们后面的讨论。
网格问题是由 m \times nm×n 个小方格组成一个网格,每个小方格与其上下左右四个方格认为是相邻的,要在这样的网格上进行某种搜索。
岛屿问题是一类典型的网格问题。每个格子中的数字可能是 0 或者 1。我们把数字为 0 的格子看成海洋格子,数字为 1 的格子看成陆地格子,这样相邻的陆地格子就连接成一个岛屿。
在这里插入图片描述
在这样一个设定下,就出现了各种岛屿问题的变种,包括岛屿的数量、面积、周长等。不过这些问题,基本都可以用 DFS 遍历来解决。

2、DFS 的基本结构

网格结构要比二叉树结构稍微复杂一些,它其实是一种简化版的图结构。要写好网格上的 DFS 遍历,我们首先要理解二叉树上的 DFS 遍历方法,再类比写出网格结构上的 DFS 遍历。我们写的二叉树 DFS 遍历一般是这样的:

void traverse(TreeNode root) {
    // 判断 base case
    if (root == null) {
        return;
    }
    // 访问两个相邻结点:左子结点、右子结点
    traverse(root.left);
    traverse(root.right);
}

可以看到,二叉树的 DFS 有两个要素:「访问相邻结点」和「判断 base case」。

(1)第一个要素是访问相邻结点。二叉树的相邻结点非常简单,只有左子结点和右子结点两个。二叉树本身就是一个递归定义的结构:一棵二叉树,它的左子树和右子树也是一棵二叉树。那么我们的 DFS 遍历只需要递归调用左子树和右子树即可。
(2)第二个要素是 判断 base case。一般来说,二叉树遍历的 base case 是 root == null。这样一个条件判断其实有两个含义:一方面,这表示 root 指向的子树为空,不需要再往下遍历了。另一方面,在 root == null 的时候及时返回,可以让后面的 root.left 和 root.right 操作不会出现空指针异常。

对于网格上的 DFS,我们完全可以参考二叉树的 DFS,写出网格 DFS 的两个要素:

(1)相邻结点

首先,网格结构中的格子有多少相邻结点?答案是上下左右四个。对于格子 (r, c) 来说(r 和 c 分别代表行坐标和列坐标),四个相邻的格子分别是 (r-1, c)、(r+1, c)、(r, c-1)、(r, c+1)。换句话说,网格结构是「四叉」的
在这里插入图片描述

(2)base case

网格 DFS 中的 base case 是什么?从二叉树的 base case 对应过来,应该是网格中不需要继续遍历、grid[r][c] 会出现数组下标越界异常的格子,也就是那些超出网格范围的格子
在这里插入图片描述
这一点稍微有些反直觉,坐标竟然可以临时超出网格的范围?这种方法我称为「先污染后治理」—— 甭管当前是在哪个格子,先往四个方向走一步再说,如果发现走出了网格范围再赶紧返回。这跟二叉树的遍历方法是一样的,先递归调用,发现 root == null 再返回。

这样,我们得到了网格 DFS 遍历的框架代码:

void dfs(int[][] grid, int r, int c) {
    // 判断 base case
    // 如果坐标 (r, c) 超出了网格范围,直接返回
    if (!inArea(grid, r, c)) {
        return;
    }
    // 访问上、下、左、右四个相邻结点
    dfs(grid, r - 1, c);
    dfs(grid, r + 1, c);
    dfs(grid, r, c - 1);
    dfs(grid, r, c + 1);
}

// 判断坐标 (r, c) 是否在网格中
boolean inArea(int[][] grid, int r, int c) {
    return 0 <= r && r < grid.length 
        	&& 0 <= c && c < grid[0].length;
}

如何避免重复遍历

网格结构的 DFS 与二叉树的 DFS
最大的不同之处在于,遍历中可能遇到遍历过的结点。这是因为,网格结构本质上是一个「图」,我们可以把每个格子看成图中的结点,每个结点有向上下左右的四条边。在图中遍历时,自然可能遇到重复遍历结点。
这时候,DFS 可能会不停地「兜圈子」,永远停不下来,如下图所示:
在这里插入图片描述 如何避免这样的重复遍历呢?答案是标记已经遍历过的格子。以岛屿问题为例,我们需要在所有值为 1 的陆地格子上做 DFS 遍历。每走过一个陆地格子,就把格子的值改为 2,这样当我们遇到 2 的时候,就知道这是遍历过的格子了。
在这里插入图片描述

我们在框架代码中加入避免重复遍历的语句:

void dfs(int[][] grid, int r, int c) {
    // 判断 base case
    if (!inArea(grid, r, c)) {
        return;
    }
    // 如果这个格子不是岛屿,直接返回
    if (grid[r][c] != 1) {
        return;
    }
    grid[r][c] = 2; // 将格子标记为「已遍历过」
    
    // 访问上、下、左、右四个相邻结点
    dfs(grid, r - 1, c);
    dfs(grid, r + 1, c);
    dfs(grid, r, c - 1);
    dfs(grid, r, c + 1);
}

// 判断坐标 (r, c) 是否在网格中
boolean inArea(int[][] grid, int r, int c) {
    return 0 <= r && r < grid.length 
        	&& 0 <= c && c < grid[0].length;
}

这样,我们就得到了一个岛屿问题、乃至各种网格问题的通用 DFS 遍历方法。以下所讲的几个例题,其实都只需要在 DFS 遍历框架上稍加修改而已。

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值