【LeetCode】200.岛屿数量(dfs+bfs+并查集,超详细图文解释)

400 阅读5分钟

题目

image-20200702010338364

分析

img

说明:以下介绍的算法,除了并查集以外,DFS 和 BFS 都属于很基础的算法内容,也非常好理解,写法也相对固定,读者需要多写,发现并记录自己的问题,我也是在写了几遍甚至是在写本题解的过程中,才发现出自己的问题。

这道题是可以使用一个经典的算法来解决的,那就是 Flood fill,以下的定义来自 维基百科:Flood fill 词条

Flood fill 算法是从一个区域中提取若干个连通的点与其他相邻区域区分开(或分别染成不同颜色)的经典 算法。因为其思路类似洪水从一个区域扩散到所有能到达的区域而得名。在 GNU Go扫雷 中,Flood Fill算法被用来计算需要被清除的区域。

“Flood” 我查了一下,作为动词是 “淹没;充满” 的意思,作为名词是 “洪水” 的意思。下面我们简单解释一下这个算法:

  • 从一个区域中提取若干个连通的点与其他相邻区域区分开

从一个点扩散开,找到与其连通的点,这不是什么高深的算法,其实就是从一个点开始,进行一次 “深度优先遍历” 或者 “广度优先遍历”,通过 “深度优先遍历” 或者 “广度优先遍历” 发现一片连着的区域,对于这道题来说,就是从一个是 “陆地” 的格子开始进行一次 “深度优先遍历” 或者 “广度优先遍历”,把与之相连的所有的格子都标记上,视为发现了一个 “岛屿”。

说明:这里做 “标记” 的意思是,通过 “深度优先遍历” 或者 “广度优先遍历” 操作,我发现了一个新的格子,与起始点的那个格子是连通的,我们视为 “标记” 过,也可以说 “被访问过”。

那么每一次进行 “深度优先遍历” 或者 “广度优先遍历” 的条件就是:

1、这个格子是陆地 1,如果是水域 0 就无从谈论 “岛屿”;

2、这个格子不能是之前发现 “岛屿” 的过程中执行了 “深度优先遍历” 或者 “广度优先遍历” 操作,而被标记的格子(这句话说得太拗口了,大家意会即可,意会不了不是您的问题,是我表达的问题,直接看代码会清楚很多)。

方法一:深度优先遍历

图解

img

img

img

img

img

img

img

img

img

img

img

img

img

img

img

img

img

img

代码实现1:

/**
 * 方法一:深度优先遍历
 */
public class Solution {

    //           x-1,y
    //  x,y-1    x,y      x,y+1
    //           x+1,y
    // 方向数组,它表示了相对于当前位置的 4 个方向的横、纵坐标的偏移量,这是一个常见的技巧。上左下右
    private static final int[][] directions = {{-1, 0}, {0, -1}, {1, 0}, {0, 1}};
    // 标记数组,标记了 grid 的坐标对应的格子是否被访问过
    private boolean[][] marked;
    // grid 的行数
    private int rows;
    // grid 的列数
    private int cols;
    private char[][] grid;

    public int numIslands(char[][] grid) {
        rows = grid.length;
        if (rows == 0) {
            return 0;
        }
        cols = grid[0].length;
        this.grid = grid;
        marked = new boolean[rows][cols];
        int count = 0;
        for (int i = 0; i < rows; i++) {
            for (int j = 0; j < cols; j++) {
                // 如果是岛屿中的一个点,并且没有被访问过
                // 就进行深度优先遍历
                if (!marked[i][j] && grid[i][j] == '1') {
                    count++;
                    dfs(i, j);
                }
            }
        }
        return count;
    }

    // 从坐标为 (i,j) 的点开始进行深度优先遍历
    private void dfs(int i, int j) {
        marked[i][j] = true;
        // 得到 4 个方向的坐标
        for (int k = 0; k < 4; k++) {
            int newX = i + directions[k][0];
            int newY = j + directions[k][1];
            // 如果不越界、没有被访问过、并且还要是陆地
            if (inArea(newX, newY) && grid[newX][newY] == '1' && !marked[newX][newY]) {
                dfs(newX, newY);
            }
        }
    }

    // 封装成 inArea 方法语义更清晰
    private boolean inArea(int x, int y) {
        // 等于号不要忘了
        return x >= 0 && x < rows && y >= 0 && y < cols;
    }

    public static void main(String[] args) {
        Solution solution = new Solution();
        char[][] grid1 = {
                {'1', '1', '1', '1', '0'},
                {'1', '1', '0', '1', '0'},
                {'1', '1', '0', '0', '0'},
                {'0', '0', '0', '0', '0'}};
        int numIslands1 = solution.numIslands(grid1);
        System.out.println(numIslands1);

        char[][] grid2 = {
                {'1', '1', '0', '0', '0'},
                {'1', '1', '0', '0', '0'},
                {'0', '0', '1', '0', '0'},
                {'0', '0', '0', '1', '1'}};
        int numIslands2 = solution.numIslands(grid2);
        System.out.println(numIslands2);
    }
}

代码实现2:

class Solution {
    void dfs(char[][] grid, int r, int c) {
        int nr = grid.length;
        int nc = grid[0].length;

        if (r < 0 || c < 0 || r >= nr || c >= nc || grid[r][c] == '0') {
            return;
        }

        grid[r][c] = '0';
        dfs(grid, r - 1, c);
        dfs(grid, r + 1, c);
        dfs(grid, r, c - 1);
        dfs(grid, r, c + 1);
    }

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

        int nr = grid.length;
        int nc = grid[0].length;
        int num_islands = 0;
        for (int r = 0; r < nr; ++r) {
            for (int c = 0; c < nc; ++c) {
                if (grid[r][c] == '1') {
                    ++num_islands;
                    dfs(grid, r, c);
                }
            }
        }

        return num_islands;
    }
}

复杂度分析

  • 时间复杂度:O(MN),其中 M 和 N分别为行数和列数。
  • 空间复杂度:O(MN),在最坏情况下,整个网格均为陆地,深度优先搜索的深度达到 M N。

方法二:广度优先遍历

除了 “深度优先遍历”,你还可以使用 “广度优先遍历”,此时你就不用回溯了。“广度优先遍历” 需要一个 “辅助队列”。

图解

img

img

img

img

img

img

img

img

img

img

img

img代码实现3:

import java.util.LinkedList;

/**
 * 方法二:广度优先遍历
 */
public class Solution2 {


    private int rows;
    private int cols;

    public int numIslands(char[][] grid) {
        //           x-1,y
        //  x,y-1    x,y      x,y+1
        //           x+1,y
        int[][] directions = {{-1, 0}, {0, -1}, {1, 0}, {0, 1}};

        rows = grid.length;
        if (rows == 0) {
            return 0;
        }
        cols = grid[0].length;
        boolean[][] marked = new boolean[rows][cols];
        int count = 0;
        for (int i = 0; i < rows; i++) {
            for (int j = 0; j < cols; j++) {
                // 如果是岛屿中的一个点,并且没有被访问过
                // 从坐标为 (i,j) 的点开始进行广度优先遍历
                if (!marked[i][j] && grid[i][j] == '1') {
                    count++;
                    LinkedList<Integer> queue = new LinkedList<>();
                    // 小技巧:把坐标转换为一个数字
                    // 否则,得用一个数组存,在 Python 中,可以使用 tuple 存
                    queue.addLast(i * cols + j);
                    // 注意:这里要标记上已经访问过
                    marked[i][j] = true;
                    while (!queue.isEmpty()) {
                        int cur = queue.removeFirst();
                        int curX = cur / cols;
                        int curY = cur % cols;
                        // 得到 4 个方向的坐标
                        for (int k = 0; k < 4; k++) {
                            int newX = curX + directions[k][0];
                            int newY = curY + directions[k][1];
                            // 如果不越界、没有被访问过、并且还要是陆地,我就继续放入队列,放入队列的同时,要记得标记已经访问过
                            if (inArea(newX, newY) && grid[newX][newY] == '1' && !marked[newX][newY]) {
                                queue.addLast(newX * cols + newY);
                                // 【特别注意】在放入队列以后,要马上标记成已经访问过,语义也是十分清楚的:反正只要进入了队列,你迟早都会遍历到它
                                // 而不是在出队列的时候再标记
                                // 【特别注意】如果是出队列的时候再标记,会造成很多重复的结点进入队列,造成重复的操作,这句话如果你没有写对地方,代码会严重超时的
                                marked[newX][newY] = true;
                            }
                        }
                    }
                }
            }

        }
        return count;
    }

    private boolean inArea(int x, int y) {
        // 等于号这些细节不要忘了
        return x >= 0 && x < rows && y >= 0 && y < cols;
    }

    public static void main(String[] args) {
        Solution2 solution2 = new Solution2();
        char[][] grid1 = {
                {'1', '1', '1', '1', '0'},
                {'1', '1', '0', '1', '0'},
                {'1', '1', '0', '0', '0'},
                {'0', '0', '0', '0', '0'}};
        int numIslands1 = solution2.numIslands(grid1);
        System.out.println(numIslands1);

        char[][] grid2 = {
                {'1', '1', '0', '0', '0'},
                {'1', '1', '0', '0', '0'},
                {'0', '0', '1', '0', '0'},
                {'0', '0', '0', '1', '1'}};
        int numIslands2 = solution2.numIslands(grid2);
        System.out.println(numIslands2);
    }
}

代码实现4:

class Solution {
public:
    int numIslands(vector<vector<char>>& grid) {
        int nr = grid.size();
        if (!nr) return 0;
        int nc = grid[0].size();

        int num_islands = 0;
        for (int r = 0; r < nr; ++r) {
            for (int c = 0; c < nc; ++c) {
                if (grid[r][c] == '1') {
                    ++num_islands;
                    grid[r][c] = '0';
                    queue<pair<int, int>> neighbors;
                    neighbors.push({r, c});
                    while (!neighbors.empty()) {
                        auto rc = neighbors.front();
                        neighbors.pop();
                        int row = rc.first, col = rc.second;
                        if (row - 1 >= 0 && grid[row-1][col] == '1') {
                            neighbors.push({row-1, col});
                            grid[row-1][col] = '0';
                        }
                        if (row + 1 < nr && grid[row+1][col] == '1') {
                            neighbors.push({row+1, col});
                            grid[row+1][col] = '0';
                        }
                        if (col - 1 >= 0 && grid[row][col-1] == '1') {
                            neighbors.push({row, col-1});
                            grid[row][col-1] = '0';
                        }
                        if (col + 1 < nc && grid[row][col+1] == '1') {
                            neighbors.push({row, col+1});
                            grid[row][col+1] = '0';
                        }
                    }
                }
            }
        }

        return num_islands;
    }
};

复杂度分析

  • 时间复杂度:O(MN),其中 MM 和 N分别为行数和列数。
  • 空间复杂度: O ( min ⁡ ( M , N ) ) O(\min(M, N)) O(min(M,N)),在最坏情况下,整个网格均为陆地,队列的大小可以达到$ \min(M, N)$。

方法三:使用并查集

使用并查集解决本问题的思想很简单:

1、如果当前是“陆地”,尝试与周围合并一下;

2、如果当前是“水域”,就把所有的“水域”合并在一起,为此,我设置了一个虚拟的结点,表示“所有的水域都和这个虚拟结点是连接的”。

注意

1、针对上面的第 1 点:如果当前是 “陆地”,尝试与周围合并一下”,此时 “周围” 并不需要像 “深度优先遍历” 和 “广度优先遍历” 一样,方向是四周。事实上,只要 “向右”、“向下” 两个方向就可以了,原因很简单,你可以在脑子里想象一个 “4 个方向” 和 “2 个方向” 的算法执行流程(或者看我下面展示的动画),就知道 “4 个方向” 没有必要;

2、针对上面的第 2 点:由于我设置了“虚拟结点”,最后返回“岛屿个数”的时候,应该是连通分量个数 - 1,不要忘记将 “虚拟结点” 代表的 “水域” 分量去掉,剩下的连通分量个数就是 “岛屿个数”。

图解

img

img

img

img

img

img

img

img

img

img

img

img

img

img

img

img

img

img

img

img

img

代码实现5:

from typing import List


class Solution:
    def numIslands(self, grid: List[List[str]]) -> int:

        class UnionFind:

            def __init__(self, n):
                self.count = n
                self.parent = [i for i in range(n)]
                self.rank = [1 for _ in range(n)]

            def get_count(self):
                return self.count

            def find(self, p):
                while p != self.parent[p]:
                    self.parent[p] = self.parent[self.parent[p]]
                    p = self.parent[p]
                return p

            def is_connected(self, p, q):
                return self.find(p) == self.find(q)

            def union(self, p, q):
                p_root = self.find(p)
                q_root = self.find(q)
                if p_root == q_root:
                    return

                if self.rank[p_root] > self.rank[q_root]:
                    self.parent[q_root] = p_root
                elif self.rank[p_root] < self.rank[q_root]:
                    self.parent[p_root] = q_root
                else:
                    self.parent[q_root] = p_root
                    self.rank[p_root] += 1

                self.count -= 1

        row = len(grid)
        # 特判
        if row == 0:
            return 0
        col = len(grid[0])

        def get_index(x, y):
            return x * col + y

        # 注意:我们不用像 DFS 和 BFS 一样,4 个方向都要尝试,只要看一看右边和下面就可以了
        directions = [(1, 0), (0, 1)]
        # 多开一个空间,把水域 "0" 都归到这个虚拟的老大上
        dummy_node = row * col

        # 多开的一个空间就是那个虚拟的空间
        uf = UnionFind(dummy_node + 1)
        for i in range(row):
            for j in range(col):
                # 如果是水域,都连到那个虚拟的空间去
                if grid[i][j] == '0':
                    uf.union(get_index(i, j), dummy_node)
                if grid[i][j] == '1':
                    # 向下向右如果都是陆地,即 "1",就要合并一下
                    for direction in directions:
                        new_x = i + direction[0]
                        new_y = j + direction[1]
                        if new_x < row and new_y < col and grid[new_x][new_y] == '1':
                            uf.union(get_index(i, j), get_index(new_x, new_y))
        # 不要忘记把那个虚拟结点减掉
        return uf.get_count() - 1


if __name__ == '__main__':
    grid = [['1', '1', '1', '1', '0'],
            ['1', '1', '0', '1', '0'],
            ['1', '1', '0', '0', '0'],
            ['0', '0', '0', '0', '0']]
    solution = Solution()
    result = solution.numIslands(grid)
    print(result)

这里有一点需要注意:解决这个问题是可以不用建立 marked数组,但有以下的知识点需要大家知道。

  • 对于算法的输入而言,很多时候是介意修改输入的,除非题目就是要我们修改输入数据;
  • 再者 marked数组没有必要节约这个空间,现在空间越来越不值钱了,而且程序执行完成以后,空间可以回收。我们写算法的时候,应该力求时间复杂度最优,并且代码可读性强。