每年12月,USACO新赛季第一场月赛开打,青铜组(Bronze Division)是绝大多数竞赛新手的第一站。我见过不少人把青铜组想得很简单——会排序、会循环、会数组,好像就能拿满分——这话对了一半,另一半得用真题来回答。2017年12月这场月赛正好是典型案例:三道题分别考几何、模拟、枚举,表面上一道比一道基础,但真正能在比赛里一次AC三道的人,远没有大家想象的多。这篇文章就把这三道真题完整拆开讲,从读题、算法设计、边界条件到完整AC代码,再把我自己踩过的坑和复盘别人代码时反复见到的问题一起列出来。适合刚打完这场月赛的同学做复盘,也适合准备参加青铜组的选手当第一套真题来练手。
1. 2017年12月青铜组的三道题到底在考什么
先给这一场的题目来个全景。我刷这套题的时候最大的感受是:每道题都只需要最基础的语法,但每道题都藏着一个需要你主动发现的条件。这不是拼算法,而是拼"你愿不愿意在动手写代码之前多想两分钟"。
1.1 三道题一览
| 题号 | 官方题目名 | 中文俗称 | 核心考点 | 一句话难点 |
|---|---|---|---|---|
| 1 | Blocked Billboard | 被遮挡的广告牌 | 矩形相交面积计算 | 卡车可能同时遮住两块广告牌,要分别扣减 |
| 2 | Milk Measurement | 牛奶产量记录 | 按时间排序 + 状态维护 | 输入天数无序,且墙面照片显示的是"最高者的集合" |
| 3 | Team Tic Tac Toe | 团队井字棋 | 八条线枚举 + 组合去重 | "不同的获胜组合"要做无序去重 |
从这张表能看出一个共性:三道题其实都在考同一件事——你有没有把题目的限制条件完整读进去。
第一题很多人想用逐格标记做,因为坐标都不大,逐格其实算得动,但真正的问题出在"部分遮挡"的判定不精确,搞错一个区间就全错。第二题最大的坑是输入天数乱序,官方数据里故意把顺序打乱,按读入顺序直接做的人基本都掉进去了。第三题则难在"不同组合"四个字,字母对 AB 和 BA 必须算同一种,没做过类似题的人很容易在这里多算。
1.2 青铜组真正的分水岭
我在各种OJ上见过很多刷了上百道"水题"的同学,他们写循环、写数组、写字符串处理都很熟练,但一上USACO真题,经常卡在铜牌第一题两个小时。原因不是语法不熟,而是长期刷水题养成了"样例过了就交"的习惯,根本不检查边界。
青铜组的题目设计很明显在筛选"严谨"而不是"聪明"。比如第二题,只要你能想到"先把记录按天数排序再模拟",后面的代码几乎就是流水账。可比赛时很多人的第一反应是"数据都给我了,我就按输入顺序跑一遍吧",然后完美错过答案。这一场的分水岭,就是你能不能跳出输入给你的顺序,去还原题目里的真实世界。
2. Blocked Billboard:矩形相交面积与"先算整体再扣重叠"
这道题是2017年12月青铜组的开门题,代码量很小,但非常能检验你对几何问题抽象建模的基本功。它给我的感觉很像做小学应用题里的"阴影面积":两个矩形加在一起,被第三个矩形挡住了一部分,问剩下的可见面积是多少。
2.1 题意理解:广告牌、卡车和"还能看见多少"
题目给你的信息是:两块平行于坐标轴的广告牌,分别用左下角坐标和右上角坐标描述;一辆卡车,同样是个矩形,挡在广告牌前面。你要输出两块广告牌仍然可见的总面积。
这里有几个隐含前提必须拎清楚:
- 两块广告牌之间互不重叠,题目明确保证,所以"可见总面积"才能直接等于两块的面积之和减去被遮挡的部分。
- 卡车的遮挡是"部分遮挡",不是"整块覆盖",所以你要算的是广告牌矩形和卡车矩形的相交面积。
- 一个矩形可以同时遮到两块广告牌,所以必须对每块广告牌分别做一次相交面积计算。
我用一组自己平时验证用的数据来演示:第一块广告牌左下角 (1,1)、右上角 (4,4),面积是 3×3=9;第二块广告牌左下角 (7,1)、右上角 (10,4),面积也是 9;卡车左下角 (3,2)、右上角 (9,3)。
卡车和第一块广告牌的公共部分:x方向从 3 到 4,宽 1;y方向从 2 到 3,高 1,相交面积 1。卡车和第二块广告牌的公共部分:x方向从 7 到 9,宽 2;y方向从 2 到 3,高 1,相交面积 2。所以答案是 9+9-1-2=15。
2.2 相交面积公式为什么可以写成一行
两个矩形相交的区域,其实就是在x轴上两个区间取交集,在y轴上两个区间取交集,然后用交集的宽度乘以高度。
x方向的交集宽度可以写成:
max(0, min(a.x2, b.x2) - max(a.x1, b.x1))
如果你看不懂这个式子,换个生活化的比喻:两个人共用一张长条桌,每个人的"地盘"是一个区间。他们实际能撞到胳膊的公共区域,是从两人地盘右侧中较靠左的那个点起,到两人地盘左侧中较靠右的那个点止。也就是"取右边界里较小的,取左边界里较大的",这两者之差就是重叠长度。差为负数说明根本没碰上,直接归零。
把这个逻辑套到y方向,就得到了完整的重叠面积函数:
struct Rect { int x1, y1, x2, y2; }; int overlapArea(const Rect& a, const Rect& b) { int w = max(0, min(a.x2, b.x2) - max(a.x1, b.x1)); int h = max(0, min(a.y2, b.y2) - max(a.y1, b.y1)); return w * h; }我当时第一次写这个函数时手滑把 max 和 min 用反了,测出来的答案比样例大了好几倍。后来养成了一个习惯:每写一个几何公式,先找两组极端数据验证——完全重合和完全不相交,两个结果都正确,才敢往下走。
2.3 完整AC代码与文件输入输出
USACO所有题目都是文件输入输出,不读标准输入。你需要在代码里写 freopen 把 billboard.in 和 billboard.out 接进来,或者用 ifstream / ofstream。新手最容易在这两行上栽跟头:忘了建输入文件,或者文件名打错。
#include <bits/stdc++.h> using namespace std; struct Rect { int x1, y1, x2, y2; }; int area(const Rect& r) { return (r.x2 - r.x1) * (r.y2 - r.y1); } int overlapArea(const Rect& a, const Rect& b) { int w = max(0, min(a.x2, b.x2) - max(a.x1, b.x1)); int h = max(0, min(a.y2, b.y2) - max(a.y1, b.y1)); return w * h; } int main() { freopen("billboard.in", "r", stdin); freopen("billboard.out", "w", stdout); Rect b1, b2, truck; cin >> b1.x1 >> b1.y1 >> b1.x2 >> b1.y2; cin >> b2.x1 >> b2.y1 >> b2.x2 >> b2.y2; cin >> truck.x1 >> truck.y1 >> truck.x2 >> truck.y2; int ans = area(b1) + area(b2) - overlapArea(b1, truck) - overlapArea(b2, truck); cout << ans << endl; return 0; }这份代码的答案就是 15。整个程序逻辑极其直白:先算两块广告牌总面积,再分别扣除卡车遮住的部分。
2.4 这道题最容易错的两处
第一个常见错误是我每次看同学代码都会遇到的:overlapArea 里的 max(0, ...) 被漏写。如果不加这个保护,当两个矩形没有交集时,min 减 max 会得到负数,负面积加进答案导致结果错得离谱。第二处是坐标读取顺序,输入给的是左下角和右上角,但有人想当然认为是任意两个对角,又没做排序,直接套公式,遇到 x1 > x2 的输入就翻车。
还有一个值得说的点:题目保证广告牌互不重叠,这是个强假设,不要觉得自己聪明去实现一套能处理"广告牌也重叠"的通用解法。比赛题要的是在给定约束下高效正确,而不是过度设计。真有兴趣拓展,可以在赛后自己想想如果广告牌重叠该怎么扣减,但考场里把题目的每条保证都当作重要条件来用,才是青铜组的正确打开方式。
3. Milk Measurement:按天排序与"最高者集合"的实时维护
第二题是整场我推荐所有新手反复品的一题。它没有难算法,但把"模拟题"最容易踩的坑全部集中在了同一道题里。题意是这样的:三头奶牛,名字分别是 Bessie、Elsie、Mildred,初始产量都是 7 加仑。谷仓墙上挂着一张照片,照片上是当前产量最高的所有奶牛(注意是"所有",可以是一头也可以是多头)。接下来给你若干条记录,每条记录形如"天数 名字 变化量",你需要在所有记录处理过程中,统计"照片内容"一共变化了多少次。
3.1 样例手工推演:为什么答案是2
样例输入是:
4 7 Mildred +3 4 Elsie -7 9 Mildred +9 3 Bessie +1如果你不排序直接按读入顺序模拟,结果一定会错。正确做法是先把四条记录按天数排序:
| 天数 | 名字 | 变化量 | 操作后三头牛产量 | 当前最高者集合 | 是否变化 |
|---|---|---|---|---|---|
| 初始 | - | - | B=7, E=7, M=7 | {Bessie, Elsie, Mildred} | - |
| 3 | Bessie | +1 | B=8, E=7, M=7 | {Bessie} | 变化,答案=1 |
| 4 | Elsie | -7 | B=8, E=0, M=7 | {Bessie} | 未变化 |
| 7 | Mildred | +3 | B=8, E=0, M=10 | {Mildred} | 变化,答案=2 |
| 9 | Mildred | +9 | B=8, E=0, M=19 | {Mildred} | 未变化 |
所以答案是 2。注意第 3 天的变化:初始照片上是三头牛并列,现在变成只有 Bessie 一头牛排第一,照片内容变了,必须计数。如果只记录"当前第一名是谁",就会漏掉这种并列集合的变化。
3.2 为什么排完序后直接模拟就能通过
这题数据范围不大,N 不超过 100,所以排序后逐条模拟,时间复杂度 O(N log N + N),完全够用。有些同学一看到"变化量"就往线段树、树上差分那边想,纯属给自己加戏。青铜组的数据范围不需要这些,能线性模拟就大胆模拟。
但"直接模拟"并不意味着无脑写。你需要想清楚要维护两个东西:三头奶牛的当前产量值,以及当前"产量最高者的集合"。
struct Rec { int day; string name; int delta; };排序时按 day 从小到大排。这里有个小技巧:把记录定义成结构体,排序函数一行搞定,比直接拿三个数组对着排要不容易错。
3.3 关键设计:比较集合而不是比较最大值
这道题的正解思路是:每次更新完奶量后,重新扫描三头牛的产量,找出当前最大值,然后把所有产量等于这个值的牛都放进一个新的集合;拿这个新集合和上一个集合比较,不一样就答案加一。
这么做的原因是,照片显示的是"最高者集合"。你要判断的是集合是否变化,而不是单纯判断"最高者是否还是同一头牛"。比如旧集合是 {A, B},新集合是 {A},最高者确实还是 A,但照片从两头牛变成一头牛了,这就算一次变化。用 set 直接比较,就能天然处理好这种并列和排序问题,不需要自己写一堆 if。
map<string, int> milk; milk["Bessie"] = 7; milk["Elsie"] = 7; milk["Mildred"] = 7; set<string> curMaxSet = {"Bessie", "Elsie", "Mildred"};初始化时三个人的产量都是 7,所以照片初始集合是三个人都在。这一步很关键,我见过有人把初始最高集合写成空集或者只写一个名字,样例结果直接偏了 1。
3.4 完整AC代码与三个隐蔽细节
细节一:变化量可能是负数,产量降到 0 甚至负数都是合法的,所以更新时不能做任何"非负"剪枝。细节二:排序后处理时,记录里的名字不是固定的,直接用 map 读名字更新。细节三:每次重新构造新集合时,必须等所有更新完成再做比较。
#include <bits/stdc++.h> using namespace std; struct Rec { int day; string name; int delta; }; int main() { freopen("measurement.in", "r", stdin); freopen("measurement.out", "w", stdout); int n; cin >> n; vector<Rec> recs(n); for (int i = 0; i < n; i++) { cin >> recs[i].day >> recs[i].name >> recs[i].delta; } sort(recs.begin(), recs.end(), [](const Rec& a, const Rec& b) { return a.day < b.day; }); map<string, int> milk; milk["Bessie"] = 7; milk["Elsie"] = 7; milk["Mildred"] = 7; set<string> curMaxSet = {"Bessie", "Elsie", "Mildred"}; int ans = 0; for (const Rec& r : recs) { milk[r.name] += r.delta; int best = -1; for (auto& p : milk) { best = max(best, p.second); } set<string> newMaxSet; for (auto& p : milk) { if (p.second == best) newMaxSet.insert(p.first); } if (newMaxSet != curMaxSet) { ans++; curMaxSet = newMaxSet; } } cout << ans << endl; return 0; }如果哪天真遇到数据量巨大的类似题,可以把"找当前产量最大值"这步优化成用有序集合按产量排序,每次更新后调整对应奶牛的位置,复杂度降为 O(N log N),但核心思想还是"比较最高者集合是否变化"。青铜组能理解到这一层,已经超出要求了。
3.5 这种"状态模拟"题的通用套路
这次比赛结束后我把这类题总结成一个模板:凡是"动态更新 + 查询全局关系"的模拟题,只要数据范围不大,最稳的写法就是每次更新后全部重算,再把新旧状态做比较。不要一开始就去维护一个看似精巧但实际上有一堆边界条件的复杂结构。重算所有东西的代价很低,而你把维护逻辑想岔了的代价很高。在竞赛里,稳定性永远优先于无意义的炫技。
4. Team Tic Tac Toe:八条线枚举与无序组合去重
第三题是井字棋,但主角从棋子变成了奶牛。3×3 的棋盘上,9 个位置各自被某头奶牛占据,每个位置用一个字母表示。你需要统计两种获胜情况:一种是一条线上的三个格子被同一头奶牛占满,这算"单人获胜";另一种是一条线上的三个格子恰好被两头奶牛一起占满,这算"团队获胜"。最后输出两个数字:单人获胜的不同奶牛数,以及团队获胜的不同奶牛组合数。
4.1 题意理解:什么算"恰好由两头奶牛占满"
这道题的字符串处理很简单,难在理解"恰好由两头奶牛"这件事。
一条线上有三个格子,如果三个格子里面只有两种字母,那就说明这头线上恰好由两头奶牛占据。注意这里不需要额外判断"两头牛是否都出现过一次"——三个格子只有两种字母,必然有一头出现两次、另一头出现一次,所以只要set赋值后 size 等于 2,就一定满足条件。
但这里还有一个隐藏要求:"不同的获胜组合"只统计一次。比如某条线是 ABB,另一条线是 BAA,本质都是 A 和 B 的组合,只能算一种团队组合。这就是我前面说的无序去重问题。
我自己造了一组数据来验证逻辑:
AAB BCB AAA单人获胜:只有最后一行 AAA,所以单人的不同奶牛数是 1。团队获胜:第一行 AAB 是 AB,第二行 BCB 是 BC,第一列 ABA 是 AB,第二列 ACA 是 AC,第三列 BBA 是 AB,主对角线 ACA 是 AC;副对角线 BCA 三个字母都不同,不计。去重后团队组合是 AB、BC、AC,一共 3 种。所以输出应该是 1 和 3。
4.2 把八条线统一抽象成"处理一条线"
写代码时最怕的是把八条线分八种情况写,那样代码又长又容易漏。更聪明的做法是:把每一行、每一列、两条对角线都转成一个 string,然后统一交给一个函数去处理。
#include <bits/stdc++.h> using namespace std; char board[3][3]; set<char> solo; set<string> team; void processLine(const string& line) { set<char> s(line.begin(), line.end()); if (s.size() == 1) { solo.insert(*s.begin()); } else if (s.size() == 2) { auto it = s.begin(); char a = *it; char b = *next(it); if (a > b) swap(a, b); string key = ""; key += a; key += b; team.insert(key); } } int main() { freopen("tttt.in", "r", stdin); freopen("tttt.out", "w", stdout); for (int i = 0; i < 3; i++) { for (int j = 0; j < 3; j++) { cin >> board[i][j]; } } for (int i = 0; i < 3; i++) { string line = ""; line += board[i][0]; line += board[i][1]; line += board[i][2]; processLine(line); } for (int j = 0; j < 3; j++) { string line = ""; line += board[0][j]; line += board[1][j]; line += board[2][j]; processLine(line); } string diag1 = ""; diag1 += board[0][0]; diag1 += board[1][1]; diag1 += board[2][2]; processLine(diag1); string diag2 = ""; diag2 += board[0][2]; diag2 += board[1][1]; diag2 += board[2][0]; processLine(diag2); cout << solo.size() << endl; cout << team.size() << endl; return 0; }注意读取时的细节:输入是连续的字母,中间没有空格,用 cin 读单个 char 是最稳的。如果你用 string 变量按行读,三个字符之间没分割符,读进来倒也没问题,但在后续处理里会多一层转换,没必要。
4.3 组合去重的两种写法对比
我在代码里用的是 set ,把两个字母先排序再拼成字符串,保证 AB 和 BA 落进同一个 key。这个写法最直观,也最不容易写错。
另一种常见写法是开一个 bool used[26][26] 数组,只在 a < b 时把 used[a-'A'][b-'A'] 标记为 true,最后数一遍有多少个 true。这种写法的好处是省内存、查询快,但代码里必须严格保证只标记"小写字母在前"的情况,否则去重逻辑会破功。
对比一下:
| 方法 | 优点 | 缺点 |
|---|---|---|
| set<string> | 写法简单,去重天然正确 | 多建字符串,稍有开销 |
| bool used[26][26] | 更接近底层,无额外分配 | 需要自己维护排序方向 |
青铜组完全可以用第一种,不用犹豫。等你去银组刷了很多题以后再回来看,会觉得第二种写法也挺顺手,但在现阶段,把大量精力花在"省一个字符串"上不值得。
4.4 我复盘时反复看到的三个错误
第一个错误是漏掉两条对角线。很多同学能想到行和列,但把斜着的两条线忘了,白白丢一个测试点。第二个错误是团队组合重复统计。有人把 AB 和 BA 当成两个组合,导致 team 数量变大。第三个错误比较隐蔽:有人用"字母是否出现超过一次"来判断团队,比如看到某条线是 ABB,就说 B 出现了两次所以不是团队——这种理解把规则弄反了,AB 这条线里 A 和 B 各承担了至少一个格子,整条线只有两个字母,恰恰就是团队获胜。
5. 从这一场真题看青铜组的备考重点
把三道题放在一起看,会发现一套很清晰的能力要求:几何题的建模能力、模拟题的细节把握能力、枚举题的去重意识。这三样东西都靠练,不靠背模板。
5.1 三道题的共同思维模式:读题之后再问自己三个问题
每次做完一道青铜题,我都会强制自己回答三个问题:这道题的约束条件里哪些被我当成废话跳过?我的代码在最极端的数据下会不会崩?如果答案是一个集合而不是我直觉里的单个值,我有没有处理好"并列"和"去重"?
举个例子,Blocked Billboard 里"两块广告牌不重叠"就是容易跳过的废话条件,但它决定了能不能用简单的相加/扣减法。Milk Measurement 里的"照片上是所有最高者"就是集合思维,Team Tic Tac Toe 里的"不同组合"就是去重思维。这三个小词,每一个都能让人在样例全过的情况下爆零。
低年级同学写代码常常只关注"能跑出样例",这是做题和比赛的差别。我自己后来复盘时,每次都会故意改掉样例里的一个参数,让答案发生变化,再手算一遍验证代码对不对。比如数矩形面积时,我会把卡车移开,让可见面积等于总面积;改牛奶变化量,让最高集合出现三个人并列的情况;改井字棋棋盘,让某条线三个字母全相同。这些极端情况才是代码真正的试金石。
5.2 我的一个互测方法:写暴力程序对拍
第三题我曾经担心 processLine 的判定逻辑有隐藏 bug,于是写了一个不调用任何集合、单纯数格子的暴力版本,然后随机生成棋盘数据让两个程序同时跑一天。对拍几十万组数据后,发现两个版本完全一致,我才对自己的去重逻辑彻底放心。
这个"暴力对拍"的方法特别适合几何题和模拟题。Blocked Billboard 完全可以写一个逐格填充的暴力程序:把两块广告牌按坐标填充到二维标记数组里,再模拟卡车遮挡,最后统计还有多少格被标记且未被遮挡。用随机坐标数据去对拍公式版,比瞪眼找 bug 高效得多。USACO 青铜组没有时间限制压力,花十分钟写个暴力对拍,好过花两小时在脑内推演。
5.3 接下来怎么练
如果你刚打完这一场,我建议把这三道题按三个层次重写:第一遍合上参考代码,凭记忆完整复现;第二遍限时 30 分钟,模拟比赛状态;第三遍换一种语言,比如用 Python 或 Java 各写一次,体会不同语言里集合、排序、字符串处理的差异。
完成这三步之后,可以接着做 2018 年 1 月的青铜组真题。2018 年之后的题目风格会更强调模拟和排序,和这一场有天然衔接。打竞赛最忌讳的是只刷题不复盘,一套真题做三遍,比囫囵吞枣刷十套新题有效得多。
最后说一个我自己的小习惯。每次刷完一套 USACO 真题,我不急着看官方题解,而是先把自己代码里的每一个判断条件追着问一遍:这个 if 成立的前提是什么?如果输入是 0、是整个矩形完全盖住、是负的增量,程序还成立吗?把这三道题按这个标准过一遍,比多刷十道题都管用。青铜组的满分从来不靠灵光一闪,靠的是把"想当然"三个字从代码里彻底删掉。