在算法竞赛和编程面试中,岛屿类问题是非常经典的一类图论题目。这类问题通常给定一个二维矩阵,其中0代表水域,1代表陆地,要求我们通过深度优先搜索(DFS)或广度优先搜索(BFS)等图遍历算法来解决各种变种问题。今天我将分享四个岛屿问题的DFS解法,并详细解析每个问题的解决思路和实现细节。
这四个问题虽然各有特点,但都具备以下共同特征:
不同之处在于:
这个问题要求计算所有不与边界相连的孤岛的总面积。换句话说,我们需要找出所有完全被水域包围的陆地区域,并统计它们的总面积。
关键思路:
java复制import java.util.*;
class Main{
static int res = 0; // 存储最终结果
static int count = 0; // 记录当前岛屿面积
static boolean flag = false; // 标记是否接触边界
public static void dfs(int[][] graph, boolean[][] visited, int x, int y) {
// 终止条件:已访问或是水域
if (visited[x][y] || graph[x][y] == 0) {
return;
}
// 检查是否在边界上
if (x == 0 || y == 0 || x == graph.length - 1 || y == graph[0].length - 1) {
flag = true; // 标记接触边界
}
count++; // 增加当前岛屿面积
visited[x][y] = true; // 标记已访问
// 四个方向的偏移量
int[][] dir = {{1, 0}, {0, 1}, {-1, 0}, {0, -1}};
// 遍历四个方向
for (int i = 0; i < 4; i++) {
int nextx = x + dir[i][0];
int nexty = y + dir[i][1];
// 边界检查
if (nextx < 0 || nexty < 0 || nextx >= graph.length || nexty >= graph[0].length) {
continue;
}
dfs(graph, visited, nextx, nexty);
}
}
public static void main(String[] agrs) {
Scanner sc = new Scanner(System.in);
int n = sc.nextInt();
int m = sc.nextInt();
int[][] graph = new int[n][m];
// 读取输入矩阵
for (int i = 0; i < n; i++) {
for (int j = 0; j < m; j++) {
graph[i][j] = sc.nextInt();
}
}
boolean[][] visited = new boolean[n][m];
// 遍历整个矩阵
for (int i = 0; i < n; i++) {
for (int j = 0; j < m; j++) {
if (!visited[i][j] && graph[i][j] == 1) {
dfs(graph, visited, i, j);
// 如果没有接触边界,累加面积
if (flag == false) {
res += count;
}
// 重置计数器和标记
count = 0;
flag = false;
}
}
}
System.out.println(res);
}
}
边界判断时机:在DFS过程中实时检查当前位置是否在边界上,比在DFS结束后再遍历整个岛屿更高效。
访问标记的重要性:必须使用visited数组记录已访问的单元格,否则会导致无限递归和错误计数。
方向数组的使用:使用dir数组表示四个移动方向,比写四个单独的递归调用更简洁。
重置状态:每次开始新的DFS前,要确保count和flag被正确重置。
提示:在实际编码中,可以将dir数组定义为类静态变量,避免在每次递归调用时重复创建。
这个问题要求将所有与边界相连的岛屿沉没(变为0),而保留完全被水域包围的岛屿。与101题不同,这里我们需要先标记所有与边界相连的岛屿,然后进行反转处理。
关键步骤:
java复制import java.util.*;
public class Main{
public static void dfs(int[][] graph, int x, int y) {
// 终止条件:水域或已标记
if (graph[x][y] == 0 || graph[x][y] == 2) {
return;
}
graph[x][y] = 2; // 标记为需要沉没
int[][] dir = {{1, 0}, {0, 1}, {-1, 0}, {0, -1}};
for (int i = 0; i < 4; i++) {
int nextx = x + dir[i][0];
int nexty = y + dir[i][1];
// 边界检查
if (nextx < 0 || nexty < 0 || nextx >= graph.length || nexty >= graph[0].length) {
continue;
}
dfs(graph, nextx, nexty);
}
}
public static void main(String[] agrs) {
Scanner sc = new Scanner(System.in);
int n = sc.nextInt();
int m = sc.nextInt();
int[][] graph = new int[n][m];
// 读取输入矩阵
for (int i = 0; i < n; i++) {
for (int j = 0; j < m; j++) {
graph[i][j] = sc.nextInt();
}
}
// 处理左右边界
for (int i = 0; i < n; i++) {
if (graph[i][0] == 1) {
dfs(graph, i, 0);
}
if (graph[i][m - 1] == 1) {
dfs(graph, i, m - 1);
}
}
// 处理上下边界
for (int j = 0; j < m; j++) {
if (graph[0][j] == 1) {
dfs(graph, 0, j);
}
if (graph[n - 1][j] == 1) {
dfs(graph, n - 1, j);
}
}
// 反转标记:2变0,未被标记的1保留
for (int i = 0; i < n; i++) {
for (int j = 0; j < m; j++) {
if (graph[i][j] == 2) {
graph[i][j] = 0;
}
else if (graph[i][j] == 1) {
graph[i][j] = 0;
}
}
}
// 输出结果
for (int i = 0; i < n; i++) {
for (int j = 0; j < m; j++) {
System.out.print(graph[i][j] + " ");
}
System.out.println();
}
}
}
边界遍历顺序:代码中先处理左右边界,再处理上下边界,这可以确保所有边界相连的岛屿都被标记。
标记值选择:使用2作为临时标记值,避免与原始数据(0和1)冲突。在实际应用中,可以根据问题需要选择其他合适的值。
反转逻辑:注意在最后一步中,原始未被标记的1也要变为0,这与问题描述要求一致。
空间优化:这个方法直接修改了输入矩阵,避免了使用额外的visited数组,节省了空间。
注意:如果题目要求不能修改输入矩阵,则需要创建一个副本进行操作。
这个问题采用了逆向思维的方法。给定一个表示地形的矩阵,数值代表海拔高度。我们需要找出所有既能流向太平洋(左、上边界)又能流向大西洋(右、下边界)的单元格。
关键思路:
java复制import java.util.*;
public class Main{
public static void dfs(int[][] graph, boolean[][] visited, int x, int y) {
if (visited[x][y]) {
return;
}
visited[x][y] = true;
int[][] dir = {{1, 0}, {0, 1}, {-1, 0}, {0, -1}};
for (int i = 0; i < 4; i++) {
int nextx = x + dir[i][0];
int nexty = y + dir[i][1];
// 边界检查
if (nextx < 0 || nexty < 0 || nextx >= graph.length || nexty >= graph[0].length) {
continue;
}
// 只有当前单元格海拔不高于相邻单元格才能流动
if (graph[x][y] <= graph[nextx][nexty]) {
dfs(graph, visited, nextx, nexty);
}
}
}
public static void main(String[] agrs) {
Scanner sc = new Scanner(System.in);
int n = sc.nextInt();
int m = sc.nextInt();
int[][] graph = new int[n][m];
// 读取输入矩阵
for (int i = 0; i < n; i++) {
for (int j = 0; j < m; j++) {
graph[i][j] = sc.nextInt();
}
}
// 初始化两个标记数组
boolean[][] pacific = new boolean[n][m];
boolean[][] atlantic = new boolean[n][m];
// 从太平洋边界(左、上)出发DFS
for (int i = 0; i < n; i++) {
dfs(graph, pacific, i, 0);
}
for (int j = 0; j < m; j++) {
dfs(graph, pacific, 0, j);
}
// 从大西洋边界(右、下)出发DFS
for (int i = 0; i < n; i++) {
dfs(graph, atlantic, i, m - 1);
}
for (int j = 0; j < m; j++) {
dfs(graph, atlantic, n - 1, j);
}
// 找出同时被两个DFS标记的单元格
for (int i = 0; i < n; i++) {
for (int j = 0; j < m; j++) {
if (pacific[i][j] && atlantic[i][j]) {
System.out.print(i + " " + j);
System.out.println();
}
}
}
}
}
逆向思维:从边界出发而不是从内部出发,这是解决此类问题的关键思路转变。
流动方向判断:水只能从高海拔流向低海拔或相同海拔,因此判断条件是graph[x][y] <= graph[nextx][nexty]。
两个标记数组:需要分别维护能流向太平洋和大西洋的单元格标记。
结果输出:只需遍历一次矩阵,找出同时被两个数组标记的位置即可。
提示:这个问题可以扩展为找出所有能流向任意指定边界的单元格,只需调整起始边界即可。
这个问题要求我们在最多将一个0变为1的情况下,找出可能形成的最大岛屿面积。解决这个问题需要分两步:
关键点:
java复制import java.util.*;
public class Main{
static int mark = 2; // 起始标记值
static int count = 0; // 当前岛屿面积计数
static int[][] dir = {{1, 0}, {0, 1}, {-1, 0}, {0, -1}};
public static void dfs(int[][] graph, boolean[][] visited, int x, int y, int mark) {
if (visited[x][y]) {
return;
}
visited[x][y] = true;
graph[x][y] = mark; // 标记当前单元格
count++; // 增加面积计数
for (int i = 0; i < 4; i++) {
int nextx = x + dir[i][0];
int nexty = y + dir[i][1];
// 边界检查
if (nextx < 0 || nexty < 0 || nextx >= graph.length || nexty >= graph[0].length) {
continue;
}
// 只处理未标记的陆地
if (graph[nextx][nexty] == 1) {
dfs(graph, visited, nextx, nexty, mark);
}
}
}
public static void main(String[] agrs) {
Scanner sc = new Scanner(System.in);
int n = sc.nextInt();
int m = sc.nextInt();
int[][] graph = new int[n][m];
// 存储标记到面积的映射
HashMap<Integer, Integer> markToSize = new HashMap<>();
// 临时存储相邻岛屿标记,避免重复计算
HashSet<Integer> adjacentMarks = new HashSet<>();
// 读取输入矩阵
for (int i = 0; i < n; i++) {
for (int j = 0; j < m; j++) {
graph[i][j] = sc.nextInt();
}
}
int maxIsland = 0; // 存储现有最大岛屿面积
boolean[][] visited = new boolean[n][m];
// 第一遍DFS:标记所有岛屿并计算面积
for (int i = 0; i < n; i++) {
for (int j = 0; j < m; j++) {
if (!visited[i][j] && graph[i][j] == 1) {
count = 0;
dfs(graph, visited, i, j, mark);
markToSize.put(mark, count);
mark++;
maxIsland = Math.max(maxIsland, count);
}
}
}
int result = maxIsland;
// 检查每个0单元格
for (int i = 0; i < n; i++) {
for (int j = 0; j < m; j++) {
if (graph[i][j] == 0) {
int size = 1; // 当前单元格变为1,初始面积为1
adjacentMarks.clear();
// 检查四个方向
for (int k = 0; k < 4; k++) {
int neari = i + dir[k][0];
int nearj = j + dir[k][1];
// 边界检查
if (neari < 0 || nearj < 0 || neari >= graph.length || nearj >= graph[0].length) {
continue;
}
Integer curMark = graph[neari][nearj];
// 如果是已标记的岛屿且未计算过
if (markToSize.containsKey(curMark) && !adjacentMarks.contains(curMark)) {
size += markToSize.get(curMark);
adjacentMarks.add(curMark);
}
}
result = Math.max(result, size);
}
}
}
System.out.println(result);
}
}
岛屿标记:使用从2开始的整数标记不同岛屿,避免与原始数据冲突。
面积映射:使用HashMap存储标记到面积的映射,便于快速查询。
相邻岛屿去重:使用HashSet确保同一岛屿不会被多次计算。
边界情况处理:当矩阵中没有0时,结果就是最大现有岛屿面积;当矩阵全为0时,结果为1。
性能考虑:这种方法的时间复杂度是O(nm),因为每个单元格最多被处理两次(标记阶段和查询阶段)。
提示:在实际应用中,如果矩阵非常大,可以考虑并行处理不同区域的标记过程。
通过这四个问题的解决,我们可以总结出一些DFS在图论问题中的应用技巧:
方向数组的使用:使用{{1,0},{0,1},{-1,0},{0,-1}}这样的方向数组可以简化代码,避免重复。
访问标记的策略:根据问题需求选择是修改原矩阵还是使用额外的visited数组。
边界条件的处理:在DFS前进行边界检查,可以简化递归终止条件。
逆向思维:有时候从边界出发比从内部出发更高效(如103题)。
多阶段处理:复杂问题可以分解为多个DFS阶段(如104题先标记再查询)。
空间优化:合理利用矩阵本身存储中间状态,减少额外空间使用。
标记值的选取:选择不会与原始数据冲突的标记值,通常从2开始。
在实际编程面试中,岛屿类问题非常常见,掌握这些变种及其解法可以帮助我们快速应对各种相关问题。建议读者可以尝试LeetCode上的类似题目(如200. Number of Islands、695. Max Area of Island等)来巩固这些技巧。