
做USACO青铜组真题解析这几年我每年都会把2022年2月这场翻出来重讲一遍。原因很简单这套题里藏着青铜组最典型的两种考法——一道是“操作题”但考的是逆推一道是“交换题”但考的是逆序对思想。两题都有点反直觉第一次做很容易写出能过样例、却过不了大数据的代码。如果你正在为USACO青铜组做准备或者想通过真题解析补一下贪心和模拟的薄弱点这篇内容应该能让你少走一段弯路。我尽量把推导过程、代码、以及我实际踩过的坑都掰开讲不绕弯子。1. Sleeping in Class先想清楚“最终形态”再谈怎么合并1.1 题面重写以及一个容易被忽略的边界这道题的题意用大白话说就是一排奶牛第i头奶牛有一个需求值a_i。你可以不断把“相邻的两头奶牛”合并成一头合并后需求值相加。目标是让最后剩下的所有奶牛需求值都相等问最少需要操作多少次。注意题面里有几个关键细节操作对象必须是相邻的两头奶牛不能跳着合并。当只剩一头奶牛时无论它的值是多少你其实已经无法继续操作而它本身也满足“所有奶牛需求值相等”这个条件所以答案不可能超过N-1。原题中有多组测试样例每组单独输出答案别只处理一组数据就交上去。我见过不少同学一上来就盯着“合并”这个动作想是不是每次找一对和最小的相邻数合并这个贪心方向看着合理的但反例很快就能找到。比如输入是[1, 100, 1]合并最小的1和1得到[2, 100]剩下的不相等还得继续合并得到102。而正确做法是直接把三个数都合成一个数答案2。顺着“当前能怎么合并”的模拟思路走很容易走进死胡同。1.2 从“合并过程”变成“连续段划分”这道题的突破口是把操作反过来看。不管中间怎么合并最后剩下的每一头奶牛都对应原数组里的一段连续区间。因为操作只允许相邻合并不可能跨过某个元素去合并另一边的元素所以原数组最终会被划分成K个连续段每段分别合并成一个数。于是问题变成了能不能把数组切成K段使得每一段的和都相等如果能切那么操作次数是多少这里有个非常漂亮的结论每次合并奶牛总数减少1。原来有N头最后剩K头那么一共执行了N-K次操作。所以我们希望K尽可能大K越大合并次数越少。连起来的推理是最终每段的和都相等设这个和为T。总和sum K * T因此T必须是整数而且必须sum能被K整除。答案 N - K为了让答案最小要让K最大。算一笔账如果sum9你可以先看K6行不行再看K5K4……第一个合法的K就是最优的K。下面我直接用这个思路来推导代码。1.3 为什么枚举约数而不是暴力枚举每一段如果你真的让K从N往1枚举并且对每个K都扫描一遍整个数组复杂度是O(N²)。在青铜组的数据范围下N往往只有10^3级别这么做其实能过。但如果你想把这个题解彻底吃透就应该注意到一个更本质的约束K必须是sum的约数。理由很简单sum K * TT是整数所以K必须整除sum。sum的约数个数非常少常见数据范围内一般不超过几百个。因此正确做法是枚举sum的约数作为K再对每个K做一次线性扫描检查。这个思路还有一个额外好处你可以单独提出一个check函数每次检查“按每段和为target sum/K能否把数组划分成恰好K段”。划分成功就更新答案。1.4 线性check函数的编码细节check函数的逻辑是cur 0 遍历每个a[i]: cur a[i] 如果cur target: 当前段已经超了失败 如果cur target: 当前段刚好凑满清零开始下一段 如果cur target: 继续累加为什么可以这样直接贪心因为a_i都是正数cur只会越加越大。一旦cur超过target后面无论怎么加都不可能降回target所以立刻可以判定失败。而当cur恰好等于target时必须在这里强制断开否则继续累加下去cur又要超过target照样失败。注意一个容易被忽略的坑如果数组本身就是[1, 2, 3]target是3扫描过程是cur1cur3时清零cur3时清零最后cur0这说明划分成功。但如果最后一段在数组末尾刚好凑满target循环结束后cur应该是0如果你忘记在结束后检查cur是否为0可能会把“能划分成功”误判成“不能切断最后一段”。1.5 C与Python代码对照C写法#include bits/stdc.h using namespace std; int main() { int T; cin T; while (T--) { int N; cin N; vectorlong long a(N); long long sum 0; for (auto x : a) { cin x; sum x; } long long ans N - 1; // 最坏情况所有合成一头 for (int k N; k 1; k--) { if (sum % k) continue; long long target sum / k; long long cur 0; bool ok true; for (int i 0; i N; i) { cur a[i]; if (cur target) { ok false; break; } if (cur target) { cur 0; } } if (ok) { ans N - k; break; } } cout ans \n; } return 0; }Python写法import sys input sys.stdin.readline T int(input()) for _ in range(T): n int(input()) a list(map(int, input().split())) s sum(a) ans n - 1 for k in range(n, 0, -1): if s % k ! 0: continue target s // k cur 0 ok True for x in a: cur x if cur target: ok False break if cur target: cur 0 if ok: ans n - k break print(ans)两个版本的核心逻辑完全一样。唯一要注意的是C里sum和target要用long long不要用int。青铜组虽然不会出特别大的数但多组数据、多段累加之后溢出风险都是潜在的养成用long long的习惯没坏处。1.6 我实际遇到过的三个翻车点第一把答案写成K而不是N-K。K是最终段数题目问的是操作次数每次合并减少一头牛所以答案一定是N-K。我见过有人AC了样例因为样例给的恰好是K和N-K相等的情况结果一上大数据全错。第二枚举K的范围写错。你要是老老实实枚举1到N在N很大时会超时你要是枚举sum的约数但方向写反了从小的K开始找找到的不是最优段数。应该从大到小找第一个合法K因为K越大答案越小。第三check函数里当cur target时直接break这个没问题但注意break出去之后不要顺手把ans更新了。我一开始就在break之后继续走结果两个本来不该合法的K也被算进去了。2. Photoshoot II相邻交换题先别急着模拟2.1 把“从排列a变到排列b”翻译成给奶牛重新编号第二题是Photoshoot II。题目大意是有N头编号为1到N的奶牛你现在有一张照片照片里牛的顺序是a另一张照片牛的顺序是b。你可以交换任意相邻两头牛问最少交换几次能把a变成b。第一次看到这题很多人会想那我把a一步步模拟成b不就行了但“最少几步”这个问题模拟起来很容易漏。正确的第一步永远是做映射。核心操作是这样的以b为基准给每头牛一个“目标位置编号”。比如b [1, 3, 2]那么牛1的目标位置是0我习惯用0-based牛3的目标位置是1牛2的目标位置是2然后遍历a把a中每个元素替换成它的目标位置。比如a [2, 3, 1]替换后得到p [2, 1, 0]。于是问题就变了现在不是两个排列a和b了而是“把数组p通过相邻交换变成[0, 1, 2, ..., N-1]这个递增有序数组最少需要多少次交换”。为什么要费劲做这层转换因为这样一来原先“从a到b”的相对顺序关系全部集中在了p的单调性上。你不再需要关心奶牛的真实编号只关心哪头奶牛应该排在第几位。2.2 逆序对数量为什么就是最少交换次数这里直接给出结论最少相邻交换次数等于数组p中的逆序对数量。所谓逆序对就是一对下标i j但p[i] p[j]。理由分两步一次相邻交换只会改变相邻两个元素之间的相对顺序所以一次交换最多让逆序对数量减少1。逆序对数量从某个值一直减到0至少要交换那么多次。冒泡排序的每次交换恰好都消除一个逆序对。所以“每次减少1”的策略是真实可达的不是纸上谈兵。把这两件事合起来就得到精确答案初始逆序对数 最少交换次数。举个刚才的例子。p [2, 1, 0]逆序对是(2,1)、(2,0)、(1,0)共3对。对应真实排列a [2, 3, 1]变成b [1, 3, 2]最少确实需要3次相邻交换。你自己手动模拟一下会发现2次怎么都做不到这就是“只统计移动距离”和“逆序对计数”之间的差别。2.3 O(N²)暴力实现青铜组怎么卡边界知道了答案是逆序对数实现就很直接了。青铜组的数据范围下两层循环统计逆序对足够#include bits/stdc.h using namespace std; int main() { int n; cin n; vectorint a(n), b(n), pos(n 1); for (int i 0; i n; i) cin a[i]; for (int i 0; i n; i) { cin b[i]; pos[b[i]] i; } vectorint p(n); for (int i 0; i n; i) { p[i] pos[a[i]]; } int ans 0; for (int i 0; i n; i) { for (int j i 1; j n; j) { if (p[i] p[j]) ans; } } cout ans \n; return 0; }这个代码最核心的地方就是p数组的构造。别急着写交换部分的代码先把映射做好后面几乎就是数数了。2.4 如果想进阶用树状数组把复杂度降到O(N log N)青铜组本身不要求这个进阶但如果你刷到白银甚至黄金组同样的思路会反复出现。树状数组的写法也不复杂从左到右扫描p对于每个p[i]统计当前已经扫描过的元素里有多少个比p[i]大然后累加到答案再把p[i]自己插入树状数组。long long ans 0; vectorint bit(n 1, 0); auto add [](int idx, int val) { while (idx n) { bit[idx] val; idx idx -idx; } }; auto query [](int idx) { int res 0; while (idx 0) { res bit[idx]; idx - idx -idx; } return res; }; for (int i 0; i n; i) { int val p[i] 1; // 树状数组下标从1开始 ans i - query(val); // 当前已扫描的i个元素中大于p[i]的数量 add(val, 1); }这里有一点值得记query(val)返回的是小于等于val的元素个数不是严格小于。因为我们统计的是已经插入的i个元素里有多少比p[i]大所以用i减去小于等于p[i]的个数正好。2.5 我以前踩过的一个错误思路这题我最初不是用逆序对做的而是写了一个自认为很聪明的贪心从右往左扫p维护一个“目前见过的最小值”只要当前p[i]大于这个最小值就认为它需要被移动一次。思路听起来合理吧样例也能过。直到我拿a [2, 3, 1]b [1, 3, 2]去测试答案算出来2但正确答案是3。原因在于一头牛向右移动时可能一次要跨过好几头牛每跨过一头牛都是一次交换。如果用“需要移动的牛数”去代替“交换次数”就会把2次交换算成1次。这个教训让我之后每次遇到“最少交换次数”都先问自己一句我统计的到底是交换次数还是被移动的元素个数这两个概念在青铜组的陷阱题里特别常考Photoshoot II就是最典型的一道。3. 两题放一起看青铜组“贪心与逆推”的三条隐藏规律做完整套真题我发现这两道题背后其实有共同的套路而且这个套路在青铜组里出现的频率非常高。3.1 规律一先确定终点形态再做回推Sleeping in Class的终点是“K段等和”Photoshoot II的终点是“把数组变成递增有序”。两道题都要求你先在纸上画出最后应该长什么样而不是盯着中途操作。很多青铜组选手做题慢是因为他们试图模拟每一步操作。但操作题的逆转视角往往才是钥匙。以后遇到“最小操作次数”“能否通过操作达成目标”这类题先问最终状态有什么性质把终极状态刻画出来操作次数通常等于状态量的变化而这个变化可以只靠数学算出来。3.2 规律二把操作次数翻译成一个整数而不是一段过程Sleeping in Class里操作次数 N - KPhotoshoot II里交换次数 逆序对数量。这两题的共同点是答案都能被精准地表示成某个整数指标的变化量。当你发现自己还在对着数组模拟每一次合并、每一次交换时停下来想一想能不能定义一种值每操作一次该值减1最后变成0如果能操作次数就是这个值的初始大小。3.3 规律三约数枚举和排名重编码是青铜组的两张王牌Sleeping in Class教给你“枚举约数”这个技巧遇到“每段和相等”这种目标总和sum的约数是天然的候选集合。Photoshoot II教给你“排名重编码”把目标顺序映射成1、2、3……之后很多比较复杂的排序问题就变成了普通数组上的“单调性维护”。这两个技巧不是孤立的。青铜组还有不少题都藏在这两张王牌后面比如US Open某些谈到“把排列变成某种有序状态”的题本质上都和今天的套路重合。3.4 和国内大厂笔试真题的对应关系很多人以为USACO只跟信息学竞赛相关其实国内大厂笔试里的不少算法题都能在这套真题里找到原型。比如说“将数组划分成若干连续段使段和相等”就是Sleeping in Class的直接变体“通过相邻交换将数组变得有序求最小交换次数”在互联网公司的笔试和面试题里也反复出现。刷USACO青铜组不是单纯为了竞赛奖项。它练的是“把过程操作抽象成数学模型”的能力而这种能力恰好在国内机考场景里也吃得开。用这套题入门一举两得。4. 把这套题当模拟赛我推荐的三步复盘法很多同学刷USACO真题的方式是打开题解看懂关掉下一题。这样做效果很差。我建议你把这套2022年2月的真题当成一场正式模拟赛来对待。4.1 第一步严格按比赛规则自测一次给自己定好1小时15分钟掐表开始。期间不查题解、不暂停、不和别人讨论。输入输出方式按题目页面要求来最好在本地命令行里跑通样例再提交。这一步的目的不是看你能AC几题而是暴露你的真实做题节奏。哪怕只写出一题也比看一遍题解然后觉得自己都会强得多。4.2 第二步记录“卡住的位置”而不是只记录“对错”我复盘时会给自己建一张小表内容包括题目名、读题花了多久、写出第一版代码花了多久、第一次卡住的是什么逻辑、最后是怎么突破的。比如Sleeping in Class卡住可能卡在“为什么不能一开始就传一段子串求和”上Photoshoot II卡住可能卡在“怎么把b变成目标数组”。记录这些位置比记分数有用得多因为下一次你很可能还会在同样的位置卡住。4.3 第三步用一页纸模板榨干每一套题每套题做完之后我会用一个固定模板梳理核心考点这道题考的是贪心、模拟还是数学推导最终形态题目要求变成什么样子用一句话概括。操作次数公式操作次数 ? 怎么从状态量计算一句话题解如果能一句话说清楚解法说明你真正懂了。错法备忘我错在哪里下次怎么避免以今天两题为例题目核心考点最终形态操作次数公式Sleeping in Class枚举约数 连续段划分K段等和N - KPhotoshoot II逆序对 排名重编码递增数组p中的逆序对数把这种模板积累下二三十道题你梳理出的不只是题目而是一整个解题工具箱。4.4 考前一周的具体节奏如果距离比赛还有一周我的建议是前三天每天做一套青铜真题只做最近两三年的中间两天把做错的题重做一遍最后两天不刷新题只看每道题的“终极形态”和“操作次数公式”这两栏。因为这个阶段你再学新套路已经来不及了把已有的套路练到肌肉记忆才是最重要的。最后再分享一个小技巧Sleeping in Class这题里枚举K时从大到小找第一个合法段数其实可以再优化——如果数组里已经出现某一段的和等于target这段就可以锁死你只需要继续检查剩下的部分。这个优化在青铜组不一定有必要但养成“提前剪枝”的习惯等你到了白银组会遇到很多需要这种思维的时刻。