ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

2017年1月USACO白银组真题:二分、前缀和、逆向思维全解析

2017年1月USACO白银组真题:二分、前缀和、逆向思维全解析 如果你正在备战USACO白银组2017年1月的这场月赛是我个人很推荐的一套真题。三道题——奶牛舞蹈秀、蹄子剪刀布、秘密奶牛代码——恰好覆盖了白银组最高频的三种解题思维二分答案、前缀和、逆向倒推。我当时第一次做这套题前两道很快就看出了套路第三道却被N的取值范围吓住绕了一大圈远路。这篇文章把我当年的踩坑和后来带学弟学妹时的经验一起整理了从读题、思路推导、代码实现到边界处理尽量一次讲透。无论你是第一次接触USACO白银组还是已经刷了一部分题想查漏补缺这套真题都值得认真过一遍。1. 2017年1月白银组一套典型的“套路检验”试卷1.1 三道题分别考什么USACO白银组通常不会出偏题怪题它更像一个“算法基础能力测试”题目背景永远是农场、奶牛、草料但内里全是数据结构和算法的基本功。2017年1月的三道题尤其典型我把它们拆成了一张表题目核心算法难度感受关键陷阱Cow Dance Show二分答案 优先队列模拟中等偏低二分边界、long longHoof Paper Scissors前缀和 / 动态规划中等手势编码、切换点枚举Secret Cow Code逆向推导 取模中等偏高长度可能到10^18正向必死从考点分布看这场考试几乎就是在告诉你白银组不考复杂的图论和高级数据结构你只要把二分、前缀和、逆向思维这三板斧练熟就能拿不错的分数。三道题没有一个需要用到什么奇技淫巧但每一道都隐藏着“如果你只会暴力一定超时”的劝退设计。这正是USACO白银组区别于青铜组的地方——青铜组靠模拟和枚举就能过白银组必须开始理解“复杂度”这三个字。1.2 难度梯度从热身到硬啃三道题的梯度安排也很有意思。第一题Cow Dance Show是典型的二分答案模板题你只要见过一次“最小值最大/最大值最小”的题基本就能套模板第二题Hoof Paper Scissors稍微绕一点它考的是状态枚举和区间拆分难度卡在“你能不能想到把整个序列切一刀”第三题Secret Cow Code则是全场最阴的一道N给到10^18目的就是逼你放弃模拟转向逆向思考。我在实际测试中的感觉是如果第一次接触这套题第一题大概30分钟内能AC第二题要看状态设计是否熟练第三题如果不读两遍题可能根本无从下手。所以我说这套题非常适合用来检验自己的“算法思维成熟度”——不是看你背了多少模板而是看你在N特别大的时候能不能快速切换思路。1.3 读题时最容易漏掉的细节这里我要先提一个赛场上的细节USACO的老式题目都要用文件输入输出比如cowdance.in / cowdance.out不是标准输入输出。这个机制直到现在还在部分训练场沿用写作代码时一定要记得加freopen。很多人第一次交USACO题时挂在这里不是因为算法错而是因为忘了文件IO白白丢分。另外Secret Cow Code里的N是long long级别如果你习惯性开int读进来就已经溢出后面全白做。这三道题里两处涉及数据范围的问题恰好都是白银组选手最常犯的低级错误。2. Cow Dance Show二分答案与堆模拟的完美结合2.1 题目还原这不是舞蹈题是调度题Cow Dance Show奶牛舞蹈秀的题面大概是这样有N头奶牛每头奶牛上台表演的时间是d_i。舞台同时最多容纳K头奶牛。刚开始K头奶牛一起上台之后只要有奶牛表演结束下一头奶牛就立刻补上。现在FJ给了你一个总时间上限T_max问至少需要多大的K才能保证整场演出在T_max内结束。剥掉舞蹈秀的外壳这就是一个典型的多处理器任务调度问题K个处理器N个任务每个任务有固定执行时间d_i任务按顺序到达只要有处理器空闲就立刻安排下一个任务。问K至少多大才能让所有任务在T_max内完成。一旦翻译成调度问题很多人的直觉是“那我把d_i排序大的先安排”——这就是典型的贪心误判。2.2 为什么一定用二分答案这道题的核心不是“怎么安排某一场演出”而是“给定K判断能不能在T_max内完成”。对于给定的K模拟一遍演出流程是线性的用一个小根堆存每头奶牛的结束时间每次弹出最早结束的奶牛然后让下一头奶牛在那个时间点接上。这个模拟的复杂度是O(N log K)完全可控。那K本身怎么找你当然可以从1到N一个个试但N到10^4级别时最坏情况要试N次每次模拟O(N log N)整体就是O(N² log N)大概率超时。正确的做法是二分答案K越小总时间越长这个单调性是显然的。所以对K做二分每次check一下当前K能不能满足T_max最终找到最小的可行K。复杂度降为O(N log N log N)N10^4时运行时间几乎可以忽略。提示判断一道题能不能用二分答案就看“答案是否具有单调性”。这里显然K增大时总演出时间不可能变大。只要单调二分就是最优候选。2.3 用最小堆模拟演出流程模拟的思路很多人第一反应是“用一个数组记录每头奶牛什么时候结束然后排序找最早的”。这确实可行但每次找最早结束都要排序太慢。正确姿势是用优先队列最小堆。我给出一个可复现的check函数实现#include bits/stdc.h using namespace std; typedef long long ll; int N; ll Tmax; vectorll d; bool check(int K) { priority_queuell, vectorll, greaterll pq; // 小根堆存当前在舞台上的奶牛的结束时间 int idx 0; // 下一头等待上台的奶牛下标 // 前K头先上台 for (int i 0; i K i N; i) { pq.push(d[i]); idx; } ll lastEnd 0; while (!pq.empty()) { ll t pq.top(); pq.pop(); // 最早结束的那头奶牛下台时刻为t lastEnd t; if (idx N) { // 下一头奶牛立刻上台结束时间是t d[idx] pq.push(t d[idx]); idx; } } return lastEnd Tmax; }这里我记录的是lastEnd——也就是最后一头奶牛下台的时刻。整个流程中每当堆顶出队就代表有奶牛结束此时立刻补一头进去。注意补进去的奶牛结束时间不是它自己的d_i而是“它上台的时刻t 自己的表演时长d_i”这一点看起来简单但很容易在代码里忘掉加t直接push(d[idx])结果整场演出时间被算小二分结果出错。2.4 二分边界与long long的威力二分时的区间是[1, N]。为什么下界是1因为至少有一个舞台位这是物理下限为什么上界是N因为KN时所有奶牛同时上台总时间就是最大的那个d_i一定小于等于T_max吗不一定。等一下KN代表舞台上能容纳所有奶牛所有奶牛从0时刻同时开始表演整场演出的结束时间就是最长的那个d_i。题目保证一定存在可行解吗如果T_max连最长的单头奶牛时长都小于那确实无解——但原题数据范围保证有解所以这个极端情况不用太担心。尽管如此你仍然要明白KN时总演出时间是max(d_i)这是二分上界成立的基础。二分框架我习惯这样写int lo 1, hi N; while (lo hi) { int mid (lo hi) / 2; if (check(mid)) hi mid; else lo mid 1; } cout lo endl;这里有个常见的翻车点check里所有时间相关的变量必须开long long。为什么因为如果N10^4每头奶牛时长d_i最大可达10^9串行情况下总时间是N×d_i10^13远超int的21亿上限。我第一次写这道题时所有都用int本地小数据全对一到大数据就WA花了不少时间才反应过来是溢出。2.5 实测过程中的两个小教训第一个教训是“堆里到底存什么”。有人会把奶牛的编号也存进去其实完全没必要因为我们要的只是结束时间堆只用来取最小值。第二个教训是“当K比较大时前K头奶牛先全部上台会存在一种情况某些奶牛在0时刻上台后可能在较早时刻就结束了导致后面有奶牛在0时刻就补上吗”答案是补上的奶牛结束时间一定大于等于最早结束的那头奶牛所以整个过程的时间线是自然推进的。你不需要手动维护当前时间堆顶弹出的时刻就是当前发生事件的时间。实测下来这道题用上述代码在USACO官方数据上结果是正确的。如果你用Python写思路完全一样Python的heapq小根堆也够用但要注意常数可能稍大建议在白银组尽量用C。3. Hoof Paper Scissors前缀和与“最多切换一次”的套路3.1 规则还原不是剪刀石头布而是蹄子剪刀布Hoof Paper Scissors直译是“蹄子、纸、剪刀”本质就是石头剪刀布只不过把“石头”换成了“蹄子”。规则是蹄子H打败剪刀S剪刀打败纸P纸打败蹄子。FJ会出一个长度为N的手势序列Bessie知道整个序列但她只能全程出一个手势并且最多可以中途切换一次手势比如前半场全出H后半场全出P也可以不切换。问Bessie最多能赢多少局。这个“最多切换一次”是最关键的条件。如果没有这个条件答案就是统计FJ序列中哪种手势出现次数最少Bessie出能打败它的那个手势就行。但有了切换问题就变成了选一个切换位置i前i局用某个手势g1后N-i局用另一个手势g2使得总胜利数最大。3.2 朴素枚举为什么这么慢最朴素的想法枚举切换位置iO(N)再枚举g1和g2O(9)然后暴力统计前i局用g1赢多少、后N-i局用g2赢多少O(N)总复杂度O(N²)N10^5时完全不可行。所以必须把“统计某段区间某个手势能赢多少局”这个操作降到O(1)。这就是前缀和的经典应用场景预处理出“如果Bessie从头到尾一直出某个手势g到第i个位置时累计赢了多少局”记为pre[g][i]。有了pre数组区间查询就是简单的减法。3.3 前缀和预处理与手势编码先把三个手势映射成数字方便数组索引。我习惯用H0, P1, S2然后用一个win数组表达“谁打败谁”int win[3] {2, 0, 1}; // H(0) 打败 S(2) // P(1) 打败 H(0) // S(2) 打败 P(1)win[g]表示“手势g能打败的那个手势的编号”。比如win[0]2意味着Bessie出H0时如果FJ出S2Bessie就赢。然后预处理前缀和vectorvectorint pre(3, vectorint(N 1, 0)); for (int g 0; g 3; g) { for (int i 0; i N; i) { pre[g][i 1] pre[g][i] (fj[i] win[g] ? 1 : 0); } }pre[g][i]的含义是FJ的前i个手势中有多少个是会被g打败的。也就是说如果Bessie一直出g她到第i局为止能赢多少局。为什么这样定义因为Bessie出g能赢当且仅当FJ出了win[g]。所以统计FJ序列中win[g]出现的次数就等价于统计Bessie出g时的胜场数。这个转换要理解透很多新手会弄反写成统计FJ出什么时Bessie会赢那其实是同一个意思但数组索引容易混乱。核心枚举逻辑如下int ans 0; for (int g1 0; g1 3; g1) { for (int g2 0; g2 3; g2) { for (int i 0; i N; i) { // 前i个用g1后N-i个用g2 int cur pre[g1][i] (pre[g2][N] - pre[g2][i]); ans max(ans, cur); } } } cout ans endl;这里的i是切换点取值范围0到N。i0表示一上来就用g2iN表示全程用g1这两种情况天然覆盖了“不切换”的选项。每一轮内层循环O(1)计算整体复杂度O(9N)对10^5完全没压力。3.4 易错点编码对应关系和胜负表这道题我见过最多的错误都出在胜负关系上。有人说“猪蹄剪刀布那不是剪刀赢布、布赢蹄子、蹄子赢剪刀吗”——对但代码里编码一旦错位整个pre数组就废了。建议在实际写码前先手写一个3×3小表核对一遍。我用表格列一下关键胜负映射Bessie出拳能赢的FJ出拳对应代码H (0)S (2)win[0] 2P (1)H (0)win[1] 0S (2)P (1)win[2] 1另外题目里说Bessie“最多切换一次”注意是“最多”。如果你在实现时把“切换一次”当成“必须切换”然后把不切换的情况排除那也会错。上面枚举i0和iN的自由度恰好把不需要切换的情况包含了所以不用额外处理。3.5 延伸这道题换动态规划也能做如果你熟悉DP这道题也可以定义为dp[i][j][k]处理完前i局当前出的手势是j已经切换了k次k0或1的最大胜场。转移时考虑“这一局继续用手势j”和“如果k1则从另一个手势切过来”两种情况。复杂度同样是O(N)。不过对白银组而言前缀和版本更直观也更贴近“区间查询”的套路我建议先掌握前缀和版本DP版本可以作为进阶练习自己推一遍。4. Secret Cow Code逆向思维干掉10^18的N4.1 手算样例从COW到COWWCOSecret Cow Code秘密奶牛代码的题面是初始给一个字符串s每次操作把当前字符串和“将最后一个字符移到最前面的结果”拼起来。比如sCOW第一步把最后一个字符W移到最前面得到WCO拼起来就是COWWCO。如果继续做第二次会把COWWCO的最后一个字符O移到最前得到OCOWWC拼起来就是COWWCOOCOWWC长度从6翻到12。题目问的是重复这个操作直到字符串长度至少为NN可以大到10^18最终字符串的第N个字符是什么。4.2 为什么不能真的生成字符串很多人的第一反应是那就模拟生成啊反正长度到了N就停。但N是10^18初始字符串长度小于等于30第一次翻倍变成60第二次120……只要40多次就能超过10^18。40多次模拟本身不可怕可怕的是每一轮你都要存下一个长度可能为10^18的字符串那必定内存爆炸。所以这就是出题人设置N很大的原因逼你放弃字符串本身转向研究“位置映射”。4.3 逆向倒推的映射推导我们换个角度不生成字符串而是问“最终第N个字符是从哪来的”。假设当前长度为len的字符串是由两个长度为len/2的部分拼成的前半部分是上一轮的字符串prev后半部分是rotate(prev)即prev把最后一个字符移到最前面。如果我们要找的位置在第len/2个字符之前那它其实就在prev的对应位置。如果位置在第len/2个字符之后那它位于rotate(prev)中。此时需要做一个映射rotate(prev)的第j个字符等于prev的第(j - 1 len/2) mod (len/2)个字符。为什么是mod (len/2)因为rotate操作本质上是一个循环右移一位原字符串的第0个字符跑到了第1位第1个字符跑到了第2位……最后一个字符跑到了第0位。所以逆映射就是“向左回退一位”用取模处理首尾相接。这样我们可以从最终长度len一路倒推每次把len减半同时把要查的位置pos也缩回到上一轮字符串的对应位置直到len等于初始字符串长度L。此时pos指向的就是初始字符串中的下标直接输出s[pos]即可。4.4 代码实现与取模的边界陷阱#include bits/stdc.h using namespace std; typedef long long ll; int main() { freopen(cowcode.in, r, stdin); freopen(cowcode.out, w, stdout); string s; ll N; cin s N; ll L s.size(); ll len L; while (len N) len * 2; // 找到第一个不小于N的长度 ll pos N - 1; // 转成0-indexed while (len L) { ll half len / 2; if (pos half) { pos - half; // 现在pos是rotate(prev)中的位置 pos (pos half - 1) % half; // 映射回prev中的位置 } len half; } cout s[pos] endl; return 0; }我特别解释一下取模那行。当pos减去half之后它的范围在[0, half-1]。我们要把它映射回prev的下标。如果pos0代表rotate(prev)的第一个字符也就是prev的最后一个字符下标是half-1如果pos0则映射为pos-1。用统一的表达式就是(pos half - 1) % half。这个写法比if判断更简洁但也更容易让第一次接触的人懵建议配合注释理解。我用手算验证几组初始COWN3len初始为3直接≥Npos2输出s[2]W正确。初始COWN5len翻到6pos4。len6, half3pos≥3pos1pos(13-1)%30len3。输出s[0]C与COWWCO的第5个字符C一致。初始COWN6pos5pos5-32pos(23-1)%31输出s[1]O第6个字符确实是O。4.5 这类题型的识别特征逆向思维题在USACO白银组不算少见特征非常明显输入参数极大超过10^9但规律本身是周期性的、倍增的、或可逆的。遇到这种题不要急着模拟先画一画“正向是怎么来的”然后问自己“如果我知道最终位置它上一步应该在哪里”一旦找到逆向递推式代码往往只有十几行而且跑得飞快。5. 考场复盘白银组拿分策略与常见翻车点5.1 做题顺序先把能AC的AC掉USACO月赛的计分不是看谁先交而是看最终分数所以做题顺序很重要。我的习惯是快速读三题先做最像模板的那道。这场比赛中Cow Dance Show是最典型的模板题直接二分堆半小时搞定保底拿到第一题满分。然后做Hoof Paper Scissors前缀和思路想清楚后代码量不大。Secret Cow Code留到最后因为它需要多绕一个弯如果前面卡住了好歹已经有两题在手。三道题都写了文件IO提交前务必确认输入输出文件名。USACO老式题目文件名都是小写比如cowdance.in / cowdance.out对大小写敏感的Linux评测环境里写错一个字母就是0分。5.2 白银组典型失误统计我带人刷这套题时统计过大家在下面几个位置集中翻车Cow Dance Show二分区间开错上界开到100而不是N或者二分条件写反。建议写完后用小样例手动跑一遍二分过程。Cow Dance Show堆模拟时忘了让补位的奶牛从“上一个结束时刻t”开始表演直接push(d[idx])导致总时间偏小。Hoof Paper Scissors胜负关系写反比如把win[0]写成0而不是2导致pre统计全错。Secret Cow CodeN没开long long输入时就已经溢出或者取模时pos0的情况没有处理好导致答案偏了一位。这四条几乎覆盖了八成WA。我建议每道题AC之后都手动构造几个边界样例N1、K1、KN、N刚好等于某个翻倍长度的临界值。跑一遍没问题再交能省很多心理折磨。5.3 这套题的沉淀对之后打Golden组的意义这几道题虽然只是白银组难度但背后的思维在Golden组依然高频出现。二分答案在Gold组会以“二分答案 贪心验证”“二分答案 最短路验证”等变体出现前缀和会升级为二维前缀和、差分数组逆向思维则在各种大N题目里反复出现甚至Gold组的不少题核心难点就是你能不能发现“数据这么大只能倒着做”。所以我的建议是这几道真题不要只刷一遍就扔。第一遍独立做第二遍按“最优复杂度”要求重写第三遍试着用不同算法求解同一题——比如Hoof Paper Scissors你可以用DP再做一遍Secret Cow Code也可以思考如果初始字符串不止一个而是多个模式串拼接该怎么处理。每一遍重做收获都不一样。我当时刷完这套题最大的感受是白银组考的不是高深算法而是你能不能在一个看似农场故事的外壳下迅速识别出它背后的算法模型。奶牛舞蹈秀是调度蹄子剪刀布是区间选择秘密奶牛代码是位置映射。把这个识别速度练上去黄金组的大门就算推开一半了。
返回列表