后台经常有人问我:GESP 一级 Python 真题解析刷完,离大厂笔试真题解析还有多远?我的回答通常很直接——真题只是载体,真正拉开差距的是你把每道题拆到什么程度。今天拿 USACO 2009 年 11 月白银组三题出来复盘,就是想把这种拆题过程完整走一遍。这套题既不冷门也不超纲,连通分量、BFS、区间 DP、扫描模拟,全是后面打算法竞赛和应付算法笔试的基石。无论你是准备 USACO Silver,还是想补数据结构基本功,这篇都值得从头看完。
1. 复盘前先看:这套白银题在考什么
USACO 月度赛的白银组,定位是“已经掌握基本语法和简单算法,但还不太会用状态设计和搜索技巧”的选手。2009 年 11 月这套题非常有代表性:三题分别覆盖了三个最常被考察的能力点——图上搜索、动态规划状态设计、以及“用枚举去抽象题目条件”的思维。
先看整体结构:
| 题号 | 题目名 | 核心考点 | 难度定位 |
|---|---|---|---|
| 第一题 | Cow Beauty Pageant | 连通块标记 + BFS 最短路 | 白银入门 |
| 第二题 | Cow Run | 区间 DP、状态压缩 | 白银进阶 |
| 第三题 | Cows in a Row | 枚举 + 单次扫描 | 白银入门 |
从做题策略上说,我的建议是:第三题最快拿下,第一题稳拿,第二题留足时间慢慢推。很多人上来就在第二题死磕,结果第一题反而因为细节出错丢了分。
白银组常见的数据范围是“网格几十到一百”、“N 几百到一千”,这套题里第二题的 N 也只有 300 级别。这意味着算法复杂度只要不超过 O(N^2) 基本都能过,真正的难点从来不是常数优化,而是你能不能把题目抽象成正确的模型。
下面按题拆开讲。
2. 第一题:Cow Beauty Pageant,两个斑点的缝合术
2.1 读懂题意
题目给了你一张由.和X组成的网格,里面恰好有两个由X组成的连通块。你可以把任意多格.涂成X,问最少涂几格,能让两个连通块变成一个连通块。
我第一眼看到这题时,第一反应是:这不就是“走迷宫找最短路径”吗?但要注意,题目要求的不是路径上的步数,而是“额外涂几格”。这两个数在很多时候很接近,但边界情况会坑人。
样例是个典型的场景:两个X块中间隔了一格.,那答案就是 1。如果两个块紧挨着,答案就是 0。这个“减不减一”的问题,是本题最容易翻车的地方。
2.2 建模与搜索策略:先染色,再 BFS
做题的第一件事不是写代码,而是想清楚怎么建模。
我采用的思路分两步:
第一步,用 flood fill 给两个连通块分别打上颜色标记。第一个连通块标记成 1,第二个标记成 2。这样做的目的是把“找连通块”和“找最短距离”两个逻辑彻底拆开,代码不容易乱。
第二步,把颜色 1 的所有格子作为起点,做多源 BFS。遇到颜色 2 的格子就停止,输出当前扩展的层数。
这里有个关键点:为什么 BFS 输出的层数就是答案?
因为 BFS 是层层扩散的。第一层从颜色 1 的格子出发,能一步到达的可能是空格子。如果下一步就能碰到颜色 2,说明两个连通块之间只隔了一个空格,答案就是 1。如果中间要经过两格空白,BFS 会经过第二层空白后才碰到颜色 2,这时输出 2。
换句话说,BFS 统计的是“从第一个连通块出发,穿过多少个空白格子才能到达第二个连通块”。这个数字恰好就是需要涂成X的格子数。
有人可能会问:直接用曼哈顿距离枚举两个连通块之间的所有点对距离,再减一,不是更简单吗?
对于这道题的规模,曼哈顿距离法确实能过。但它有个隐患:曼哈顿距离计算的是“横纵坐标差之和”,并不考虑路径上是否有障碍物。如果网格里没有别的障碍,这个距离是准确的;可一旦题目稍微改一下,比如中间加一堵墙,曼哈顿距离立刻失效。BFS 则天然适应任何网格结构,换题不改代码。
2.3 参考实现
#include <bits/stdc++.h> using namespace std; int n, m; char g[55][55]; int color[55][55]; int dx[4] = {1, -1, 0, 0}; int dy[4] = {0, 0, 1, -1}; void paint(int sx, int sy, int id) { queue<pair<int, int>> q; q.push({sx, sy}); color[sx][sy] = id; while (!q.empty()) { auto [x, y] = q.front(); q.pop(); for (int k = 0; k < 4; k++) { int nx = x + dx[k], ny = y + dy[k]; if (nx < 0 || nx >= n || ny < 0 || ny >= m) continue; if (g[nx][ny] == 'X' && color[nx][ny] == 0) { color[nx][ny] = id; q.push({nx, ny}); } } } } int main() { freopen("beauty.in", "r", stdin); freopen("beauty.out", "w", stdout); cin >> n >> m; for (int i = 0; i < n; i++) cin >> g[i]; int id = 0; for (int i = 0; i < n; i++) for (int j = 0; j < m; j++) if (g[i][j] == 'X' && color[i][j] == 0) paint(i, j, ++id); queue<pair<int, int>> q; for (int i = 0; i < n; i++) for (int j = 0; j < m; j++) if (color[i][j] == 1) q.push({i, j}); int step = 0; while (!q.empty()) { int sz = q.size(); while (sz--) { auto [x, y] = q.front(); q.pop(); for (int k = 0; k < 4; k++) { int nx = x + dx[k], ny = y + dy[k]; if (nx < 0 || nx >= n || ny < 0 || ny >= m) continue; if (color[nx][ny] == 2) { cout << step << '\n'; return 0; } if (color[nx][ny] == 0) { color[nx][ny] = 1; q.push({nx, ny}); } } } step++; } return 0; }2.4 这题最容易扣分的三个点
第一个坑是“输出要不要减一”。很多人把 BFS 步数算出来后习惯性减一,结果在“两个块紧挨着”的数据上输出 -1。记住:BFS 里统计的是空白格数量,不是两个 X 之间的曼哈顿距离,所以不要额外减一。
第二个坑是把两个连通块同时加入队列。如果你把颜色 1 和颜色 2 都作为起点入队,那么第一步就可能输出 0,因为 BFS 队列里同时存在两种颜色的格子。这题起点只能是第一个连通块,第二个连通块是“终点条件”,不能作为起点。
第三个坑是递归 flood fill。网格再小,递归深度也可能被极端数据拉高,而且 USACO 老题对栈空间并不友好。我习惯把 flood fill 写成 BFS 或栈式 DFS,这样不管数据怎么给都不会爆栈。
3. 第二题:Cow Run,区间 DP 一次讲透
3.1 题意复述
这道题是整套真题里最有价值的一道。题目背景大致是:FJ 站在原点,位置上有 N 头牛分布在一条直线的不同坐标点(坐标可以是负数)。FJ 以每秒 1 单位的速度移动。只要还有牛没被抓住,每过一秒就会产生一定量的损失。目标是最小化把所有牛都抓住的总损失。
N 不超过 300,坐标可能是负的,也可能是正的。
这个题如果没想清楚,很容易写出暴力搜索,然后看复杂度和状态爆炸。但 N 只有 300,说明出题人就是在暗示:要么 O(N^2),要么 O(N^2 log N)。
3.2 关键性质:被抓的牛一定是一段连续区间
这道题的核心观察是:任意时刻,已经被 FJ 抓住的牛,在排序后的坐标轴上一定形成一个连续区间。
为什么?反证法:假设 FJ 从区间 [l, r] 出发,下一个目标是 r+2 那头的牛,而 r+1 那头牛还站在原位没被抓。那么 FJ 在前往 r+2 的路上必然经过 r+1。既然都已经经过了,顺手把 r+1 抓掉只会让后续损失变小,不会增加任何额外移动距离。所以“跳过中间牛先抓远处牛”一定不是最优解。
这个性质非常重要。它直接改变了状态设计的方向:你不用记录 FJ 抓过哪些零散的牛,只需要记录当前连续区间的左右端点,以及 FJ 现在是在左端点还是右端点。
3.3 状态设计与转移公式
设排序后的牛坐标为 x[0...n-1]。定义:
- dp[l][r][0]:已经抓完区间 [l, r] 内的所有牛,且 FJ 当前站在左端点 l,此时累计的最小损失。
- dp[l][r][1]:已经抓完区间 [l, r] 内的所有牛,且 FJ 当前站在右端点 r,此时累计的最小损失。
初始状态是:FJ 从原点出发,去抓第一头牛。如果第一头牛是 i,那么在路上其他 n-1 头牛都在损失,所以:
dp[i][i][0] = dp[i][i][1] = abs(x[i]) * (n - 1)
然后考虑区间扩展。假设当前区间 [l, r] 长度为 len,还没有被抓的牛数量为 n - len。FJ 移动距离为 d 时,所有还没被抓的牛都会产生 d 的损失,所以扩展成本是 d * (n - len)。
转移可以整理成一个表格:
| 当前状态 | 下一步去向 | 移动距离 | 新状态 |
|---|---|---|---|
| dp[l][r][0](在 l) | 走向 l-1 | x[l] - x[l-1] | dp[l-1][r][0] |
| dp[l][r][0](在 l) | 走向 r+1 | x[r+1] - x[l] | dp[l][r+1][1] |
| dp[l][r][1](在 r) | 走向 r+1 | x[r+1] - x[r] | dp[l][r+1][1] |
| dp[l][r][1](在 r) | 走向 l-1 | x[r] - x[l-1] | dp[l-1][r][0] |
这里需要注意,每次扩展后区间的 len 会加一,但移动过程的损失是按“旧的剩余牛数量”来算的,也就是 n - len。逻辑上很顺:区间里已经有 len 头牛被抓了,剩下 n - len 头牛在 FJ 移动的过程中继续产生损失。
最终答案就是 dp[0][n-1][0] 和 dp[0][n-1][1] 中的最小值。
3.4 C++ 实现
#include <bits/stdc++.h> using namespace std; const long long INF = 4e18; long long dp[305][305][2]; int main() { freopen("cowrun.in", "r", stdin); freopen("cowrun.out", "w", stdout); int n; cin >> n; vector<long long> x(n); for (int i = 0; i < n; i++) cin >> x[i]; sort(x.begin(), x.end()); for (int i = 0; i < n; i++) for (int j = 0; j < n; j++) dp[i][j][0] = dp[i][j][1] = INF; for (int i = 0; i < n; i++) { long long initCost = abs(x[i]) * (n - 1); dp[i][i][0] = dp[i][i][1] = initCost; } for (int len = 1; len <= n; len++) { for (int l = 0; l + len - 1 < n; l++) { int r = l + len - 1; long long remain = n - len; // 当前位置在左端点 long long v = dp[l][r][0]; if (v < INF) { if (l > 0) dp[l-1][r][0] = min(dp[l-1][r][0], v + (x[l] - x[l-1]) * remain); if (r + 1 < n) dp[l][r+1][1] = min(dp[l][r+1][1], v + (x[r+1] - x[l]) * remain); } // 当前位置在右端点 v = dp[l][r][1]; if (v < INF) { if (r + 1 < n) dp[l][r+1][1] = min(dp[l][r+1][1], v + (x[r+1] - x[r]) * remain); if (l > 0) dp[l-1][r][0] = min(dp[l-1][r][0], v + (x[r] - x[l-1]) * remain); } } } cout << min(dp[0][n-1][0], dp[0][n-1][1]) << '\n'; return 0; }3.5 常见坑与调试技巧
这个题最容易犯的错误是把remain算成n - (len + 1)。我最初推转移时也犯过这个错,后来在 n=2 的手推样例里发现了问题。解决办法是回到定义:移动发生时,区间里还只有 len 头牛被抓,所以损失乘的是 n - len。
关于边界:数组下标要严格判断 l > 0 和 r+1 < n,否则会越界去访问不存在的奶牛。我建议不要嫌麻烦,每个转移都独立判断一次。四个转移看起来冗余,但能显著减少低级失误。
还有一个易错点:初始化时要用long long。坐标差和剩余牛数量相乘可能超过 int 范围。老题目虽然数据不大,但乘法一旦乘起来还是可能爆炸。我习惯在所有成本计算的地方全部用 long long。
调试时最有效的办法是小数据手推。n=2,两头牛分别在 -5 和 10,FJ 在原点。手推答案后,对比程序输出。如果一致,基本逻辑就对了。如果不一致,优先检查初始化和转移里的距离公式。
4. 第三题:Cows in a Row,一个循环搞定全流程
4.1 题意与样例推导
这道题表面上是模拟,但藏着一个很容易被忽略的抽象点。题目说:有一排牛,每个牛属于某个品种编号。你可以选择一种品种,把这种品种的所有牛全部移除掉。移除之后,剩下的牛保持相对顺序不变,问这一段中“连续相同品种”的最大长度能是多少。
举个例子:牛序列是 3 5 5 3 5 5 7。如果选择移除品种 3,剩下 5 5 5 5 7,最大连续长度是 4。但如果移除 5,剩下 3 3 7,最大只有 1。所以答案是 4。
这个例子的关键在“移除 3 之后,原本被 3 隔开的两段 5 拼到了一起”。如果你真的先把 3 从数组里一个一个删掉再扫描,算法也能过,但会显得很笨。更聪明的做法是“跳过”,而不是“删除”。
4.2 “跳过删除品种”的扫描法
我们不需要真的生成一个新数组。外层枚举要删除的品种编号,内层遍历原数组。遇到等于被删除品种的牛就 continue,不参与统计;遇到其他牛就更新“当前品种连续长度”。
维护两个变量:当前连续段的品种 cur,和当前连续段长度 len。如果当前牛品种等于 cur,len 加一;如果不等,说明连续段断开了,重置 cur 和 len。
这个做法的时间复杂度是 O(N * K),K 是不同品种的数量。在 N 只有一千级别的数据下,完全够用。
有人可能会想用更复杂的双指针去优化。其实没必要。USACO 白银组最忌讳的就是“想太多”,把暴力枚举写对了就已经 AC。真正需要警惕的反而是那些“看起来该优化却没优化”的地方——比如在循环内频繁拷贝 vector。
4.3 参考实现
#include <bits/stdc++.h> using namespace std; int main() { freopen("cowrow.in", "r", stdin); freopen("cowrow.out", "w", stdout); int n; cin >> n; vector<int> a(n); set<int> kinds; for (int i = 0; i < n; i++) { cin >> a[i]; kinds.insert(a[i]); } int ans = 0; // 如果题目允许“不删除任何品种”,可以先跑一遍原序列作为初值。 // 如果题目强制必须删除一种,请把这部分去掉。 int cur = -1, len = 0; for (int v : a) { if (v == cur) len++; else { cur = v; len = 1; } ans = max(ans, len); } for (int banned : kinds) { cur = -1; len = 0; for (int v : a) { if (v == banned) continue; if (v == cur) len++; else { cur = v; len = 1; } ans = max(ans, len); } } cout << ans << '\n'; return 0; }4.4 边界点与变体
有个边界很值得讨论:如果整个序列只有一种品种,强制删除一种后序列为空,答案应该是 0;而“最多删除一种”下答案应该是 n。USACO 的原题措辞一般会写清楚,但我在实战中见过不少变体题把这一点含糊化。稳妥的做法是写程序前先确认题意,然后在代码里加注释标明自己的假设。
另一个变体是:不限制你只删除一种品种,而是可以删除任意多种。那就不能直接用这个 O(N*K) 扫描了,可能要用到按品种分组和维护前后缀长度。不过那是更高的难度,这里先按下不表。
这道题放在白银组,意义在于让选手感受“枚举 + 扫描”这种朴素但可靠的方法。它不需要复杂数据结构,只需要老老实实把每个可能被删除的品种试一遍。这种思维在面对大厂笔试真题解析时其实非常有用,因为面试题里很多所谓“滑动窗口”的题目,初始思路往往就是先枚举,再找规律压缩计算量。
5. 复盘结论:老题对今天笔试和考试的迁移价值
很多人觉得 USACO 老题已经过时了,不如去刷新的题。但我复盘完这套题之后,感受恰恰相反。2009 年的题放到今天,核心算法一点都不过时。
第一题“连通块 + BFS”,几乎是所有网格类问题的原型。从迷宫寻路到游戏地图连通性判断,再到社交网络里的最短关系链,本质都是无权图上的最短路。你把这个 BFS 扩展逻辑吃透,后面遇到各种“从一堆起点出发找最近目标”的题都能秒懂。
第二题“区间 DP”,更是动态规划里一个非常典型的模型。现在做系统架构师 2026 年 5 月真题解析或者大厂笔试真题解析时,你可能会觉得真正难的不是套路题,而是那些需要对状态做压缩的题目。区间 DP 就是“想清楚状态表示”的最佳训练素材。它教你一个道理:不要试图枚举所有状态,而是先找规律,把无关信息从状态里删掉。
第三题“枚举 + 扫描”,看似简单,却是很多人写代码时最容易毛躁的地方。GESP 一级 Python 真题解析考的是语法级的循环和分支,但在 USACO 白银组里,同样的循环和分支要放在多一层的抽象里:通过外层枚举条件,内层扫描序列。这个抽象层级提升,恰恰是区分普通代码能力和算法思维的分界线。
我给新手的建议是:刷完一套题后,别急着看下一套,先自己写一遍复盘。一句话总结这个题在考什么,一句话总结我哪里卡住了,一句话总结下次遇到类似题要先想什么。三句话写不了多少时间,但对记忆的巩固效果非常明显。
6. 最后分享两个实操阶段的小心得
第一,USACO 老题目的文件输入输出经常让人抓狂。freopen里的文件名必须和题目要求完全一致,连大小写都不能错。我早年吃过一次亏,把Beauty写成了beauty,本地跑得欢,提交直接零分。现在我的习惯是:写代码前先把题目要求的输入输出文件名复制到注释里,再填到freopen里。
第二,白银组的题大多不卡常数,但非常卡“逻辑完整性”。三道题里最值得反复做的是第二题,我建议你做完后,把 n 分别取 1、2、3 各构造一组小样例,手推一遍状态转移表,再对着程序输出验证。这个过程能帮你把区间 DP 的每个细节都刻在脑子里,比单纯 AC 一道题有用得多。
这套 2009 年 11 月的白银组真题,难度放在今天依然很合适,尤其是第二题,可以说是我见过最好的区间 DP 入门题之一。你把它完整吃透,再去看后续年份的白银组或黄金组,会发现很多题都是从这里长出来的。