说明:以下介绍的算法,除了并查集以外,DFS 和 BFS 都属于很基础的算法内容,也非常好理解,写法也相对固定,读者需要多写,发现并记录自己的问题,我也是在写了几遍甚至是在写本题解的过程中,才发现出自己的问题。
这道题是可以使用一个经典的算法来解决的,那就是 Flood fill,以下的定义来自 维基百科:Flood fill 词条。
Flood fill 算法是从一个区域中提取若干个连通的点与其他相邻区域区分开(或分别染成不同颜色)的经典 算法。因为其思路类似洪水从一个区域扩散到所有能到达的区域而得名。在 GNU Go 和 扫雷 中,Flood Fill算法被用来计算需要被清除的区域。
“Flood” 我查了一下,作为动词是 “淹没;充满” 的意思,作为名词是 “洪水” 的意思。下面我们简单解释一下这个算法:
从一个区域中提取若干个连通的点与其他相邻区域区分开从一个点扩散开,找到与其连通的点,这不是什么高深的算法,其实就是从一个点开始,进行一次 “深度优先遍历” 或者 “广度优先遍历”,通过 “深度优先遍历” 或者 “广度优先遍历” 发现一片连着的区域,对于这道题来说,就是从一个是 “陆地” 的格子开始进行一次 “深度优先遍历” 或者 “广度优先遍历”,把与之相连的所有的格子都标记上,视为发现了一个 “岛屿”。
说明:这里做 “标记” 的意思是,通过 “深度优先遍历” 或者 “广度优先遍历” 操作,我发现了一个新的格子,与起始点的那个格子是连通的,我们视为 “标记” 过,也可以说 “被访问过”。
那么每一次进行 “深度优先遍历” 或者 “广度优先遍历” 的条件就是:
1、这个格子是陆地 1,如果是水域 0 就无从谈论 “岛屿”;
2、这个格子不能是之前发现 “岛屿” 的过程中执行了 “深度优先遍历” 或者 “广度优先遍历” 操作,而被标记的格子(这句话说得太拗口了,大家意会即可,意会不了不是您的问题,是我表达的问题,直接看代码会清楚很多)。
代码实现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。除了 “深度优先遍历”,你还可以使用 “广度优先遍历”,此时你就不用回溯了。“广度优先遍历” 需要一个 “辅助队列”。
代码实现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,不要忘记将 “虚拟结点” 代表的 “水域” 分量去掉,剩下的连通分量个数就是 “岛屿个数”。
代码实现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数组没有必要节约这个空间,现在空间越来越不值钱了,而且程序执行完成以后,空间可以回收。我们写算法的时候,应该力求时间复杂度最优,并且代码可读性强。