岛屿问题(DFS)

DFS的基本结构

网格结构比二叉树结构稍微复杂一点,它其实是一种简化版的图的结构。要写好网格上的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的时候,就知道这是遍历过的格子 。就是说,每个格子可能取三个值:

  • 0 —— 海洋格子
  • 1 —— 陆地格子(未遍历过)
  • 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;
}

例题

1、岛屿的最大面积

695. 岛屿的最大面积 - 力扣(LeetCode)

给定一个包含了一些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;
}

2、填海造陆问题

827. 最大人工岛 - 力扣(LeetCode)

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

大致思路:我们先计算出所有岛屿的面积,在所有的格子上标记出岛屿的面积。然后搜索哪个海洋格子相邻的两个岛屿面积最大。例如下图中红色方框内的海洋格子,上边、左边都与岛屿相邻,我们可以计算出它变成陆地以后可以连接成的岛屿面积为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;
    }
}

3、岛屿的周长

463. 岛屿的周长 - 力扣(LeetCode)

网格中的格子 水平和垂直 方向相连(对角线方向不相连)。整个网格被水完全包围,但其中恰好有一个岛屿(或者说,一个或多个表示陆地的格子相连组成的岛屿)。

岛屿中没有“湖”(“湖” 指水域在岛屿内部且不和岛屿周围的水相连)。格子是边长为 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;
}

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值