后台经常有人问我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) { queuepairint, 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); queuepairint, 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] 出发下一个目标是 r2 那头的牛而 r1 那头牛还站在原位没被抓。那么 FJ 在前往 r2 的路上必然经过 r1。既然都已经经过了顺手把 r1 抓掉只会让后续损失变小不会增加任何额外移动距离。所以“跳过中间牛先抓远处牛”一定不是最优解。这个性质非常重要。它直接改变了状态设计的方向你不用记录 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-1x[l] - x[l-1]dp[l-1][r][0]dp[l][r][0]在 l走向 r1x[r1] - x[l]dp[l][r1][1]dp[l][r][1]在 r走向 r1x[r1] - x[r]dp[l][r1][1]dp[l][r][1]在 r走向 l-1x[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; vectorlong 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][r1][1] min(dp[l][r1][1], v (x[r1] - x[l]) * remain); } // 当前位置在右端点 v dp[l][r][1]; if (v INF) { if (r 1 n) dp[l][r1][1] min(dp[l][r1][1], v (x[r1] - 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)。我最初推转移时也犯过这个错后来在 n2 的手推样例里发现了问题。解决办法是回到定义移动发生时区间里还只有 len 头牛被抓所以损失乘的是 n - len。关于边界数组下标要严格判断 l 0 和 r1 n否则会越界去访问不存在的奶牛。我建议不要嫌麻烦每个转移都独立判断一次。四个转移看起来冗余但能显著减少低级失误。还有一个易错点初始化时要用long long。坐标差和剩余牛数量相乘可能超过 int 范围。老题目虽然数据不大但乘法一旦乘起来还是可能爆炸。我习惯在所有成本计算的地方全部用 long long。调试时最有效的办法是小数据手推。n2两头牛分别在 -5 和 10FJ 在原点。手推答案后对比程序输出。如果一致基本逻辑就对了。如果不一致优先检查初始化和转移里的距离公式。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。如果当前牛品种等于 curlen 加一如果不等说明连续段断开了重置 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; vectorint a(n); setint 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 入门题之一。你把它完整吃透再去看后续年份的白银组或黄金组会发现很多题都是从这里长出来的。
阅读完成 · 觉得有帮助?