岛屿问题套路总结

981 阅读6分钟

基本介绍

网格问题的基本概念

我们首先明确一下岛屿问题中的网格结构是如何定义的,以方便我们后面的讨论。

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

岛屿问题是一类典型的网格问题。每个格子中的数字可能是 0 或者 1。我们把数字为 0 的格子看成海洋格子,数字为 1 的格子看成陆地格子,这样相邻的陆地格子就连接成一个岛屿。

image.png

在这样一个设定下,就出现了各种岛屿问题的变种,包括岛屿的数量、面积、周长等。不过这些问题,基本都可以用 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 caseroot == null。这样一个条件判断其实有两个含义:一方面,这表示 root 指向的子树为空,不需要再往下遍历了。另一方面,在 root == null 的时候及时返回,可以让后面的 root.leftroot.right 操作不会出现空指针异常。

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

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

image.png

其次,网格 DFS 中的 base case 是什么?从二叉树的 base case 对应过来,应该是网格中不需要继续遍历、grid[r][c] 会出现数组下标越界异常的格子,也就是那些超出网格范围的格子。

image.png

这一点稍微有些反直觉,坐标竟然可以临时超出网格的范围?这种方法我称为「先污染后治理」—— 甭管当前是在哪个格子,先往四个方向走一步再说,如果发现走出了网格范围再赶紧返回。这跟二叉树的遍历方法是一样的,先递归调用,发现 root == null 再返回。

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

private void dfs(char[][] grid, int i, int j) {
    //超出网格范围
    if (i >= grid.length || j >= grid[0].length || i < 0 || j < 0) {
        return;
    }

    dfs(grid, i - 1, j);
    dfs(grid, i + 1, j);
    dfs(grid, i, j - 1);
    dfs(grid, i, j + 1);
}

如何避免重复遍历

网格结构的 DFS 与二叉树的 DFS 最大的不同之处在于,遍历中可能遇到遍历过的结点。这是因为,网格结构本质上是一个「图」,我们可以把每个格子看成图中的结点,每个结点有向上下左右的四条边。在图中遍历时,自然可能遇到重复遍历结点。

这时候,DFS 可能会不停地「兜圈子」,永远停不下来,如下图所示:

7fec64afe8ab72c5df17d6a41a9cc9ba3879f58beec54a8791cbf108b9fd0685.gif

如何避免这样的重复遍历呢?答案是标记已经遍历过的格子。以岛屿问题为例,我们需要在所有值为 1 的陆地格子上做 DFS 遍历。每走过一个陆地格子,就把格子的值改为 2,这样当我们遇到 2 的时候,就知道这是遍历过的格子了。也就是说,每个格子可能取三个值:

  • 0 —— 海洋格子
  • 1 —— 陆地格子(未遍历过)
  • 2 —— 陆地格子(已遍历过) 我们在框架代码中加入避免重复遍历的语句:
private void dfs(char[][] grid, int i, int j) {
    //超出网格范围
    if (i >= grid.length || j >= grid[0].length || i < 0 || j < 0) {
        return;
    }

    //要么是海洋,要么已经遍历过的陆地
    if (grid[i][j] != '1') {
        return;
    }

    //标记已经遍历过的陆地
    grid[i][j] = '2';
    dfs(grid, i - 1, j);
    dfs(grid, i + 1, j);
    dfs(grid, i, j - 1);
    dfs(grid, i, j + 1);
}

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

在一些题解中,可能会把「已遍历过的陆地格子」标记为和海洋格子一样的 0,美其名曰「陆地沉没方法」,即遍历完一个陆地格子就让陆地「沉没」为海洋。这种方法看似很巧妙,但实际上有很大隐患,因为这样我们就无法区分「海洋格子」和「已遍历过的陆地格子」了。如果题目更复杂一点,这很容易出 bug。

200. 岛屿数量

题目链接:leetcode-cn.com/problems/nu…

image.png

这是力扣第 200 题「岛屿数量」,最简单也是最经典的一道岛屿问题,题目会输入一个二维数组grid,其中只包含0或者10代表海水,1代表陆地,且假设该矩阵四周都是被海水包围着的。

我们说连成片的陆地形成岛屿,那么请你写一个算法,计算这个矩阵grid中岛屿的个数,函数签名如下:

int numIslands(char[][] grid);

比如说题目给你输入下面这个grid有四片岛屿,算法应该返回 4:

image.png

代码如下:

class Solution {
    
    public int numIslands(char[][] grid) {
        private int res = 0;
        //遍历grid
        for (int i = 0; i < grid.length; i ++) {
            for (int j = 0; j < grid[0].length; j ++) {
                if (grid[i][j] == '1') {
                    //每发现一个岛屿,dfs深度搜索
                    dfs(grid, i, j);
                    //岛屿数量加一
                    res ++;
                }
            }
        }
        return res;
    }

    private void dfs(char[][] grid, int i, int j) {
        //超出网格范围
        if (i >= grid.length || j >= grid[0].length || i < 0 || j < 0) {
            return;
        }
        
        //要么是海洋,要么已经遍历过的陆地
        if (grid[i][j] != '1') {
            return;
        }
        
        //标记已经遍历过的陆地
        grid[i][j] = '2';
        dfs(grid, i - 1, j);
        dfs(grid, i + 1, j);
        dfs(grid, i, j - 1);
        dfs(grid, i, j + 1);
    }
}

为什么每次遇到岛屿,都要用 DFS 算法把岛屿「置为2」呢?主要是为了省事,避免维护visited数组。因为dfs函数遍历到值为不是1的位置会直接返回(海洋或者是已经遍历过的陆地),所以只要把经过的位置都设置为2,就可以起到不走回头路的作用。

1254. 统计封闭岛屿的数目

题目链接:leetcode-cn.com/problems/nu…

image.png

image.png

函数签名如下:

int closedIsland(int[][] grid)

比如题目给你输入如下这个二维矩阵:

image.png

算法返回 2,只有图中灰色部分的0是四周全都被海水包围着的「封闭岛屿」。

那么如何判断「封闭岛屿」呢?其实很简单,把上一题中那些靠边的岛屿排除掉,剩下的不就是「封闭岛屿」了吗

有了这个思路,就可以直接看代码了,注意这题规定0表示陆地,用1表示海水:

代码如下:

class Solution {
    public int closedIsland(int[][] grid) {
        int m = grid.length, n = grid[0].length;
        for(int j = 0; j < n; j++) {
            //把靠上边的岛屿淹没
            dfs(grid, 0, j);
            //把靠下边的岛屿淹没
            dfs(grid, m-1, j);
        }
        for(int i = 0; i < m; i++) {
            //把靠左边的岛屿淹没
            dfs(grid, i, 0);
            //把靠右边的岛屿淹没
            dfs(grid, i, n-1);
        }
        int res = 0;
        for(int i = 0; i < m; i++) {
            for(int j = 0; j < n; j++) {
                if(grid[i][j] == 0) {
                    res++;
                    dfs(grid, i, j);
                }
            }
        }
        return res;
    }

    /**
     * 从(i,j)开始,将与之相邻的陆地都变成海水
     */
    public void dfs(int[][] grid, int i, int j) {
        int m = grid.length, n = grid[0].length;
        if(i < 0 || j < 0 || i >= m || j >= n) {
            return;
        }
        if(grid[i][j] == 1) {
            //已经是海水了
            return;
        }
        //将(i,j)变成海水
        grid[i][j] = 1;
        //淹没上下左右的陆地
        dfs(grid, i+1, j);
        dfs(grid, i, j+1);
        dfs(grid, i-1, j);
        dfs(grid, i, j-1);
    }
}

只要提前把靠边的陆地都淹掉,然后算出来的就是封闭岛屿了。

1905. 统计子岛屿

题目链接:leetcode-cn.com/problems/co…

image.png

image.png

什么情况下grid2中的一个岛屿Bgrid1中的一个岛屿A的子岛?

当岛屿B中所有陆地在岛屿A中也是陆地的时候,岛屿B是岛屿A的子岛。

反过来说,如果岛屿B中存在一片陆地,在岛屿A的对应位置是海水,那么岛屿B就不是岛屿A的子岛

那么,我们只要遍历grid2中的所有岛屿,把那些不可能是子岛的岛屿排除掉,剩下的就是子岛。

代码如下:

class Solution {
    public int countSubIslands(int[][] grid1, int[][] grid2) {
        int m = grid1.length, n = grid1[0].length;
        for(int i = 0; i < m; i++) {
            for(int j = 0; j < n; j++) {
                if(grid1[i][j] == 0 && grid2[i][j] == 1) {
                    //这个岛屿肯定不是子岛屿,淹掉
                    dfs(grid2, i, j);
                }
            }
        }
        //现在grid2中剩下的岛屿都是子岛屿,计算岛屿的数量
        int res = 0;
        for(int i = 0; i < m; i++) {
            for(int j = 0; j < n; j++) {
                if(grid2[i][j] == 1) {
                    res++;
                    dfs(grid2, i, j);
                }
            }
        }
        return res;
    }   
    //从(i,j)开始,将与之相邻的陆地都变成海水
    void dfs(int[][] grid, int i, int j) {
        int m = grid.length, n = grid[0].length;
        if(i < 0 || j <0 || i >= m || j >= n) {
            return;
        }
        if(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);
    }
}

695. 岛屿的最大面积

题目链接:leetcode-cn.com/problems/ma…

image.png

image.png

int maxAreaOfIsland(int[][] grid)

比如题目给你输入如下一个二维矩阵:

image.png

其中面积最大的是橘红色的岛屿,算法返回它的面积 6。

这题的大体思路和之前完全一样,只不过dfs函数淹没岛屿的同时,还应该想办法记录这个岛屿的面积

我们可以给dfs函数设置返回值,记录每次淹没的陆地的个数,直接看解法吧:

代码如下:

class Solution {
    public int maxAreaOfIsland(int[][] grid) {
        int res = 0;
        int m = grid.length, n = grid[0].length;
        for(int i = 0; i < m; i++) {
            for(int j = 0; j < n; j++) {
                if(grid[i][j] == 1) {
                    res = Math.max(res, dfs(grid, i, j));
                }
            }
        }
        return res;
    }

    private int dfs(int[][] grid, int row, int col) {
        if (row >= grid.length || col >= grid[0].length || row < 0 || col < 0) {
            return 0;
        }

        if (grid[row][col] == 0) {
            return 0;
        }

        grid[row][col] = 0;
        return 1 + dfs(grid, row - 1, col)
                 + dfs(grid, row + 1, col)
                 + dfs(grid, row, col - 1)
                 + dfs(grid, row, col + 1);
    }
}

463. 岛屿的周长

题目链接:leetcode-cn.com/problems/is…

image.png

image.png

岛屿的周长是计算岛屿全部的「边缘」,而这些边缘就是我们在 DFS 遍历中,dfs 函数返回的位置。观察题目示例,我们可以将岛屿的周长中的边分为两类,如下图所示。黄色的边是与网格边界相邻的周长,而蓝色的边是与海洋格子相邻的周长。

image.png

当我们的 dfs 函数因为「坐标 (r, c) 超出网格范围」返回的时候,实际上就经过了一条黄色的边;而当函数因为「当前格子是海洋格子」返回的时候,实际上就经过了一条蓝色的边。这样,我们就把岛屿的周长跟 DFS 遍历联系起来了,我们的题解代码也呼之欲出:

代码如下:

class Solution {
    public int islandPerimeter(int[][] grid) {
        int res = 0;
        for(int i = 0; i < grid.length; i++) {
            for(int j = 0; j < grid[0].length; j++) {
                if(grid[i][j] == 1) {
                    res+= dfs(grid, i, j);
                }
            }
        }
        return res;
    }

    public int dfs(int[][] grid, int row, int col) {
        if(row >= grid.length || row < 0 || col >= grid[0].length || col < 0) {
            return 1;
        }
        if(grid[row][col] == 0) {
            return 1;
        }
        if(grid[row][col] != 1) {
            return 0;
        }
        grid[row][col] = 2;
        return dfs(grid, row+1, col) +
               dfs(grid, row-1, col) +
               dfs(grid, row, col+1) +
               dfs(grid, row, col-1);
    }
}