网格结构比二叉树结构稍微复杂一点,它其实是一种简化版的图的结构。要写好网格上的DFS遍历,我们首先要理解二叉树上的DFS遍历方法,再类比写出网格结构上的DFS遍历。我们写的二叉树DFS遍历一般是:
public 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遍历的框架代码:
public 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);
}
public void 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的时候,就知道这是遍历过的格子 。就是说,每个格子可能取三个值:
加入避免重复遍历的语句之后:
public 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);
}
//判断坐标是否在网格中
public boolean inArea(int[][] grid, int r, int c) {
return 0 <= r && r < grid.length && 0 <= c && c < grid[0].length;
}
给定一个包含了一些0和1的非空二维数组 grid,一个岛屿是一组相邻的1(代表陆地),这里的相邻要求两个1必须在水平或者竖直方向上相邻。你可以假设 grid 的四个边缘都被0(代表海洋)包围着。
找到给定的二维数组中最大的岛屿面积。如果没有岛屿,则返回面积为0。
?这道题只需要对每个岛屿做DFS遍历,求出每个岛屿的面积就可以了。求岛屿面积的方法也很简单,代码如下:
public int maxAreaOfIsland(int[][] grid) {
int res = 0;
for(int r = 0; r < grid.length; r++) {
for(int c = 0; c < grid[0].length; r++) {
if(grid[r][c] == 1) {
int a = area(grid, r, c);
res = Math.max(res, a);
}
}
}
return res;
}
public int area(int[][] grid, int r, int c) {
if(!inArea(grid, r, c)) {
return 0;
}
if(grid[r][c] != 1) {
return 0;
}
grid[r][c] = 2;
return 1
+ area(grid, r-1, c)
+ area(grid, r+1, c)
+ area(grid, r, c-1)
+ area(grid, r, c+1);
}
public boolean inArea(int[][] grid, int r, int c) {
return 0 <= r && r < grid.length
&& 0 <= c && c < grid[0].length;
}
这道题是岛屿最大面积的升级版。现在我们有填海造陆的能力,可以把一个海洋格子变成陆地格子,进而让两块岛屿连成一块。那么填海造陆之后,最大可能构造出多大的岛屿呢?
大致思路:我们先计算出所有岛屿的面积,在所有的格子上标记出岛屿的面积。然后搜索哪个海洋格子相邻的两个岛屿面积最大。例如下图中红色方框内的海洋格子,上边、左边都与岛屿相邻,我们可以计算出它变成陆地以后可以连接成的岛屿面积为7+1+2=10?
然后,这种做法可能遇到一个问题。如下图种红色方框内的海洋格子,它的上边、左边都与岛屿相邻,这时候连接成的岛屿面积难道是 7+1+7 ?!显然不是。这两个7来自同一个岛屿,所以填海造陆之后得到的岛屿面积应该只有 7+1=8?
?所以,我们要区分一个海洋格子相邻的两个7是不是来自同一个岛屿。所以,我们在方格中不能标记岛屿的面积,而应该标记岛屿的面积,如下图所示。这样我们就可以发现红色方框内的海洋格子,它的两个相邻的岛屿实际上是同一个。
可以看到,这道题实际上是对网格做了两边DFS:第一步遍历,计算面积并标记;第二遍DFS遍历,观察每个海洋相邻的陆地格子
class Solution {
public int largestIsland(int[][] grid) {
if (grid == null || grid.length == 0) return 1;
int result = 0, index = 2;
HashMap<Integer, Integer> areasMap = new HashMap();
for (int i = 0; i < grid.length; i++)
for (int j = 0; j < grid[0].length; j++)
if (grid[i][j] == 1) areasMap.put(index, calculateAreas(index++, grid, i, j)); // 只计算未编号的岛屿
if (areasMap.size() == 0) return 1; // 没有岛屿,全是海洋
for (int i = 0; i < grid.length; i++) {
for (int j = 0; j < grid[0].length; j++) {
if (grid[i][j] == 0) {
Set<Integer> islands = getIslands(grid, i, j);
if (islands.size() == 0) continue; // 周围没有岛屿
result = Math.max(result, islands.stream().map(item -> areasMap.get(item)).reduce(Integer::sum).orElse(0) + 1);
}
}
}
if (result == 0) return areasMap.get(2); // 全是岛屿,没有海洋
return result;
}
public int calculateAreas(int index, int[][] grid, int row, int column) {
if (!isLegal(grid, row, column) || grid[row][column] != 1) return 0;
grid[row][column] = index;
return calculateAreas(index, grid, row + 1, column) + calculateAreas(index, grid, row - 1, column) + calculateAreas(index, grid, row, column - 1) + calculateAreas(index, grid, row, column + 1) + 1;
}
public boolean isLegal(int[][] grid, int row, int column) {
return row >= 0 && row < grid.length && column >= 0 && column < grid[0].length;
}
public Set<Integer> getIslands(int[][] grid, int row, int column) {
Set<Integer> result = new HashSet<>();
if (isLegal(grid, row + 1, column) && grid[row + 1][column] != 0)
result.add(grid[row + 1][column]);
if (isLegal(grid, row - 1, column) && grid[row - 1][column] != 0)
result.add(grid[row - 1][column]);
if (isLegal(grid, row, column - 1) && grid[row][column - 1] != 0)
result.add(grid[row][column - 1]);
if (isLegal(grid, row, column + 1) && grid[row][column + 1] != 0)
result.add(grid[row][column + 1]);
return result;
}
}
网格中的格子?水平和垂直?方向相连(对角线方向不相连)。整个网格被水完全包围,但其中恰好有一个岛屿(或者说,一个或多个表示陆地的格子相连组成的岛屿)。
岛屿中没有“湖”(“湖” 指水域在岛屿内部且不和岛屿周围的水相连)。格子是边长为 1 的正方形。网格为长方形,且宽度和高度均不超过 100 。计算这个岛屿的周长。
说实话,这道题用DFS来解并不是最优的方法。对于岛屿,直接用数学的方法求周长会更容易。不过这道题是一个很好理解DFS遍历过程的例题。
岛屿的周长是计算岛屿全部的边缘,而这些边缘就是我们在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;
}
public int dfs(int[][] grid, int r, int c) {
if(!inArea(grid, r, c)) {
return 1;
}
if(grid[r][c] == 0) {
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);
}
public boolean inArea(int[][] grid, int r, int c) {
return 0 <= r && r < grid.length
&& 0 <= c && c < grid[0].length;
}