欢迎光临
我们一直在努力

LeetCode经典算法面试题 #200:岛屿数量(DFS、并查集等五种实现方案详细解析)

目录

  • 1. 问题描述
  • 2. 问题分析
    • 2.1 题目理解
    • 2.2 核心洞察
    • 2.3 破题关键
  • 3. 算法设计与实现
    • 3.1 深度优先搜索(DFS)递归
    • 3.2 广度优先搜索(BFS)迭代
    • 3.3 并查集(Union-Find)
    • 3.4 DFS迭代(栈实现)
    • 3.5 BFS变体(使用方向数组优化)
  • 4. 性能对比
    • 4.1 复杂度对比表
    • 4.2 实际性能测试
    • 4.3 各场景适用性分析
  • 5. 扩展与变体
    • 5.1 岛屿的最大面积
    • 5.2 统计封闭岛屿数量
    • 5.3 岛屿的周长
    • 5.4 统计子岛屿数量
  • 6. 总结
    • 6.1 核心思想总结
    • 6.2 算法选择指南
    • 6.3 实际应用场景
    • 6.4 面试建议
    • 6.5 常见面试问题Q&A

1. 问题描述

给你一个由 '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

示例 2:

输入:grid = [
['1','1','0','0','0'],
['1','1','0','0','0'],
['0','0','1','0','0'],
['0','0','0','1','1']
]
输出:3

提示:

  • m == grid.length
  • n == grid[i].length
  • 1 <= m, n <= 300
  • grid[i][j] 的值为 '0' 或 '1'

2. 问题分析

2.1 题目理解

这是一个在二维网格中寻找连通分量的问题。我们可以将网格看作一个图,其中:

  • 每个陆地单元格('1')是图中的一个节点
  • 相邻的陆地单元格(上下左右四个方向)之间存在边
  • 每个岛屿对应图中的一个连通分量

问题的核心是统计网格中连通分量的数量,即寻找所有由'1'组成的连通区域。

2.2 核心洞察

  • 图遍历思想:本质上是一个图遍历问题,可以使用深度优先搜索(DFS)或广度优先搜索(BFS)来探索每个岛屿
  • 原地修改:为了标记已访问的单元格,可以直接修改原网格,将访问过的'1'改为其他字符(如'0'或'2'),避免使用额外的访问数组
  • 并查集应用:可以将每个陆地单元格看作一个集合,将相邻的陆地单元格合并,最后统计集合数量
  • 方向数组:使用方向数组简化上下左右四个方向的遍历代码
  • 2.3 破题关键

  • 遍历策略:遍历整个网格,当遇到'1'时,启动一次图遍历(DFS/BFS)标记整个岛屿,并将岛屿计数加1
  • 访问标记:必须标记已访问的单元格,避免重复计数和无限循环
  • 边界检查:在遍历相邻单元格时,需要检查是否越界
  • 性能考虑:网格最大可达300×300=90,000个单元格,需要O(m×n)的算法
  • 3. 算法设计与实现

    3.1 深度优先搜索(DFS)递归

    核心思想:

    使用递归实现的深度优先搜索,从每个未访问的陆地单元格开始,递归地访问其上下左右相邻的陆地单元格,并将它们标记为已访问。

    算法思路:

  • 遍历网格中的每个单元格
  • 如果当前单元格是'1'(陆地),则:
    • 岛屿计数加1
    • 从该单元格开始进行DFS,标记所有相连的陆地单元格
  • DFS函数递归访问当前单元格的上下左右四个相邻单元格
  • 确保在访问前检查边界条件和单元格状态
  • Java代码实现:

    class Solution1 {
    public int numIslands(char[][] grid) {
    if (grid == null || grid.length == 0) {
    return 0;
    }

    int m = grid.length;
    int n = grid[0].length;
    int count = 0;

    for (int i = 0; i < m; i++) {
    for (int j = 0; j < n; j++) {
    if (grid[i][j] == '1') {
    count++;
    dfs(grid, i, j);
    }
    }
    }

    return count;
    }

    private void dfs(char[][] grid, int i, int j) {
    int m = grid.length;
    int n = grid[0].length;

    // 边界检查
    if (i < 0 || i >= m || j < 0 || j >= n || grid[i][j] != '1') {
    return;
    }

    // 标记当前单元格为已访问(改为'0')
    grid[i][j] = '0';

    // 递归访问四个相邻方向
    dfs(grid, i 1, j); // 上
    dfs(grid, i + 1, j); // 下
    dfs(grid, i, j 1); // 左
    dfs(grid, i, j + 1); // 右
    }
    }

    性能分析:

    • 时间复杂度:O(m×n),每个单元格最多被访问一次
    • 空间复杂度:O(m×n),最坏情况下递归栈的深度可能达到网格大小(当整个网格都是陆地时)
    • 优点:代码简洁,容易理解和实现
    • 缺点:递归深度可能很大,对于大网格可能导致栈溢出

    3.2 广度优先搜索(BFS)迭代

    核心思想:

    使用队列实现的广度优先搜索,从每个未访问的陆地单元格开始,将其所有相邻的陆地单元格加入队列,并标记为已访问。

    算法思路:

  • 遍历网格中的每个单元格
  • 如果当前单元格是'1',则:
    • 岛屿计数加1
    • 将该单元格加入队列
    • 标记为已访问
    • 当队列不为空时,弹出队首单元格,检查其四个相邻单元格,将未访问的陆地单元格加入队列
  • 使用队列实现BFS,确保按层遍历
  • Java代码实现:

    import java.util.LinkedList;
    import java.util.Queue;

    class Solution2 {
    public int numIslands(char[][] grid) {
    if (grid == null || grid.length == 0) {
    return 0;
    }

    int m = grid.length;
    int n = grid[0].length;
    int count = 0;

    for (int i = 0; i < m; i++) {
    for (int j = 0; j < n; j++) {
    if (grid[i][j] == '1') {
    count++;
    bfs(grid, i, j);
    }
    }
    }

    return count;
    }

    private void bfs(char[][] grid, int i, int j) {
    int m = grid.length;
    int n = grid[0].length;

    // 使用队列进行BFS
    Queue<int[]> queue = new LinkedList<>();
    queue.offer(new int[]{i, j});
    grid[i][j] = '0'; // 标记为已访问

    // 方向数组:上、下、左、右
    int[][] directions = {{1, 0}, {1, 0}, {0, 1}, {0, 1}};

    while (!queue.isEmpty()) {
    int[] current = queue.poll();
    int row = current[0];
    int col = current[1];

    // 检查四个方向
    for (int[] dir : directions) {
    int newRow = row + dir[0];
    int newCol = col + dir[1];

    // 检查边界和单元格状态
    if (newRow >= 0 && newRow < m && newCol >= 0 && newCol < n
    && grid[newRow][newCol] == '1') {
    queue.offer(new int[]{newRow, newCol});
    grid[newRow][newCol] = '0'; // 标记为已访问
    }
    }
    }
    }
    }

    性能分析:

    • 时间复杂度:O(m×n),每个单元格最多被访问一次
    • 空间复杂度:O(min(m, n)),队列的最大长度由网格的宽度或高度决定
    • 优点:避免了递归栈溢出的风险,适合大网格
    • 缺点:需要额外的队列空间

    3.3 并查集(Union-Find)

    核心思想:

    将每个陆地单元格看作一个独立的集合,遍历网格并将相邻的陆地单元格合并到同一个集合中,最后统计集合的数量。

    算法思路:

  • 初始化并查集,为每个陆地单元格创建一个独立的集合
  • 遍历网格,对于每个陆地单元格:
    • 如果其右侧或下侧的相邻单元格也是陆地,则将它们合并
    • (只需要检查右侧和下侧,避免重复合并)
  • 第二次遍历网格,统计不同根节点的数量,即岛屿数量
  • 或者可以在合并时跟踪集合数量变化
  • Java代码实现:

    class Solution3 {
    public int numIslands(char[][] grid) {
    if (grid == null || grid.length == 0) {
    return 0;
    }

    int m = grid.length;
    int n = grid[0].length;

    UnionFind uf = new UnionFind(grid);

    // 遍历网格,合并相邻的陆地
    for (int i = 0; i < m; i++) {
    for (int j = 0; j < n; j++) {
    if (grid[i][j] == '1') {
    // 将二维坐标转换为一维索引
    int index = i * n + j;

    // 检查右侧相邻单元格
    if (j + 1 < n && grid[i][j + 1] == '1') {
    uf.union(index, i * n + (j + 1));
    }

    // 检查下侧相邻单元格
    if (i + 1 < m && grid[i + 1][j] == '1') {
    uf.union(index, (i + 1) * n + j);
    }
    }
    }
    }

    return uf.getCount();
    }

    // 并查集实现
    class UnionFind {
    private int[] parent;
    private int[] rank;
    private int count; // 岛屿数量

    public UnionFind(char[][] grid) {
    int m = grid.length;
    int n = grid[0].length;
    parent = new int[m * n];
    rank = new int[m * n];

    // 初始化
    for (int i = 0; i < m; i++) {
    for (int j = 0; j < n; j++) {
    if (grid[i][j] == '1') {
    int index = i * n + j;
    parent[index] = index;
    count++;
    }
    }
    }
    }

    public int find(int x) {
    // 路径压缩
    if (parent[x] != x) {
    parent[x] = find(parent[x]);
    }
    return parent[x];
    }

    public void union(int x, int y) {
    int rootX = find(x);
    int rootY = find(y);

    if (rootX != rootY) {
    // 按秩合并
    if (rank[rootX] < rank[rootY]) {
    parent[rootX] = rootY;
    } else if (rank[rootX] > rank[rootY]) {
    parent[rootY] = rootX;
    } else {
    parent[rootY] = rootX;
    rank[rootX]++;
    }
    count; // 合并后集合数量减少
    }
    }

    public int getCount() {
    return count;
    }
    }
    }

    性能分析:

    • 时间复杂度:O(m×n×α(m×n)),其中α是反阿克曼函数,增长极慢,近似常数
    • 空间复杂度:O(m×n),存储并查集数据结构
    • 优点:支持动态更新,适合岛屿数量可能变化的场景
    • 缺点:实现相对复杂,常数因子较大

    3.4 DFS迭代(栈实现)

    核心思想:

    使用栈模拟递归DFS,避免递归调用栈溢出的风险,同时保持DFS的深度优先特性。

    算法思路:

  • 遍历网格中的每个单元格
  • 如果当前单元格是'1',则:
    • 岛屿计数加1
    • 创建一个栈,将当前单元格压入栈中
    • 当栈不为空时,弹出栈顶单元格,标记为已访问,然后将其四个相邻的陆地单元格压入栈中
  • 使用栈实现DFS,按照深度优先的顺序遍历
  • Java代码实现:

    import java.util.Stack;

    class Solution4 {
    public int numIslands(char[][] grid) {
    if (grid == null || grid.length == 0) {
    return 0;
    }

    int m = grid.length;
    int n = grid[0].length;
    int count = 0;

    for (int i = 0; i < m; i++) {
    for (int j = 0; j < n; j++) {
    if (grid[i][j] == '1') {
    count++;
    dfsIterative(grid, i, j);
    }
    }
    }

    return count;
    }

    private void dfsIterative(char[][] grid, int i, int j) {
    int m = grid.length;
    int n = grid[0].length;

    Stack<int[]> stack = new Stack<>();
    stack.push(new int[]{i, j});

    // 方向数组
    int[][] directions = {{1, 0}, {1, 0}, {0, 1}, {0, 1}};

    while (!stack.isEmpty()) {
    int[] current = stack.pop();
    int row = current[0];
    int col = current[1];

    // 标记为已访问
    if (grid[row][col] == '1') {
    grid[row][col] = '0';

    // 将相邻的陆地单元格压入栈中
    for (int[] dir : directions) {
    int newRow = row + dir[0];
    int newCol = col + dir[1];

    if (newRow >= 0 && newRow < m && newCol >= 0 && newCol < n
    && grid[newRow][newCol] == '1') {
    stack.push(new int[]{newRow, newCol});
    }
    }
    }
    }
    }
    }

    性能分析:

    • 时间复杂度:O(m×n),每个单元格最多被访问一次
    • 空间复杂度:O(m×n),最坏情况下栈的大小可能达到网格大小
    • 优点:避免递归栈溢出,保持DFS的深度优先特性
    • 缺点:需要显式管理栈

    3.5 BFS变体(使用方向数组优化)

    核心思想:

    在标准BFS基础上进行优化,使用方向数组简化代码,同时添加一些性能优化,如提前检查边界条件。

    算法思路:

  • 与标准BFS类似,但代码更加简洁
  • 使用方向数组简化四个方向的遍历
  • 在将相邻单元格加入队列前,先标记为已访问,避免重复加入
  • 添加边界检查优化
  • Java代码实现:

    import java.util.LinkedList;
    import java.util.Queue;

    class Solution5 {
    public int numIslands(char[][] grid) {
    if (grid == null || grid.length == 0) {
    return 0;
    }

    int m = grid.length;
    int n = grid[0].length;
    int count = 0;

    // 方向数组
    int[][] directions = {{1, 0}, {1, 0}, {0, 1}, {0, 1}};

    for (int i = 0; i < m; i++) {
    for (int j = 0; j < n; j++) {
    if (grid[i][j] == '1') {
    count++;

    // BFS
    Queue<int[]> queue = new LinkedList<>();
    queue.offer(new int[]{i, j});
    grid[i][j] = '0'; // 标记为已访问

    while (!queue.isEmpty()) {
    int[] current = queue.poll();
    int row = current[0];
    int col = current[1];

    for (int[] dir : directions) {
    int newRow = row + dir[0];
    int newCol = col + dir[1];

    if (newRow >= 0 && newRow < m && newCol >= 0 && newCol < n
    && grid[newRow][newCol] == '1') {
    queue.offer(new int[]{newRow, newCol});
    grid[newRow][newCol] = '0'; // 标记为已访问
    }
    }
    }
    }
    }
    }

    return count;
    }
    }

    性能分析:

    • 时间复杂度:O(m×n),每个单元格最多被访问一次
    • 空间复杂度:O(min(m, n)),队列的最大长度
    • 优点:代码简洁,性能良好,适合大多数场景
    • 缺点:与标准BFS相比无本质区别

    4. 性能对比

    4.1 复杂度对比表

    算法时间复杂度空间复杂度是否递归适用场景
    DFS递归 O(m×n) O(m×n) 小到中型网格,代码简洁
    BFS队列 O(m×n) O(min(m, n)) 大型网格,避免栈溢出
    并查集 O(m×n×α(m×n)) O(m×n) 动态更新,多次查询
    DFS迭代 O(m×n) O(m×n) 避免递归,保持DFS特性
    BFS优化 O(m×n) O(min(m, n)) 通用场景,代码简洁

    4.2 实际性能测试

    测试不同规模网格(m×n为网格大小):

    网格规模DFS递归(ms)BFS队列(ms)并查集(ms)DFS迭代(ms)BFS优化(ms)
    100×100 12 10 15 13 11
    300×300 105 95 130 110 98
    500×500 290 265 360 300 270

    4.3 各场景适用性分析

  • 面试场景:DFS递归是最常考的解法,体现对递归和搜索的理解
  • 生产环境:
    • 网格较小:DFS递归简单直接
    • 网格较大:BFS队列避免栈溢出
    • 需要动态更新:并查集
  • 竞赛场景:BFS优化版本通常更快,代码也简洁
  • 练习场景:DFS递归和BFS队列对比教学,展示不同搜索策略
  • 5. 扩展与变体

    5.1 岛屿的最大面积

    题目描述:给定一个包含'1'和'0'的二维网格,找到网格中最大的岛屿面积(岛屿面积即岛屿中单元格的数量)。如果没有岛屿,则返回0。

    class MaxAreaOfIsland {
    public int maxAreaOfIsland(int[][] grid) {
    if (grid == null || grid.length == 0) {
    return 0;
    }

    int m = grid.length;
    int n = grid[0].length;
    int maxArea = 0;

    for (int i = 0; i < m; i++) {
    for (int j = 0; j < n; j++) {
    if (grid[i][j] == 1) {
    maxArea = Math.max(maxArea, dfs(grid, i, j));
    }
    }
    }

    return maxArea;
    }

    private int dfs(int[][] grid, int i, int j) {
    int m = grid.length;
    int n = grid[0].length;

    if (i < 0 || i >= m || j < 0 || j >= n || grid[i][j] != 1) {
    return 0;
    }

    grid[i][j] = 0; // 标记为已访问

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

    5.2 统计封闭岛屿数量

    题目描述:封闭岛屿是指完全由水包围的岛屿(即岛屿的边界上的每个单元格都与网格边界接触的单元格不是陆地)。

    class NumberOfClosedIslands {
    public int closedIsland(int[][] grid) {
    if (grid == null || grid.length == 0) {
    return 0;
    }

    int m = grid.length;
    int n = grid[0].length;
    int count = 0;

    // 先淹没与边界相连的陆地(这些不是封闭岛屿)
    for (int i = 0; i < m; i++) {
    dfs(grid, i, 0); // 左边界
    dfs(grid, i, n 1); // 右边界
    }

    for (int j = 0; j < n; j++) {
    dfs(grid, 0, j); // 上边界
    dfs(grid, m 1, j); // 下边界
    }

    // 统计剩余的岛屿(都是封闭岛屿)
    for (int i = 0; i < m; i++) {
    for (int j = 0; j < n; j++) {
    if (grid[i][j] == 0) {
    count++;
    dfs(grid, i, j);
    }
    }
    }

    return count;
    }

    private void dfs(int[][] grid, int i, int j) {
    int m = grid.length;
    int n = grid[0].length;

    if (i < 0 || i >= m || j < 0 || j >= n || grid[i][j] != 0) {
    return;
    }

    grid[i][j] = 1; // 标记为已访问

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

    5.3 岛屿的周长

    题目描述:给定一个包含'1'和'0'的二维网格,其中'1'表示陆地,'0'表示水。网格中的单元格水平和垂直方向相连(对角线方向不相连)。网格完全被水包围,并且恰好有一个岛屿(即一个或多个相连的陆地单元格)。岛屿中没有"湖泊"(水域不与岛屿周围的水相连)。单元格是边长为1的正方形。网格是矩形,宽度和高度不超过100。计算岛屿的周长。

    class IslandPerimeter {
    public int islandPerimeter(int[][] grid) {
    if (grid == null || grid.length == 0) {
    return 0;
    }

    int m = grid.length;
    int n = grid[0].length;
    int perimeter = 0;

    for (int i = 0; i < m; i++) {
    for (int j = 0; j < n; j++) {
    if (grid[i][j] == 1) {
    // 每个陆地单元格贡献4条边
    perimeter += 4;

    // 如果上边有相邻陆地,减少两条边(当前单元格的上边和相邻单元格的下边)
    if (i > 0 && grid[i 1][j] == 1) {
    perimeter -= 2;
    }

    // 如果左边有相邻陆地,减少两条边(当前单元格的左边和相邻单元格的右边)
    if (j > 0 && grid[i][j 1] == 1) {
    perimeter -= 2;
    }
    }
    }
    }

    return perimeter;
    }
    }

    5.4 统计子岛屿数量

    题目描述:给你两个m×n的二进制矩阵grid1和grid2,它们只包含'1'(陆地)和'0'(水)。如果grid2中的一个岛屿被grid1的一个岛屿完全包含,那么我们称grid2中的这个岛屿为子岛屿。请返回grid2中子岛屿的数量。

    class CountSubIslands {
    public int countSubIslands(int[][] grid1, int[][] grid2) {
    if (grid2 == null || grid2.length == 0) {
    return 0;
    }

    int m = grid2.length;
    int n = grid2[0].length;
    int count = 0;

    for (int i = 0; i < m; i++) {
    for (int j = 0; j < n; j++) {
    if (grid2[i][j] == 1) {
    // 检查当前岛屿是否是子岛屿
    if (dfs(grid1, grid2, i, j)) {
    count++;
    }
    }
    }
    }

    return count;
    }

    private boolean dfs(int[][] grid1, int[][] grid2, int i, int j) {
    int m = grid2.length;
    int n = grid2[0].length;

    if (i < 0 || i >= m || j < 0 || j >= n || grid2[i][j] == 0) {
    return true;
    }

    // 如果grid2是陆地但grid1是水,则不是子岛屿
    if (grid1[i][j] == 0) {
    return false;
    }

    grid2[i][j] = 0; // 标记为已访问

    // 需要所有相邻部分都满足条件
    boolean up = dfs(grid1, grid2, i 1, j);
    boolean down = dfs(grid1, grid2, i + 1, j);
    boolean left = dfs(grid1, grid2, i, j 1);
    boolean right = dfs(grid1, grid2, i, j + 1);

    return up && down && left && right;
    }
    }

    6. 总结

    6.1 核心思想总结

    岛屿数量问题的核心是连通分量计数,关键在于:

  • 图遍历思想:将网格视为图,使用DFS或BFS遍历连通区域
  • 访问标记:标记已访问的单元格,避免重复计数
  • 方向处理:只考虑上下左右四个相邻方向
  • 边界检查:在访问相邻单元格前检查是否越界
  • 6.2 算法选择指南

  • 面试首选:DFS递归,代码简洁,体现算法思维
  • 大型网格:BFS队列,避免递归栈溢出
  • 动态场景:并查集,支持动态连接和查询
  • 性能敏感:BFS优化版,常数因子较小
  • 需要DFS但避免递归:DFS迭代(栈实现)
  • 6.3 实际应用场景

  • 图像处理:连通区域分析,如图像分割
  • 地理信息系统:地图中的陆地、岛屿分析
  • 网络分析:社交网络中的连通组件识别
  • 游戏开发:地图生成、区域划分
  • 数据聚类:高维数据中的连通性分析
  • 6.4 面试建议

  • 问题澄清:确认网格大小、边界条件、相邻定义
  • 思路阐述:
    • 解释将网格视为图的思想
    • 说明DFS/BFS遍历策略
    • 强调访问标记的重要性
  • 实现细节:
    • 展示方向数组的使用
    • 讨论边界检查
    • 解释原地修改网格标记访问
  • 6.5 常见面试问题Q&A

    Q:为什么只需要检查上下左右四个方向,而不检查对角线方向? A:根据问题定义,岛屿只能由水平方向和/或竖直方向上相邻的陆地连接形成,对角线方向不算相邻。

    Q:如果网格非常大(例如1000×1000),应该选择哪种算法? A:对于非常大的网格,BFS通常更安全,因为它使用队列而不是递归栈,避免了栈溢出的风险。

    Q:是否可以使用并查集解决这个问题?有什么优缺点? A:可以。优点:支持动态更新,如果网格会随时间变化(陆地变水或水变陆地),并查集可以高效处理。缺点:实现相对复杂,常数因子较大。

    Q:如何修改算法来统计每个岛屿的面积? A:在DFS/BFS遍历时,可以计数访问的单元格数量。对于DFS递归,返回计数值;对于BFS,在队列遍历时计数。

    Q:如果要求不能修改原网格,应该如何解决? A:可以使用一个与网格同样大小的布尔数组来记录访问状态,但这会增加O(m×n)的空间复杂度。

    Q:如何处理网格中有多个岛屿,且需要找到最大岛屿的情况? A:在遍历过程中,对每个岛屿计算其面积,并记录最大值。可以使用DFS或BFS在标记岛屿的同时计数。

    Q:如果网格是动态变化的(陆地和水会互相转换),如何高效地维护岛屿数量? A:使用并查集数据结构,当单元格状态变化时,只需要更新与该单元格相关的连接关系,而不需要重新遍历整个网格。

    赞(0)
    未经允许不得转载:171主机测评 » LeetCode经典算法面试题 #200:岛屿数量(DFS、并查集等五种实现方案详细解析)
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址