1. 问题背景与理解
LeetCode 827题"最大人工岛"是一个经典的图论问题,它考察的是对二维网格结构的理解和深度优先搜索(DFS)算法的应用。题目给定一个由0和1组成的二维网格,其中0代表海洋,1代表陆地。我们可以将最多一个0变成1(即填海造陆),要求找出操作后可能形成的最大岛屿面积。
岛屿在本题中的定义是:由相邻的1组成的区域,相邻指的是水平或垂直相邻(即上下左右四个方向)。例如,在以下3x3网格中:
code复制1 0 1
0 1 0
1 0 1
最大的岛屿面积是1,但如果我们将中心的0变成1,就能形成一个面积为5的大岛屿。
2. 解题思路分析
2.1 暴力解法及其局限性
最直观的解法是:尝试将每一个0变成1,然后计算此时的最大岛屿面积,最后取所有情况中的最大值。这种方法虽然直接,但效率很低。对于一个n×m的网格,时间复杂度为O((n×m)^2),因为每次改变一个0后都需要重新扫描整个网格。
2.2 优化思路
更高效的解法需要利用以下观察:
- 我们可以预先计算并存储每个岛屿的面积,这样在填海造陆时就不需要重新计算。
- 填海造陆后形成的新岛屿面积等于:1(新填的陆地)加上所有与之相邻的不同岛屿的面积之和。
基于此,我们可以设计一个两阶段算法:
- 标记阶段:遍历网格,为每个岛屿分配唯一ID并计算其面积。
- 连接阶段:对于每个海洋格子,检查其四周的岛屿ID,计算如果将其变为陆地能连接成的总面积。
3. 详细实现解析
3.1 数据结构选择
为了实现上述思路,我们需要以下数据结构:
id[][]:二维数组,记录每个格子所属的岛屿ID。初始时,海洋格子为0,未标记的陆地格子为1。area:哈希表,映射岛屿ID到其面积。curId:当前可用的岛屿ID,从2开始分配(避免与0和1冲突)。
3.2 标记阶段实现
标记阶段使用深度优先搜索(DFS)来探索和标记每个岛屿:
java复制private void dfs(int[][] grid, int[][] id, int i, int j, int curId, int[] size) {
int n = grid.length, m = grid[0].length;
// 边界检查:超出网格范围、不是陆地或已标记则返回
if (i < 0 || i >= n || j < 0 || j >= m || grid[i][j] != 1 || id[i][j] != 0) {
return;
}
id[i][j] = curId; // 标记当前格子
size[0]++; // 增加当前岛屿面积
// 递归探索四个方向
dfs(grid, id, i + 1, j, curId, size);
dfs(grid, id, i - 1, j, curId, size);
dfs(grid, id, i, j + 1, curId, size);
dfs(grid, id, i, j - 1, curId, size);
}
注意:这里使用
size[0]而不是简单的int变量,是因为Java中基本类型参数是按值传递的,使用数组可以绕过这个限制。
3.3 连接阶段实现
对于每个海洋格子,我们检查其四个方向的相邻格子:
java复制Set<Integer> neighborIds = new HashSet<>();
for (int d = 0; d < 4; d++) {
int x = i + dirs[d], y = j + dirs[d + 1];
if (x >= 0 && x < n && y >= 0 && y < m && id[x][y] > 1) {
neighborIds.add(id[x][y]);
}
}
int total = 1; // 当前格子变为1
for (int neiId : neighborIds) {
total += area.get(neiId);
}
maxArea = Math.max(maxArea, total);
使用HashSet来去重很重要,因为一个岛屿可能在多个方向都与当前海洋格子相邻。
4. 边界情况处理
4.1 全陆地网格
如果网格中全是1(没有海洋格子),那么最大面积就是整个网格的面积n×m。我们的算法会正确处理这种情况,因为在标记阶段就已经计算出了最大岛屿面积。
4.2 全海洋网格
虽然题目通常保证至少有一个1,但为了代码的健壮性,我们仍需处理全0的情况。此时,将任何一个0变成1都会得到面积为1的岛屿:
java复制return maxArea == 0 ? 1 : maxArea;
5. 复杂度分析
- 时间复杂度:O(n×m)。每个格子最多被访问两次:一次在标记阶段,一次在连接阶段。
- 空间复杂度:O(n×m)。需要额外的
id数组和递归栈空间(最坏情况下,如整个网格是一个大岛屿,递归深度为n×m)。
6. 实际编码中的注意事项
-
岛屿编号的选择:从2开始编号,避免与海洋(0)和未标记陆地(1)冲突。这是一个容易忽略但重要的细节。
-
面积累加技巧:在Java中,使用数组来传递和修改面积值是一个实用技巧,因为基本类型参数是按值传递的。
-
方向数组的使用:定义
dirs = {-1, 0, 1, 0, -1}可以简洁地表示四个方向,比单独定义dx, dy数组更紧凑。 -
去重的重要性:在计算连接后的面积时,必须使用Set来去重,否则可能会重复计算同一个岛屿的面积。
7. 算法优化思考
虽然这个解法已经很高效,但仍有优化空间:
-
并查集(Union-Find):可以使用并查集数据结构来标记和合并岛屿。在某些情况下,并查集的空间效率可能更好。
-
BFS替代DFS:对于非常大的网格,DFS可能导致栈溢出。可以改用BFS来避免这个问题。
-
原地标记:如果不允许修改原网格,我们需要额外的
id数组。但如果可以修改原网格,可以直接在grid上标记岛屿ID,节省空间。
8. 类似问题扩展
掌握了这个问题的解法后,可以尝试解决以下类似问题:
- 岛屿数量(LeetCode 200):统计网格中岛屿的数量。
- 岛屿周长(LeetCode 463):计算岛屿的周长。
- 最大岛屿面积(LeetCode 695):不进行填海操作时的最大岛屿面积。
- 封闭岛屿数量(LeetCode 1254):统计完全被海洋包围的岛屿数量。
9. 个人实现心得
在实际编码中,我发现以下几点特别值得注意:
-
测试案例设计:除了常规情况,一定要测试全陆地、全海洋、单行、单列等边界情况。
-
调试技巧:可以打印出
id数组和area映射来验证标记阶段的正确性。 -
性能考量:对于非常大的网格(如1000×1000),递归DFS可能导致栈溢出,这时需要改用BFS或迭代式DFS。
-
代码可读性:将标记阶段和连接阶段分离,并使用辅助函数(如dfs),可以大大提高代码的可读性和可维护性。
这个问题的解法展示了如何通过预处理和合理的数据结构选择,将看似O(n²)的问题优化到O(n)的解决方案。这种"空间换时间"的思想在算法设计中非常常见,值得深入理解和掌握。