岛屿问题-DFS

沉岛问题

链接
DFS来解决的问题
设置辅助函数 dfs
深度优先遍历

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

本文将以岛屿问题为例,展示网格类问题 DFS 通用思路,以及如何让代码变得简洁。
网格类问题的 DFS 遍历方法
网格问题的基本概念
我们首先明确一下岛屿问题中的网格结构是如何定义的,以方便我们后面的讨论。

网格问题是由 m×n 个小方格组成一个网格,每个小方格与其上下左右四个方格认为是相邻的,要在这样的网格上进行某种搜索。

岛屿问题是一类典型的网格问题。每个格子中的数字可能是 0 或者 1。我们把数字为 0 的格子看成海洋格子,数字为 1 的格子看成陆地格子,这样相邻的陆地格子就连接成一个岛屿。
在这里插入图片描述在这样一个设定下,就出现了各种岛屿问题的变种,包括岛屿的数量、面积、周长等。不过这些问题,基本都可以用 DFS 遍历来解决。
DFS 的基本结构
网格结构要比二叉树结构稍微复杂一些,它其实是一种简化版的图结构。要写好网格上的 DFS 遍历,我们首先要理解二叉树上的 DFS 遍历方法,再类比写出网格结构上的 DFS 遍历。我们写的二叉树 DFS 遍历一般是这样的:

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

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

  • 第一个要素是访问相邻结点。二叉树的相邻结点非常简单,只有左子结点和右子结点两个。二叉树本身就是一个递归定义的结构:一棵二叉树,它的左子树和右子树也是一棵二叉树。那么我们的DFS 遍历只需要递归调用左子树和右子树即可。
  • 第二个要素是 判断 base case。一般来说,二叉树遍历的 base case 是 root == null。这样一个条件判断其实有两个含义:一方面,这表示 root 指向的子树为空,不需要再往下遍历了。另一方面,在root == null 的时候及时返回,可以让后面的 root.left 和 root.right 操作不会出现空指针异常。
    对于网格上的 DFS,我们完全可以参考二叉树的 DFS,写出网格 DFS 的两个要素:
    首先,网格结构中的格子有多少相邻结点?答案是上下左右四个。对于格子 (r, c) 来说(r 和 c 分别代表行坐标和列坐标),四个相邻的格子分别是 (r-1, c)、(r+1, c)、(r, c-1)、(r, c+1)。换句话说,网格结构是「四叉」的。
    在这里插入图片描述
    其次,网格 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 的时候,就知道这是遍历过的格子了。也就是说,每个格子可能取三个值:

  • 0 —— 海洋格子
  • 1 —— 陆地格子(未遍历过)
  • 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,美其名曰「陆地沉没方法」,即遍历完一个陆地格子就让陆地「沉没」为海洋。这种方法看似很巧妙,但实际上有很大隐患,因为这样我们就无法区分「海洋格子」和「已遍历过的陆地格子」了。如果题目更复杂一点,这很容易出 bug。

岛屿问题的解法


剑指offer 12 矩阵中的路径
给定一个 m x n 二维字符网格 board 和一个字符串单词 word 。如果 word 存在于网格中,返回 true ;否则,返回 false 。
单词必须按照字母顺序,通过相邻的单元格内的字母构成,其中“相邻”单元格是那些水平相邻或垂直相邻的单元格。同一个单元格内的字母不允许被重复使用。
例如,在下面的 3×4 的矩阵中包含单词 “ABCCED”(单词中的字母已标出)。

示例 1:
输入:board = [[“A”,“B”,“C”,“E”],[“S”,“F”,“C”,“S”],[“A”,“D”,“E”,“E”]], word = “ABCCED”
输出:true

  1. 标记当前矩阵元素: 将 board[i][j] 修改为 空字符 ‘’ ,代表此元素已访问过,防止之后搜索时重复访问。
  2. 搜索下一单元格:朝当前元素的 上、下、左、右 四个方向开启下层递归,使用 或 连接 (代表只需找到一条可行路径就直接返回,不再做后续 DFS,并记录结果至 res 。
  3. 还原当前矩阵元素: 将 board[i][j] 元素还原至初始值,即 word[k] 。
class Solution {
    public boolean exist(char[][] board, String word) {
        char[] words = word.toCharArray();
        for(int i=0;i<board.length;i++){
            for(int j=0;j<board[0].length;j++){
                if(dfs(board,i,j,0,words))return true;
            }
        }
        return false;
    }
    boolean dfs(char[][] board,int i,int j,int k,char[] word){
        if(i>=board.length || j>=board[0].length || i<0 || j<0 || board[i][j]!=word[k])return false;
        if(k == word.length-1)return true;
        board[i][j]='\0';
        boolean res = dfs(board,i+1,j,k+1,word) || dfs(board,i,j+1,k+1,word) ||
                      dfs(board,i-1,j,k+1,word) || dfs(board,i,j-1,k+1,word);
        board[i][j] = word[k];
        return res;
    }
}

剑指offer13 机器人的运动范围

地上有一个m行n列的方格,从坐标 [0,0] 到坐标 [m-1,n-1] 。一个机器人从坐标 [0, 0] 的格子开始移动,它每次可以向左、右、上、下移动一格(不能移动到方格外),也不能进入行坐标和列坐标的数位之和大于k的格子。例如,当k为18时,机器人能够进入方格 [35, 37] ,因为3+5+3+7=18。但它不能进入方格 [35, 38],因为3+5+3+8=19。请问该机器人能够到达多少个格子?

  • 首先要考虑 数位相加和
  • 其次是要有意识 创建visited这个数组
  • 至于为什么只向右和向下扩张是因为不用回溯已走过的路径,扩张的路径只会向下或者向右
class Solution {
    public int movingCount(int m, int n, int k) {
        boolean[][] visited = new boolean[m][n];
        return dfs(visited,m,n,k,0,0);
    }
    int dfs(boolean[][] visited,int m,int n,int k,int i,int j){
        if(i>=m || j>=n || bitSum(i)+bitSum(j)>k || visited[i][j])return 0;
        visited[i][j] = true;
        return 1+dfs(visited,m,n,k,i+1,j)+dfs(visited,m,n,k,i,j+1);
    }
    int bitSum(int x){
        int sum=0;
        while(x!=0){
            sum += x%10;
            x /= 10;
        }
        return sum;
    }
}

LeetCode695 岛屿的最大面积
标准的沉岛问题(考虑要不要回溯)

给你一个大小为 m x n 的二进制矩阵 grid 。
岛屿 是由一些相邻的 1 (代表土地) 构成的组合,这里的「相邻」要求两个 1 必须在 水平或者竖直的四个方向上 相邻。你可以假设 grid 的四个边缘都被 0(代表水)包围着。
岛屿的面积是岛上值为 1 的单元格的数目。
计算并返回 grid 中最大的岛屿面积。如果没有岛屿,则返回面积为 0 。


在这里插入图片描述

示例 1:
输入:grid = [[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 。

class Solution {
    //可以看图理解,在加上记住这个模板。
    public int maxAreaOfIsland(int[][] grid) {
        //定义一个表示岛屿的面积
        int max = 0;
        //这两个for循环是来遍历整张二维格上的所有陆地的。
        //i 表示行,j表示列
        for(int i = 0;i<grid.length;i++){
            for(int j = 0; j<grid[0].length;j++){
                //陆地的格
                if(grid[i][j]==1){
                    //取出最大的面积
                    max = Math.max(max,dfs(grid,i,j));
                }  
            }
        }
        //返回最大的陆地面积
        return max;
    }
    public int  dfs(int[][] grid,int i,int j){
        //当超出岛屿边界(上下左右)的时候,就直接退出,特别要加上当遍历到海洋的时候也要退出,
        if(i<0||j<0 || i>=grid.length || j>= grid[0].length|| grid[i][j]==0) return 0;
       //定义一个变量表示岛屿的面积,就是包含几个陆地
        int sum = 1;
        //将陆地改为海洋,防止重复陆地重复遍历。
        grid[i][j] =0;
        //遍历上方元素,每遍历一次陆地就加一
        sum += dfs(grid,i+1,j);
        //遍历下方元素
        sum +=dfs(grid,i-1,j);
        //遍历右边元素
        sum +=dfs(grid,i,j+1);
        //遍历左边元素
        sum += dfs(grid,i,j-1);
        return sum;
    }
}

LeetCode200. 岛屿数量

给你一个由 ‘1’(陆地)和 ‘0’(水)组成的的二维网格,请你计算网格中岛屿的数量。
岛屿总是被水包围,并且每座岛屿只能由水平方向和/或竖直方向上相邻的陆地连接形成。
此外,你可以假设该网格的四条边均被水包围。
示例 1:
输入:grid = [
[“1”,“1”,“1”,“1”,“0”],
[“1”,“1”,“0”,“1”,“0”],
[“1”,“1”,“0”,“0”,“0”],
[“0”,“0”,“0”,“0”,“0”]
]
输出:1

class Solution {
    public int numIslands(char[][] grid) {
        int count = 0;
        for(int i = 0; i < grid.length; i++) {
            for(int j = 0; j < grid[0].length; j++) {
                if(grid[i][j] == '1'){
                    dfs(grid, i, j);
                    count++;
                }
            }
        }
        return count;
    }
    private void dfs(char[][] grid, int i, int j){
        if(i < 0 || j < 0 || i >= grid.length || j >= grid[0].length || grid[i][j] == '0') return;
        grid[i][j] = '0';
        dfs(grid, i + 1, j);
        dfs(grid, i, j + 1);
        dfs(grid, i - 1, j);
        dfs(grid, i, j - 1);
    }
}

LeetCode827. 最大人工岛

在二维地图上, 0 代表海洋,1代表陆地,我们最多只能将一格 0 (海洋)变成 1 (陆地)。进行填海之后,地图上最大的岛屿面积是多少?

这道题是岛屿最大面积问题的升级版。现在我们有填海造陆的能力,可以把一个海洋格子变成陆地格子,进而让两块岛屿连成一块。那么填海造陆之后,最大可能构造出多大的岛屿呢?

大致的思路我们不难想到,我们先计算出所有岛屿的面积,在所有的格子上标记出岛屿的面积。然后搜索哪个海洋格子相邻的两个岛屿面积最大。例如下图中红色方框内的海洋格子,上边、左边都与岛屿相邻,我们可以计算出它变成陆地之后可以连接成的岛屿面积为 7+1+2=107+1+2=10。
在这里插入图片描述
然而,这种做法可能遇到一个问题。如下图中红色方框内的海洋格子,它的上边、左边都与岛屿相邻,这时候连接成的岛屿面积难道是 7+1+7?显然不是。这两个 7 来自同一个岛屿,所以填海造陆之后得到的岛屿面积应该只有 7+1 = 8。
在这里插入图片描述
可以看到,要让算法正确,我们得能区分一个海洋格子相邻的两个 7 是不是来自同一个岛屿。那么,我们不能在方格中标记岛屿的面积,而应该标记岛屿的索引(下标),另外用一个数组记录每个岛屿的面积,如下图所示。这样我们就可以发现红色方框内的海洋格子,它的「两个」相邻的岛屿实际上是同一个。
在这里插入图片描述
可以看到,这道题实际上是对网格做了两遍 DFS:第一遍 DFS 遍历陆地格子,计算每个岛屿的面积并标记岛屿;第二遍 DFS 遍历海洋格子,观察每个海洋格子相邻的陆地格子。

这道题的基本思路就是这样,具体的代码还有一些需要注意的细节,但和本文的主题已经联系不大。各位可以自己思考一下如何把上述思路转化为代码。

思路:
1.第一步,先深度优先遍历出陆地,获取到所有的陆地面积,并且将同一块陆地中的格子值置换为同一索引值,该索引为记录该陆地面积的索引下标值
2.第二部,遍历海洋,找到相邻陆地面积最大的海洋格子

细化思路:
1.先计算出所有岛屿的面积,并全部记录下来,便于后面给填海时判断总面积大小使用。
2.按照通用的DFS遍历办法,需要防止遍历过的节点重复遍历,所以需要将遍历过的节点进行赋值变更,便于下次去重,避免重复遍历。0(海洋),1(陆地),所以这里建议为了避免和原值冲突,index值从2开始,一旦有陆地就依次遍历累加,这样index将代表岛屿面积map中的索引值,便于后续查找面积。
3.遍历海洋,一旦发现海洋,就判断上下左右格子是否为陆地,此处要特别注意的是,有可能四周皆为陆地,但需要判断这些陆地是同一块陆地,还是不同的陆地,避免陆地面积重复累加,这里是个易错点。

/**
     * 1.第一步,先深度优先遍历出陆地,获取到所有的陆地面积,并且将同一块陆地中的格子值置换为同一索引值,该索引为记录该陆地面积的索引下标值
     * 2.第二部,遍历海洋,找到相邻陆地面积最大的海洋格子
     */
    public static int largestIsland(int[][] grid) {
        //用来记录每次遍历中每个陆地的面积大小
        int res = 0;
        int maxArea =0;
        //索引值从2开始,避免和0(海洋),1(陆地)值冲突
        int index = 2;
        Map<Integer, Integer> map = new HashMap<Integer, Integer>();
        //1.深度优先,先遍历出陆地面积
        for (int x = 0; x< grid.length; x++){
            for (int y=0; y<grid[0].length; y++){
                if( grid[x][y] == 1 ){
                    int a = area(grid, x, y, index);
                    maxArea = Math.max(a, res);
                    map.put(index, a);
                    ++index;
                }
            }
        }
        //2.遍历海洋,找到相邻陆地面积最大的海洋格子
        int plusRes = 0;
        for (int x = 0; x< grid.length; x++){
            for (int y=0; y<grid[0].length; y++){
                //如果是0(海洋)
                if( grid[x][y] == 0 ){
                    int plusArea = plusArea(grid, x, y, map);
                    plusRes = Math.max(plusArea, plusRes);
                }
            }
        }
        //可能都没有海洋,全是陆地,则再次判断,直接返回陆地最大值
        plusRes = Math.max(maxArea, plusRes);
        return plusRes;
    }

    private static int plusArea(int[][] grid, int x, int y, Map<Integer, Integer> map){
        if( !isArea(grid, x, y) ){
            return 0;
        }
        if( grid[x][y] != 0 ){
            return 0;
        }
        Integer size = 0;
        //叠加面积很关键的一步,记得去重,因为海洋四周接壤的土地有可能是同一块陆地
        Set<Integer> set = new HashSet<Integer>();
        if( isArea(grid, x-1, y) && grid[x-1][y] >= 2 ){
            set.add(grid[x-1][y]);
        }
        if( isArea(grid, x+1, y) && grid[x+1][y] >=2 ){
            set.add(grid[x+1][y]);
        }
        if( isArea(grid, x, y-1) && grid[x][y-1] >=2 ){
            set.add(grid[x][y-1]);
        }
        if( isArea(grid, x, y+1) && grid[x][y+1] >=2 ){
            set.add(grid[x][y+1]);
        }
        for (Integer num : set){
            size += map.get(num);
        }
        //海洋变土地,+1
        ++size;
        return size;
    }

    private static int area(int[][] grid, int x, int y, int index){
        if( !isArea(grid, x, y) ){
            return 0;
        }
        if( grid[x][y] != 1 ){
            return 0;
        }
        //用index来代表同一块陆地存放面积的索引值
        grid[x][y] = index;
        return 1
                + area(grid , x+1, y,index)
                + area(grid , x-1, y,index)
                + area(grid , x, y+1,index)
                + area(grid , x, y-1,index);
    }

    private static boolean isArea(int[][] grid, int x, int y){
        return 0<=x && x<grid.length && 0<=y && y < grid[0].length;
    }

LeetCode 463. 岛屿的周长

给定一个包含 0 和 1 的二维网格地图,其中 1 表示陆地,0 表示海洋。网格中的格子水平和垂直方向相连(对角线方向不相连)。整个网格被水完全包围,但其中恰好有一个岛屿(一个或多个表示陆地的格子相连组成岛屿)。
岛屿中没有“湖”(“湖” 指水域在岛屿内部且不和岛屿周围的水相连)。格子是边长为 1 的正方形。计算这个岛屿的周长。
在这里插入图片描述

实话说,这道题用 DFS 来解并不是最优的方法。对于岛屿,直接用数学的方法求周长会更容易。不过这道题是一个很好的理解 DFS 遍历过程的例题,不信你跟着我往下看。

我们再回顾一下 网格 DFS 遍历的基本框架:

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 函数直接返回有这几种情况:

  • !inArea(grid, r, c),即坐标 (r, c) 超出了网格的范围,也就是我所说的「先污染后治理」的情况
  • grid[r][c] != 1,即当前格子不是岛屿格子,这又分为两种情况:
    • grid[r][c] == 0,当前格子是海洋格子
    • grid[r][c] == 2,当前格子是已遍历的陆地格子
      那么这些和我们岛屿的周长有什么关系呢?实际上,岛屿的周长是计算岛屿全部的「边缘」,而这些边缘就是我们在 DFS 遍历中,dfs 函数返回的位置。观察题目示例,我们可以将岛屿的周长中的边分为两类,如下图所示。黄色的边是与网格边界相邻的周长,而蓝色的边是与海洋格子相邻的周长。
      在这里插入图片描述
      当我们的 dfs 函数因为「坐标 (r, c) 超出网格范围」返回的时候,实际上就经过了一条黄色的边;而当函数因为「当前格子是海洋格子」返回的时候,实际上就经过了一条蓝色的边。这样,我们就把岛屿的周长跟 DFS 遍历联系起来了,我们的题解代码也呼之欲出:
public int islandPerimeter(int[][] grid) {
    for (int r = 0; r < grid.length; r++) {
        for (int c = 0; c < grid[0].length; c++) {
            if (grid[r][c] == 1) {
                // 题目限制只有一个岛屿,计算一个即可
                return dfs(grid, r, c);
            }
        }
    }
    return 0;
}

int dfs(int[][] grid, int r, int c) {
    // 函数因为「坐标 (r, c) 超出网格范围」返回,对应一条黄色的边
    if (!inArea(grid, r, c)) {
        return 1;
    }
    // 函数因为「当前格子是海洋格子」返回,对应一条蓝色的边
    if (grid[r][c] == 0) {
        return 1;
    }
    // 函数因为「当前格子是已遍历的陆地格子」返回,和周长没关系
    if (grid[r][c] != 1) {
        return 0;
    }
    grid[r][c] = 2;
    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;
}
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值