1. 问题背景与需求分析
农夫约翰的田地最近因为降雨积水,形成了一个N×M的矩形网格。每个网格要么是水('W'),要么是干地('.')。我们需要统计田地中形成了多少个独立的水塘。这里的水塘定义为:由相邻的'W'组成的连通区域,相邻包括水平、垂直和对角线方向共8个邻居。
这个问题本质上是一个典型的连通区域计数问题,在图论中被称为"连通分量"问题。我们需要遍历整个网格,每当遇到一个未被访问过的'W'时,就进行一次深度优先搜索(DFS)或广度优先搜索(BFS),标记所有与之相连的'W',并计数为一个水塘。
2. 算法选择与设计思路
2.1 为什么选择DFS算法
对于这种连通区域问题,DFS和BFS都是可行的解决方案。这里选择DFS主要基于以下考虑:
- 实现简洁:DFS的递归实现非常直观,代码量少
- 空间效率:虽然最坏情况下递归栈的空间复杂度与BFS相同,但在实际应用中DFS通常占用更少内存
- 适合网格结构:网格的规则结构使得DFS的递归深度不会过大(最大为N*M)
2.2 算法核心思想
算法的核心流程可以分解为:
- 遍历网格中的每一个单元格
- 当遇到一个'W'时:
- 增加水塘计数
- 从这个'W'开始进行DFS/BFS,标记所有相连的'W'为已访问(这里直接修改为'.')
- 最终的水塘计数就是答案
2.3 八方向处理
与标准的四连通问题不同,本题需要考虑八连通(包括对角线)。这意味着每个单元格有8个可能的邻居需要检查:
code复制(-1,-1) (-1,0) (-1,1)
(0,-1) (当前) (0,1)
(1,-1) (1,0) (1,1)
在代码中,我们使用一个方向数组dir来存储这8个方向的偏移量。
3. 代码实现详解
3.1 数据结构定义
cpp复制int n, m, ans;
vector<vector<char>> field;
vector<pair<int, int>> dir = {
{-1, 0}, {0, -1}, {1, 0}, {0, 1}, // 上下左右
{-1, -1}, {-1, 1}, {1, -1}, {1, 1} // 四个对角线方向
};
n,m:田地的大小(行数和列数)ans:水塘计数器field:二维向量,存储田地状态dir:方向数组,存储8个可能的移动方向
3.2 DFS函数实现
cpp复制void dfs(int x, int y) {
// 边界检查:超出网格范围或不是水
if(x < 0 || x >= n || y < 0 || y >= m || field[x][y] == '.')
return;
// 标记当前单元格为已访问(改为干地)
field[x][y] = '.';
// 递归检查所有8个邻居
for(int i = 0; i < dir.size(); i++) {
dfs(x + dir[i].first, y + dir[i].second);
}
}
DFS函数的执行流程:
- 首先检查当前位置是否合法(在网格内且是'W')
- 将当前位置标记为已访问(改为'.')
- 递归检查所有8个相邻位置
3.3 主函数逻辑
cpp复制int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n >> m;
field.resize(n, vector<char>(m));
// 读取输入数据
for(int i = 0; i < n; i++) {
for(int j = 0; j < m; j++) {
cin >> field[i][j];
}
}
// 遍历整个网格
for(int i = 0; i < n; i++) {
for(int j = 0; j < m; j++) {
if(field[i][j] == 'W') {
ans++; // 发现新水塘
dfs(i, j); // 标记所有连通区域
}
}
}
cout << ans;
return 0;
}
主函数的执行步骤:
- 关闭同步加速输入输出(竞赛常用技巧)
- 读取网格大小n和m
- 调整field大小并读取网格数据
- 遍历整个网格,每当发现'W'就增加计数并执行DFS
- 输出最终的水塘数量
4. 算法复杂度分析
4.1 时间复杂度
- 每个单元格最多被访问一次(被主循环访问或DFS访问)
- 对于每个'W',DFS会访问其所有连通区域,但每个'W'只会被处理一次
- 因此总时间复杂度为O(N×M),这是最优的,因为必须检查每个单元格
4.2 空间复杂度
- 存储网格需要O(N×M)空间
- DFS的递归栈在最坏情况下可能需要O(N×M)空间(如整个网格都是'W')
- 因此总空间复杂度为O(N×M)
5. 优化与变种思考
5.1 使用BFS实现
虽然DFS实现简洁,但BFS可以避免递归栈溢出的风险(对于极大网格):
cpp复制void bfs(int x, int y) {
queue<pair<int, int>> q;
q.push({x, y});
field[x][y] = '.';
while(!q.empty()) {
auto curr = q.front(); q.pop();
for(auto d : dir) {
int nx = curr.first + d.first;
int ny = curr.second + d.second;
if(nx >= 0 && nx < n && ny >= 0 && ny < m && field[nx][ny] == 'W') {
field[nx][ny] = '.';
q.push({nx, ny});
}
}
}
}
5.2 使用并查集(Union-Find)
虽然DFS/BFS更适合这个问题,但并查集也是一种可能的解决方案:
- 将每个'W'视为一个独立集合
- 遍历网格,合并相邻的'W'的集合
- 最终统计独立集合的数量
不过对于网格结构,DFS/BFS通常更高效且实现简单。
5.3 使用访问标记数组
当前实现直接修改输入网格,这在某些情况下可能不被允许。替代方案是使用单独的访问标记数组:
cpp复制vector<vector<bool>> visited(n, vector<bool>(m, false));
void dfs(int x, int y) {
if(x < 0 || x >= n || y < 0 || y >= m || field[x][y] == '.' || visited[x][y])
return;
visited[x][y] = true;
for(auto d : dir) {
dfs(x + d.first, y + d.second);
}
}
6. 常见问题与调试技巧
6.1 为什么我的程序输出总是0?
常见原因:
- 输入读取错误:检查是否正确地读取了n和m,以及后续的网格数据
- 方向数组定义不全:确保包含了所有8个方向
- 边界条件处理错误:检查DFS中的边界判断条件
6.2 如何处理大网格导致的栈溢出?
解决方案:
- 改用BFS实现,使用显式队列而非递归
- 增加栈大小(某些编译器支持)
- 使用迭代式DFS(手动维护栈)
6.3 如何验证程序正确性?
测试策略:
- 小规模测试用例:如1x1网格,全'W'或全'.'
- 边界情况:如所有'W'都在边缘
- 复杂情况:多个不规则形状的水塘
- 最大规模:100x100的全'W'网格
6.4 为什么使用ios::sync_with_stdio(false)?
这是C++的输入输出优化:
- 默认情况下,C++的iostream与C的stdio同步
- 关闭同步可以显著提高输入输出速度
- 副作用是不能混用C和C++的I/O函数
7. 实际应用与扩展
7.1 类似问题
这种连通区域计数的方法可以应用于许多类似问题:
- 图像处理中的连通区域分析
- 岛屿计数问题(LeetCode 200)
- 迷宫求解问题
- 社交网络中的群体检测
7.2 扩展思考
- 如果水塘定义为四连通(仅上下左右),如何修改代码?
- 只需使用前4个方向即可
- 如果需要计算每个水塘的大小?
- 在DFS中增加一个计数器
- 如果网格非常大(如1000x1000),如何优化?
- 使用BFS避免栈溢出
- 考虑并行算法
7.3 性能优化技巧
- 按行或按列分块处理
- 使用位运算压缩状态表示
- 对于稀疏网格,可以使用邻接表而非矩阵存储
- 考虑缓存友好的访问模式
在实际编程竞赛中,这类问题非常常见。掌握DFS/BFS的模板实现,并理解其变种应用,是解决许多图论和搜索问题的基础。对于初学者来说,建议从标准的DFS实现开始,熟练后再尝试其他方法和优化。