1. 深度优先搜索在图论问题中的应用
在算法竞赛和编程面试中,图论问题占据了相当大的比重。其中,岛屿类问题因其直观性和代表性,成为了检验算法能力的经典题型。今天我将分享四个使用深度优先搜索(DFS)解决的岛屿问题,并详细解析其中的算法思想和实现技巧。
深度优先搜索是一种用于遍历或搜索树或图的算法。它沿着树的深度遍历树的节点,尽可能深地搜索树的分支。当节点v的所在边都已被探寻过,搜索将回溯到发现节点v的那条边的起始节点。这一过程一直进行到已发现从源节点可达的所有节点为止。
2. 孤岛的总面积问题解析
2.1 问题描述与算法思路
101号问题要求我们计算不与边界相连的"孤岛"的总面积。这里的"孤岛"指的是由相邻的1组成的区域,且不与矩阵边界相连。相邻指的是水平或垂直相邻。
解决这个问题的关键在于:
- 首先标记所有与边界相连的岛屿区域
- 然后统计剩余未被标记的岛屿区域面积
这种"先标记排除,再统计剩余"的思路在图论问题中非常常见。通过预处理边界条件,可以简化后续的核心计算。
2.2 代码实现详解
cpp复制int dir[4][2] = {0, 1, 1, 0, 0, -1, -1, 0}; // 四个移动方向:右、下、左、上
int cnt = 0; // 当前岛屿面积计数器
void dfs(vector<vector<int>>& grid, vector<vector<bool>>& visited, int x, int y){
for (int i = 0; i < 4; i++){
int nextx = x + dir[i][0];
int nexty = y + dir[i][1];
// 边界检查
if (nextx < 0 || nextx >= grid.size() || nexty < 0 || nexty >= grid[0].size())
continue;
if (grid[nextx][nexty] == 1 && !visited[nextx][nexty]){
cnt++;
visited[nextx][nexty] = true;
dfs(grid, visited, nextx, nexty);
}
}
}
在main函数中,我们首先处理边界上的岛屿:
cpp复制// 处理左右边界
for (int i = 0; i < n; i++){
if (grid[i][0]) {
visited[i][0] = true;
dfs(grid, visited, i, 0);
}
if (grid[i][m - 1]){
visited[i][m - 1] = true;
dfs(grid, visited, i, m - 1);
}
}
// 处理上下边界
for (int j = 0; j < m; j++){
if (grid[0][j]) {
visited[0][j] = true;
dfs(grid, visited, 0, j);
}
if (grid[n - 1][j]){
visited[n - 1][j] = true;
dfs(grid, visited, n - 1, j);
}
}
2.3 关键技巧与注意事项
-
边界预处理:在DFS之前先处理边界上的岛屿,这样可以避免在DFS中频繁进行边界条件判断,提高代码清晰度和执行效率。
-
访问标记:使用visited数组记录已访问的节点,防止重复计算和无限递归。
-
方向数组:使用dir数组统一处理四个方向的移动,使代码更简洁。
实际编码中发现,如果不预先处理边界岛屿,在DFS中判断"是否与边界相连"会大大增加代码复杂度。这种"预处理+主处理"的模式在很多图论问题中都适用。
3. 沉没孤岛问题解析
3.1 问题转换思路
102号问题要求我们将所有与边界相连的岛屿"沉没"(设为0),只保留完全被水包围的岛屿。这实际上是101号问题的变种,只是输出形式不同。
算法思路与101类似:
- 标记所有与边界相连的岛屿区域
- 输出时,将标记过的区域设为0,未标记的保持原样
3.2 代码对比分析
cpp复制// 标记过程与101相同
// ...
// 输出处理
for (int i = 0; i < n; i++){
for (int j = 0; j < m; j++){
if (grid[i][j] == 1 && visited[i][j]){
cout << 0 << ' '; // 沉没边界岛屿
}else {
cout << grid[i][j] << ' ';
}
}
cout << endl;
}
3.3 性能优化思考
虽然这个问题使用DFS已经足够高效,但在极端情况下(如非常大的矩阵),可能会遇到栈溢出的风险。这时可以考虑:
- 使用BFS代替DFS,避免递归深度过大
- 使用迭代式DFS,手动维护栈
- 对矩阵进行分块处理
4. 高山流水问题解析
4.1 问题建模
103号问题"高山流水"要求找出所有既能流向太平洋(左、上边界)又能流向大西洋(右、下边界)的网格位置。水流只能从高向低或平向流动。
这个问题需要:
- 从太平洋边界出发,标记所有可达的点
- 从大西洋边界出发,标记所有可达的点
- 找出被两次标记的点
4.2 逆向DFS实现
cpp复制void dfs(vector<vector<int>>& grid, vector<vector<bool>>& visited, int x, int y) {
if (visited[x][y]) return;
visited[x][y] = true;
for (int i = 0; i < 4; i++) {
int nextx = x + dir[i][0];
int nexty = y + dir[i][1];
if (nextx < 0 || nextx >= n || nexty < 0 || nexty >= m) continue;
if (grid[x][y] > grid[nextx][nexty]) continue; // 关键:从低向高遍历
dfs (grid, visited, nextx, nexty);
}
return;
}
4.3 算法正确性证明
这种逆向思维(从边界出发,向高处遍历)的正确性在于:
- 如果一个点能被太平洋和大西洋都到达,说明存在到两个海洋的路径
- 从海洋出发向上游遍历,可以避免对每个点都进行完整的DFS
- 两次遍历的交集就是满足条件的点
5. 建造最大岛屿问题解析
5.1 问题分析
104号问题允许我们将一个0变为1,使得形成的岛屿面积最大。这需要:
- 遍历所有0的位置
- 计算将其变为1后能连接成的岛屿面积
- 找出最大值
5.2 暴力解法与优化
基础解法是对每个0进行DFS:
cpp复制for (int i = 0; i < n; i++){
for (int j = 0; j < m; j++){
vector<vector<bool>> visited(n, vector<bool>(m, false));
if (grid[i][j] == 0){
cnt = 1;
dfs(grid, visited, i, j);
res = max(cnt, res);
}
}
}
这种解法时间复杂度为O(n²m²),对于大矩阵效率不高。可以考虑以下优化:
- 预先计算并存储每个岛屿的面积
- 对每个0,检查其四周的岛屿编号,将不同岛屿的面积相加
- 使用并查集管理岛屿连接
5.3 边界情况处理
特别要注意全1矩阵的情况:
cpp复制bool zeroCnt = false;
// 检查是否有0
if (!zeroCnt) {
cout << n*m << endl;
return 0;
}
6. DFS算法总结与实战技巧
6.1 深度优先搜索的适用场景
DFS特别适合解决以下类型的问题:
- 连通性问题
- 路径查找
- 拓扑排序
- 回溯问题
在图论问题中,DFS能自然地处理节点的遍历和状态的转移。
6.2 常见错误与调试技巧
- 栈溢出:递归深度过大时,考虑改为迭代实现或使用BFS
- 重复访问:忘记标记已访问节点会导致无限循环
- 方向处理:确保方向数组完整且正确
- 边界条件:矩阵边界检查要放在递归开始前
6.3 性能优化建议
- 剪枝:在递归过程中提前终止不可能的分支
- 记忆化:存储中间结果避免重复计算
- 迭代实现:对于深度大的问题,使用显式栈
- 并行处理:对于超大矩阵,考虑分块并行计算
在实际面试中,解释清楚算法思路往往比写出完美代码更重要。建议先描述整体方法,再逐步实现,最后讨论优化可能。