把2010年3月白银组的真题翻出来重做了一遍,第一个感觉是:这些题放在今天看,依然是训练连通块和搜索的好材料。尤其是那道人称“数池塘”的经典题,你几乎可以拿它同时练到DFS、BFS、并查集三种算法。很多中文OJ收录的版本会特别标注“四方向”,意思是只能上下左右找相邻的水格,不能走斜对角。当年不少选手就是在这道题上第一次彻底搞懂Flood Fill的思路,我也是。
这篇解析我会按“题意理解 → 算法建模 → 三种写法 → 实战避坑 → 题目变体”的顺序展开,代码以C++为主,但思路部分不会绑定语言。无论你正在备战USACO白银组,还是刚学完基础搜索想做点真题练手,这篇文章应该都能给你省下不少自己摸索的时间。
1. 题目是什么:2010年3月白银组的“数池塘”
1.1 题目背景与核心考点
USACO白银组的定位,是考察选手能不能把常见算法用在“看起来不像模板题”的场景里。2010年3月这套题里,数池塘算是最温和的一道,它不涉及复杂的贪心策略,也没有绕人的数学推导,核心考点非常直白:在一个二维网格上统计连通块的数量。
题目会给你一个 N 行 M 列的字符矩阵,矩阵里每个格子要么是水(W),要么是地(.)。如果一个水格与另一个水格在上下左右四个方向相邻,它们就属于同一个池塘。你需要数出整个农场里有多少个独立的池塘。这个“数连通块”的模型,在后面很多USACO题里都会反复出现,所以千万不要把它当成一道只会做一次的题就放过去,把它彻底吃透,后面能省很多力。
1.2 输入输出格式详解
输入格式很常规,但越简单的格式越容易在细节上翻车。先看标准描述:
第一行是两个整数 N 和 M,表示网格的行数和列数。接下来 N 行,每行是长度恰好为 M 的字符串,只包含大写 W 和点号。输出只有一个整数,表示池塘的总数。
拿一个最简单但能说明问题的样例:
text复制2 2
W.
.W
在四方向规则下,这两个 W 在斜对角线上,不算相邻。所以答案是 2。如果你用的是八方向规则,它们就属于同一个池塘,答案是 1。这也是为什么很多OJ会特意标注“四方向”,题目描述不一样,方向数组就要改,不能直接照抄代码。
如果数据范围更大,比如 N 和 M 都可能到 100 甚至 1000,那么整个网格最大可能有 10^6 个格子。这个规模下你的算法必须是 O(N*M) 级别,任何多余的重循环都可能超时。
1.3 手算样例:四方向的数法
我们再手动走一个稍大一点的样例,确保“数池塘”这个过程不是靠感觉,而是有明确规则的。
text复制3 4
W.W.
.W..
..W.
逐行看每个 W:
- 位置 (0,0) 的 W,四周是 (1,0) 和 (0,1),这两个格子都不是 W,所以它单独是一个池塘。
- 位置 (0,2) 的 W,四周是 (1,2) 和 (0,1)、(0,3),也都不是 W,单独是一个池塘。
- 位置 (1,1) 的 W,四周的 (0,1)、(2,1)、(1,0)、(1,2) 都不是 W,单独是一个池塘。
- 位置 (2,2) 的 W,四周的 (1,2)、(2,1) 不是 W,单独是一个池塘。
所以正确答案是 4。你在手算的时候可能会觉得“这不是一目了然吗”,但一旦网格扩大到几十行,单靠肉眼就会数漏。所有类似问题都建议用程序跑一个标准答案样例来验证,不要对自己的眼睛太自信。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 算法思路:为什么这就是一个“染色”问题
2.1 把字符矩阵变成图论模型
很多初学者看到“池塘”两个字,会觉得这像一道生活场景题,跟图论没关系。但实际上,当你把网格里的每个格子看成图的顶点,把相邻关系看成边,问题就完全变了样:
- 每个水格是一个顶点。
- 上下左右相邻的两个水格之间有一条无向边。
- 一个池塘,就是图中的“连通分量”。
于是“数池塘”就等价于“数图中连通分量的个数”。这一步抽象能力极其重要,因为USACO白银组很多题都会披着各种外壳,考察你能否把题目场景还原成图、树、区间这些基础模型。你不能只会背模板,得知道模板在什么情况下可以套。
2.2 简单的“双循环统计”为什么不行
有人会想:我能不能直接两重循环遍历每个 W,只要它左边和上边都不是 W,就说明它是一个新池塘?这种做法在处理这种静态连通块时有点像是可行的,因为它利用了一个“从左到右、从上到下”的扫描顺序:如果某个 W 的左边和上边都没有水,那它必然是一个新连通块的起点。
但问题在于,这种方法只适用于“水格只向两个方向传播”的特殊情况吗?并不是。其实这个做法本身是对的,它就是很多“编号并查集”的雏形。真正的问题在于:当你决定把“左边或上面有水”的格子归入现有池塘时,你需要知道左边那个格子属于哪个池塘。如果简单用扫描顺序给每个池塘编号,会漏掉一种情况:当前格子的左上方和左方、上方分别属于两个不同的池塘,但实际上这两个池塘在当前格子这里被“接起来”了。这就是初学者最容易做错的“合并”问题。
更大范围的图上,连通关系可能绕一大圈才相遇,靠局部判断很难处理。所以最通用的解法还是“染色”:从任意一个没被处理过的水格出发,把整个连通块全部标记访问过,然后计数加一。
2.3 Flood Fill:从起点蔓延到整个连通块
Flood Fill 这个名字翻译过来叫“洪水填充”,意思是像水一样从起点向四周蔓延,遇到障碍就停,把能到达的所有格子都标记掉。在四方向里,蔓延方向就是上下左右;在八方向里,还要加上四个斜对角。
具体流程是:
- 从左到右、从上到下遍历整个网格。
- 遇到一个没访问过的 W,就让答案加一。
- 从这个 W 出发,用深度优先搜索(DFS)或广度优先搜索(BFS)把所有能到达的 W 都标记为“已访问”。
- 继续遍历,直到整个网格扫完。
这个思路的巧妙之处在于,每个 W 只会被访问一次,每条相邻边也最多被检查常数次,所以整体复杂度是严格的 O(N*M)。不需要做任何多余的重复扫描,你只需要保证“访问过”这个状态不会被遗漏。
2.4 复杂度和数据结构选择的底层逻辑
时间复杂度方面,每个格子最多进一次 DFS 递归或者 BFS 队列,而每个格子检查四个方向,所以总操作次数大约为 4 * N * M,在算法竞赛里这就是标准的 O(N*M)。这个复杂度对 N、M 在 1000 以内的网格都毫无压力。
空间复杂度方面,如果开一个额外的二维布尔数组记录访问状态,需要 O(NM) 的空间;如果直接修改原字符矩阵,把访问过的 W 改成点号,就不需要额外开数组。两种方式各有优劣,我后面会详细展开。对USACO白银组来说,空间限制一般不会卡到 O(NM) 都用不起的程度,所以更多是代码习惯问题。
3. 三个能跑通的版本:DFS、BFS、并查集
3.1 写法一:DFS递归,代码最短最直观
我当年第一次做这道题,用的就是DFS递归版。代码量最小,逻辑也最好懂:
cpp复制#include <bits/stdc++.h>
using namespace std;
const int MAXN = 105;
char grid[MAXN][MAXN];
bool vis[MAXN][MAXN];
int n, m;
int dx[4] = {-1, 1, 0, 0};
int dy[4] = {0, 0, -1, 1};
void dfs(int x, int y) {
vis[x][y] = true;
for (int i = 0; i < 4; i++) {
int nx = x + dx[i];
int ny = y + dy[i];
if (nx < 0 || nx >= n || ny < 0 || ny >= m) continue;
if (grid[nx][ny] != 'W') continue;
if (vis[nx][ny]) continue;
dfs(nx, ny);
}
}
int main() {
cin >> n >> m;
for (int i = 0; i < n; i++) cin >> grid[i];
int ans = 0;
for (int i = 0; i < n; i++) {
for (int j = 0; j < m; j++) {
if (grid[i][j] == 'W' && !vis[i][j]) {
ans++;
dfs(i, j);
}
}
}
cout << ans << endl;
return 0;
}
这段代码的核心逻辑就是两重循环里遇到了一个新池塘,就调用一次 dfs 把整个连通块染掉。在递归里,上下左右四个方向只要能走就一直走,直到没有路为止。
你在学习这个版本的时候,要特别体会“vis 数组”的作用:它保证同一个 W 不会被重复处理,否则递归会无限循环。如果把这道题想象成在一个屋子里给地板刷漆,vis 数组就像是“刷过漆的地板贴了标签”,下次走过就知道不用再刷。
3.2 写法二:BFS队列,不怕栈溢出
DFS递归虽然好写,但在数据量大、递归层数深的时候有爆栈风险。虽然 100 行的网格一般不会出问题,但如果你在Windows本地环境跑,默认栈空间比较小,极端情况下还是会崩。我后来刷题就逐渐偏爱BFS版本,因为它用的是显式队列,不依赖系统栈:
cpp复制#include <bits/stdc++.h>
using namespace std;
const int MAXN = 105;
char grid[MAXN][MAXN];
bool vis[MAXN][MAXN];
int n, m;
int dx[4] = {-1, 1, 0, 0};
int dy[4] = {0, 0, -1, 1};
void bfs(int sx, int sy) {
queue<pair<int, int>> q;
q.push({sx, sy});
vis[sx][sy] = true;
while (!q.empty()) {
auto [x, y] = q.front();
q.pop();
for (int i = 0; i < 4; i++) {
int nx = x + dx[i];
int ny = y + dy[i];
if (nx < 0 || nx >= n || ny < 0 || ny >= m) continue;
if (grid[nx][ny] != 'W') continue;
if (vis[nx][ny]) continue;
vis[nx][ny] = true;
q.push({nx, ny});
}
}
}
int main() {
cin >> n >> m;
for (int i = 0; i < n; i++) cin >> grid[i];
int ans = 0;
for (int i = 0; i < n; i++) {
for (int j = 0; j < m; j++) {
if (grid[i][j] == 'W' && !vis[i][j]) {
ans++;
bfs(i, j);
}
}
}
cout << ans << endl;
return 0;
}
BFS 版本的代码比 DFS 稍微长一点,但在思路上反而更符合“蔓延”的直觉:从一个起点出发,先把所有相邻的水格入队,再从队列里一个个取出来继续扩展。这种一层一层往外扩的感觉,就是“洪水填充”这个词的原本含义。
另外,BFS 在处理“求最短步数”问题时也天然比 DFS 有优势。如果你以后遇到从池塘起点到另一个点的最短距离问题,BFS 的模板可以直接复用。
3.3 写法三:并查集,换一个视角
如果你已经熟练掌握并查集,会发现这道题也可以完全不用搜索来做。思路变成:把所有 W 都当成独立的集合,然后遍历每个 W,如果它右边或下边的格子也是 W,就把两个格子合并到同一个集合。最后统计有多少个不同的集合,答案就出来了。
cpp复制#include <bits/stdc++.h>
using namespace std;
const int MAXN = 105;
char grid[MAXN][MAXN];
int fa[MAXN * MAXN];
int n, m;
int find(int x) {
return fa[x] == x ? x : fa[x] = find(fa[x]);
}
void merge(int a, int b) {
a = find(a);
b = find(b);
if (a != b) fa[a] = b;
}
int id(int x, int y) {
return x * m + y;
}
int main() {
cin >> n >> m;
for (int i = 0; i < n; i++) cin >> grid[i];
for (int i = 0; i < n * m; i++) fa[i] = i;
int dx[4] = {-1, 1, 0, 0};
int dy[4] = {0, 0, -1, 1};
for (int i = 0; i < n; i++) {
for (int j = 0; j < m; j++) {
if (grid[i][j] != 'W') continue;
for (int k = 0; k < 4; k++) {
int nx = i + dx[k];
int ny = j + dy[k];
if (nx < 0 || nx >= n || ny < 0 || ny >= m) continue;
if (grid[nx][ny] == 'W') {
merge(id(i, j), id(nx, ny));
}
}
}
}
set<int> roots;
for (int i = 0; i < n; i++) {
for (int j = 0; j < m; j++) {
if (grid[i][j] == 'W') {
roots.insert(find(id(i, j)));
}
}
}
cout << roots.size() << endl;
return 0;
}
并查集的写法在代码量上比前两种要大,但它的好处是:如果你后面遇到“动态加入水格并查询池塘数量”的问题,并查集能非常方便地维护。这道题用并查集属于“杀鸡用牛刀”,但当成练习题来写,对理解并查集的应用场景特别有帮助。你会意识到,很多看似要搜索的问题,其实都可以用集合合并来解决。
3.4 三种写法的对比与选型建议
我把三种写法放在一起对比一下,方便你按自己的情况选择:
| 写法 | 实现难度 | 是否递归 | 适合场景 | 本地调试易用性 |
|---|---|---|---|---|
| DFS | 低 | 用系统栈 | 小数据量、图论入门 | 代码短,适合学习 |
| BFS | 中 | 显式队列 | 数据量大、求最短路径 | 无爆栈风险,最稳 |
| 并查集 | 中高 | 无 | 动态合并、集合维护 | 逻辑绕一点,但扩展性强 |
如果只是做这道题,我建议你至少亲手把 DFS 和 BFS 两种版本都写一遍。考试时用哪一个?我的习惯是:如果确认 N 和 M 不超过几百,DFS 完全足够;如果看到 N、M 可能上千,直接上 BFS,省心。并查集版本可以作为课外练习,不一定要在考场上写。
4. 这些坑我当年都踩过,帮你省两小时debug
4.1 读入换行符到底怎么处理
这是很多新手刚接触字符矩阵时最头疼的问题。如果你用 cin >> n >> m 读完两个整数,紧接着用 cin >> grid[i] 读字符串,C++ 的标准输入流会自动跳过空白字符(包括换行符),所以不会有问题。但如果你用 getline(cin, s) 读每一行字符串,就要小心第一行 n m 后面的那个换行符会被 getline 读到一个空字符串里。
解决方法是读完 n m 后,先调用一次 getline(cin, 忽略变量),或者干脆统一用 cin >> s。USACO 的输入都是按行紧密排列的,不会在字符串中间插空格,所以用 cin >> 是最省事的。
还有一个隐蔽问题:某些OJ为了提高难度会把网格里的字符改成带空格的,比如 W . W,这时候就必须用 cin >> ch 一个字符一个字符读,且忽略空格。这种版本很少见,但如果你遇到 WA,可以往这个方向检查一下。
4.2 边界判断的两种姿势
四方向的越界检查一般写成:
cpp复制if (nx < 0 || nx >= n || ny < 0 || ny >= m) continue;
这行代码会在四个方向扩展时把越界格子排除掉。还有一种写法是把整个网格外扩一圈,把下标从 1 开始,四周留一圈点号,这样就不需要判边界了。比如声明 char grid[105][105],读入时从 i=1; i<=n; i++ 开始,访问四邻时直接检查 grid[nx][ny] == 'W',因为外圈的默认值不是 W,天然不会越界。
我推荐新手先写显式判边界的版本,因为它逻辑更直白,不容易因为外扩圈数忘记清零而出问题。等你写多了,再考虑外扩写法用来提速和简化代码。
4.3 递归深度与栈溢出
DFS递归版本最怕的就是一条路径很长,长到超过系统栈的限制。比如 N 和 M 都是 1000,网格里是一整条蛇形的水路,递归深度可能达到十万甚至更多,在部分环境下直接段错误。
如果你发现 DFS 在数据量大的测试点上“无缘无故”崩溃,优先把它换成 BFS。如果就是想用递归,也可以自己写一个栈来模拟 DFS 过程,但那样代码复杂度就上去了,不如直接用 BFS。这道题用 BFS 完全能满足复杂度要求,不需要硬扛着递归写。
4.4 标记状态到底要不要单独开数组
标记已访问的水格有两种做法。第一种是开一个 vis 数组,优点是原网格保持不变,方便事后调试;第二种是直接把 grid[i][j] 改成 .,表示这个水格已经被处理过,省掉一个二维数组的空间。
第二种做法的代码可以这样写:
cpp复制void dfs(int x, int y) {
grid[x][y] = '.';
for (int i = 0; i < 4; i++) {
int nx = x + dx[i];
int ny = y + dy[i];
if (nx < 0 || nx >= n || ny < 0 || ny >= m) continue;
if (grid[nx][ny] == 'W') dfs(nx, ny);
}
}
这个版本不仅省空间,还省掉了判断 vis 的代码。缺点是你无法在遍历结束后再查看原始的地图。对这道题来说完全没问题,因为答案只需要池塘数量。我个人在比赛里更倾向于用这种原地修改的写法,代码更短,不容易少写判断。
4.5 手写几个边界用例自测
教你们一个我自己的习惯:每次写完这种矩阵题,至少测三组数据。第一组是题目样例,确保基本流程没问题;第二组是极端小数据,比如只有一行一列但里面是 W;第三组是全地图无水的边界情况。
比如:
text复制1 1
.
答案应该是 0。即使没有水格,你的两重循环也应该正常跑完并输出 0,而不是因为数组越界崩溃。再比如:
text复制1 5
WWWWW
答案应该是 1。这一整行都是同一个池塘,程序应该不会数成 5。
这种边界自测能帮你抓住 90% 的粗心错误。别觉得这些用例太简单就跳过,很多WA就是在这种地方丢的分。
5. 换个马甲你还认识吗:题目的各种变体
5.1 八方向或者斜向连接怎么改
如果你遇到的题目描述说“八个方向相邻都属于同一个池塘”,只需要把方向数组从4个改成8个:
cpp复制int dx[8] = {-1, -1, -1, 0, 0, 1, 1, 1};
int dy[8] = {-1, 0, 1, -1, 1, -1, 0, 1};
其他所有逻辑都不需要变。这也是为什么很多题解会说“换个方向数组就是新题”。你在看题的时候一定要先判断是四方向还是八方向,这个看错的代价往往是一道题全盘皆输。
5.2 求最大池塘的面积
如果把题目改成“输出最大池塘包含多少个水格”,处理方式也简单。在DFS或BFS每次进入一个新格子时,给一个计数器加一,一个连通块访问完后,用计数器的值更新答案最大值。
这个变体经常出现在USACO后续赛季的题目里,本质上就是在连通块统计上加一个维护最大值的逻辑。你只要理解了基础版,这个扩展几乎不需要额外学新知识。
5.3 池塘计数和最短路径的组合
还有一类更难的变体:给你一个起点和一个终点,中间有多个池塘,池塘内部可以走,但池塘之间需要绕路,问你从起点到终点的最短步数。这种题通常会把池塘当成可走的“畅通区域”,把陆地当成不可走的障碍,本质上转成标准BFS最短路问题。
这种综合题在白银组慢慢会出现,它考验的不是你会不会背某个算法,而是你能不能把网格问题里的“状态”理解清楚。等你把数池塘的搜索模板写熟了,再去看这类题会轻松很多。
5.4 对USACO后续题目的影响
说实话,2010年3月这套题本身并不难,但“数池塘”这类连通块问题在USACO后面几年反复出现,只是换了个场景:有时候是数岛屿,有时候是数圈地,有时候是数有几块颜色区域。你只要把这道题的DFS/BFS模板完全理解透,后面那些题基本都能套同一个思路。
所以别觉得这些老题不值得做。越是基础的模板题,越值得反复吃透。我到现在偶尔写搜索题,还会下意识想起这道题的框架:两重循环找起点,DFS/BFS染色,计数更新。这就是经典题的价值。
