题目
分析
说明:以下介绍的算法,除了并查集以外,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分别为行数和列数。
- 空间复杂度:,在最坏情况下,整个网格均为陆地,队列的大小可以达到$ 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
数组没有必要节约这个空间,现在空间越来越不值钱了,而且程序执行完成以后,空间可以回收。我们写算法的时候,应该力求时间复杂度最优,并且代码可读性强。