摘要
在leetcode上经常遇见有关于岛屿问题的一系列问题。比如:
- L200. 岛屿数量 (Easy)
- 463. 岛屿的周长 (Easy)
- 695. 岛屿的最大面积 (Medium)
- 827. 最大人工岛 (Hard)
我们所熟悉的 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 再返回。
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。
二、LeetCode200 岛屿问题
2.1 深度优先遍历DFS算法
目标是找到矩阵中 “岛屿的数量” ,上下左右相连的 1
都被认为是连续岛屿。
主循环:
遍历整个矩阵,当遇到 grid[i][j] == '1' 时,从此点开始做深度优先搜索 dfs,岛屿数 count + 1 且在深度优先搜索中删除此岛屿。
dfs方法: 设目前指针指向一个岛屿中的某一点 (i, j)
,寻找包括此点的岛屿边界。
从 (i, j) 向此点的上下左右 (i+1,j),(i-1,j),(i,j+1),(i,j-1) 做深度搜索。
终止条件:
(i, j) 越过矩阵边界;
grid[i][j] == 0,代表此分支已越过岛屿边界。
搜索岛屿的同时,执行 grid[i][j] = '0',即将岛屿所有节点删除,以免之后重复搜索相同岛屿。
最终返回岛屿数 count
即可。
package 数组算法;
public class numIslands200 {
/**
* 这是一个回溯问题
*
* @param grid
* @return
*/
// 表示四个方向
int[] dx = {1, 0, -1, 0};
int[] dy = {0, 1, 0, -1};
/**
* 岛屿问题
* @param grid
* @return
*/
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;
}
/**
* 这是一个DFS算法,不需要的回溯来判断
*
* @param grid
* @param i
* @param j
*/
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') {
//表示超出返回同时排除 等于0的情况
return;
}
// 如果不是的那就设置新的
grid[i][j] = '0';
// 开始遍历的四个方向
for (int k = 0; k < 4; k++) {
int new_i = dx[k] + i;
int new_j = dy[k] + j;
dfs(grid, new_i, new_j);
}
}
}
2.2 广度优先遍历 BFS
主循环和思路一类似,不同点是在于搜索某岛屿边界的方法不同。
bfs 方法:
- 借用一个队列 queue,判断队列首部节点 (i, j) 是否未越界且为 1:
- 若是则置零(删除岛屿节点),并将此节点上下左右节点 (i+1,j),(i-1,j),(i,j+1),(i,j-1) 加入队列;
- 若不是则跳过此节点;
- 循环 pop 队列首节点,直到整个队列为空,此时已经遍历完此岛屿。
/**
* @param grid
* @return
*/
public int numIslandsV1(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') {
bfs(grid, i, j);
count++;
}
}
}
return count;
}
private void bfs(char[][] grid, int i, int j) {
Queue<int[]> list = new LinkedList<>();
//如果是1 那就加入进来
list.add(new int[]{i, j});
//判断list是否为空
while (!list.isEmpty()) {
// 将list中的删除 记录原来的x y的坐标
int[] cur = list.remove();
i = cur[0];
j = cur[1];
// 如果是的在范围之内的话,
if (0 <= i && i < grid.length && 0 <= j && j < grid[0].length && grid[i][j] == '1') {
grid[i][j] = '0';
for (int k = 0; k < 4; k++) {
int new_i = dx[k] + i;
int new_j = dy[k] + j;
list.add(new int[]{new_i, new_j});
}
}
}
}
三、岛屿的最大面积
LeetCode 695. Max Area of Island (Medium)
package 数组算法;
public class maxAreaOfIsland695 {
/**
* 岛屿面积最大的问题
*
* @param grid
* @return
*/
// 表示四个方向
int[] dx = {1, 0, -1, 0};
int[] dy = {0, 1, 0, -1};
public int maxAreaOfIsland(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) {
int area = dfs(grid, i, j);
res = Math.max(res, area);
}
}
}
return res;
}
private int dfs(int[][] grid, int i, int j) {
// 排除的范围不在内部的情况
if (i < 0 || i >= grid.length || j < 0 || j >= grid[0].length) {
return 0;
}
//排除 0 和2的数字
if (grid[i][j] != 1) {
return 0;
}
// 只能是1的时候
grid[i][j] = 2;
int sum =1;
// 剩下的就是等于的0的数字
for (int k = 0; k < 4; k++) {
int new_i = dx[k] + i;
int new_j = dy[k] + j;
sum += dfs(grid, new_i, new_j);
}
return sum;
}
}
四、填海造陆问题
LeetCode 827. Making A Large Island (Hard)
五、岛屿的周长问题
LeetCode 463. Island Perimeter (Easy)
第一种方法:
对于一个陆地格子的每条边,它被算作岛屿的周长当且仅当这条边为网格的边界或者相邻的另一个格子为水域。 因此,我们可以遍历每个陆地格子,看其四个方向是否为边界或者水域,如果是,将这条边的贡献(即 1)加入答案 ans 中即可。
第二种方法:
我们也可以将方法一改成深度优先搜索遍历的方式,此时遍历的方式可扩展至统计多个岛屿各自的周长。需要注意的是为了防止陆地格子在深度优先搜索中被重复遍历导致死循环,我们需要将遍历过的陆地格子标记为已经遍历过,下面的代码中我们设定值为 2 的格子为已经遍历过的陆地格子。
package 数组算法;
/**
* @Classname islandPerimeterV2
* @Description TODO
* @Date 2022/3/22 7:19
* @Created by xjl
*/
public class islandPerimeterV2 {
// 表示的四个方向
static int[] dx = {0, 1, 0, -1};
static int[] dy = {1, 0, -1, 0};
/**
* @description 判断格子周围是否为水,所以是需要的遍历格子,判断他的四个方向是否为水或者是超过了范围的结果
* @param: grid
* @date: 2022/3/22 7:19
* @return: int
* @author: xjl
*/
public int islandPerimeter(int[][] grid) {
int n = grid.length;
int m = grid[0].length;
int ans = 0;
for (int i = 0; i < n; ++i) {
for (int j = 0; j < m; ++j) {
// 如果是陆地 表示1的为陆地
if (grid[i][j] == 1) {
int cnt = 0;
// 开始统计这个四周是否为
for (int k = 0; k < 4; ++k) {
int tx = i + dx[k];
int ty = j + dy[k];
if (tx < 0 || tx >= n || ty < 0 || ty >= m || grid[tx][ty] == 0) {
// 表示的超过的范围的和水的都是需要计算
cnt += 1;
}
}
ans += cnt;
}
}
}
return ans;
}
public int islandPerimeterV2(int[][] grid) {
int n = grid.length;
int m = grid[0].length;
int ans = 0;
for (int i = 0; i < n; ++i) {
for (int j = 0; j < m; ++j) {
if (grid[i][j] == 1) {
ans += dfs(i, j, grid, n, m);
}
}
}
return ans;
}
private int dfs(int x, int y, int[][] grid, int n, int m) {
// 表示的超过的范围的和水的都是需要计算
if (x < 0 || x >= n || y < 0 || y >= m || grid[x][y] == 0) {
return 1;
}
//表示的访问过了
if (grid[x][y] == 2) {
return 0;
}
// 赋值为2表示已经访问
grid[x][y] = 2;
// 重新开始的计算结果
int res = 0;
for (int i = 0; i < 4; ++i) {
int tx = x + dx[i];
int ty = y + dy[i];
res += dfs(tx, ty, grid, n, m);
}
// 返回当前的一个结果的
return res;
}
}