尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

USACO白银组真题解析:状态压缩DP与递归分治的思维训练

USACO白银组真题解析:状态压缩DP与递归分治的思维训练 翻USACO历年题单的时候我经常翻回2012年11月这一场。白银组的题面出得特别“吝啬”三道题每一道都短到能抄在明信片上但你要是真以为它们是模拟题拿起电脑随便写写多半会撞得头破血流。这一场有两道题——Balanced Cow Breeds和Moo——是我带人准备USACO白银组时必讲的原题也是这篇真题解析想拆开揉碎的部分。适合刚进白银组、正在刷历年真题提升思维的同学也适合准备大厂笔试前想找点状态设计感觉的人。刷完你会发现老题的价值从来不在算法多难而在那一下“原来还能这样想”的顿悟。1. 2012年11月这一场为什么值得回头刷1.1 当时白银组的考法和现在不太一样USACO的赛季一般从11月或12月揭幕2012年11月这场是2012-2013赛季的开局。那个年代的白银组题目风格和现在有明显差别现在的Silver题考什么算法往往一眼能看出来——二分答案、最短路、并查集题面读完脑子里就有个大致方向。但2012年11月这场几乎不考模板它考的是“你能不能把一个看似复杂的问题压到足够小的状态空间里”。数据范围也能说明问题。N最大不超过1000这个规模摆明了是给你O(N²)甚至O(N² log N)的空间但你要是真写个O(N³)或者没事先造一个长度上亿的字符串照样超时超到怀疑人生。换句话说这场不是让你炫数据结构而是逼你先想清楚哪些状态是必要的哪些信息是多余的。1.2 两道题的内核高度统一这一场Silver组三道题我重点讲Balanced Cow Breeds和Moo两题它们是最有训练价值的。Balanced Cow Breeds是一道计数DPMoo是一道递归构造题表面上天差地别但内核其实是一个东西找到冗余信息然后果断删掉它。Balanced Cow Breeds里“蓝色未匹配数”是冗余的因为红色和蓝色的未匹配总数其实由原串的前缀平衡值唯一决定Moo里“整个字符串的内容”是冗余的因为字符串按固定规则递归生成只要长度表就够定位。很多选手刷USACO只会背算法模板遇到这种题就卡住本质上是缺少“状态压缩”的意识。而白银组恰恰是训练这种意识最好的阶段。1.3 第三题这次先放过坦白说这一场Silver第三题我这次不展开。不是说它不重要而是前两题已经把这场最核心的思维价值覆盖完了剩下那题的风格更接近模拟自己啃下来问题不大。与其什么都讲得很浅不如把两题吃透。刷题本来就是这样一个过程把一个题的价值榨干比把三个题都囫囵吞枣强得多。2. Balanced Cow Breeds一个看似三维的计数问题2.1 题面还原与等价转化题目长这样N头奶牛站成一排每头奶牛身上写着一个括号字符 ( 或 )。现在给每头奶牛戴一顶帽子要么红色要么蓝色。要求是把戴红帽的奶牛拎出来它们身上的括号从左到右拼起来必须是一个合法括号序列把戴蓝帽的奶牛拎出来同理也必须合法。问有多少种染色方案答案对2012取模。我当年第一次做这题被“奶牛”这个包装绕了半天后来才意识到奶牛只是障眼法题面完全可以等价地翻译成——给定一个长度为N的括号串把每个字符染成红色或蓝色使得红色子序列和蓝色子序列各自都是合法括号序列求染色方案数。颜色就是帽子字符就是牛身上的标记仅此而已。什么是合法括号序列这里给新手补个基础一个只由 ( 和 ) 组成的串从左往右扫描维护一个计数器balance遇到 ( 加1遇到 ) 减1过程中balance永远不能小于0最后balance必须等于0。这个过程的实际含义就是“先来的左括号要有对应的右括号去匹配”。2.2 最自然的O(N³)思路以及它为什么必死不假思索的话可以定义三维DPdp[i][r][b]表示已经处理完前i个字符红色子序列里当前未匹配的左括号数是r蓝色子序列里未匹配的左括号数是b此时的方案数。转移也很符合直觉。处理第i1个字符时如果这个字符是 (它可以被染红导致红色未匹配数r加1也可以被染蓝导致蓝色未匹配数b加1。如果这个字符是 )它被染红的前提是当前r0染完后r减1被染蓝的前提是当前b0染完后b减1。这个思路完全正确但复杂度要命。N1000r和b都可能到1000状态数就有1000×1000×1000也就是1e9级别转移还有常数。在2012年的机器上想跑完基本是做梦。就算放到现在1e9的DP也很大概率被卡。所以核心问题变成r和b这两个维度是不是真的互相独立2.3 关键观察未匹配总数是守恒的答案是不是独立的。这里有一个很重要的观察。设d_i为原括号串前i个字符中(的数量减去)的数量。这个值就是“如果我不染色只管看前i个字符有多少个左括号暂时没被匹配”。现在考虑染色版。每一步扫描中不管当前字符被染成什么颜色红色未匹配数和蓝色未匹配数之和总等于原串在这一个位置的未匹配总数d_i。为什么因为每遇到一个 (不管染给谁总会让某个颜色的未匹配数增加1每遇到一个 )不管从谁那里消耗掉总会让某个颜色的未匹配数减少1。红色和蓝色只是在这个总量池子里分蛋糕蛋糕的大小由原串前缀决定和染色选择无关。用个生活化的类比红盒子和蓝盒子共用一个总糖数这个总糖数由原串的前缀决定。你每次做选择只能决定这颗糖放进红盒子还是蓝盒子但总糖数你是动不了的。于是给定i和r蓝色未匹配数b就自动等于 d_i - r。三维状态瞬间压成二维dp[i][r]就够了b不用单独存。2.4 转移方程与完整代码状态dp[i][r]表示处理完前i个字符红色未匹配数为r的方案数蓝色未匹配数自动为d_i - r。转移时枚举旧状态里的红色未匹配数r_old看它能给新状态贡献到哪里如果当前字符是 (染红新红色未匹配数变成 r_old 1。染蓝红色数量不变还是 r_old但蓝色未匹配数加1。如果当前字符是 )染红要求 r_old 0染完后红色未匹配数变成 r_old - 1。染蓝要求蓝色未匹配数 b_old 0即 d_i - r_old 0染完后红色保持不变。完整代码如下用滚动数组思想写比较清晰#include bits/stdc.h using namespace std; const int MOD 2012; int main() { string s; cin s; int n (int)s.size(); vectorvectorint dp(n 1, vectorint(n 1, 0)); dp[0][0] 1; // 处理完0个字符红色未匹配为0 int balance 0; // 当前原串前缀的 ( 数 - ) 数 for (int i 0; i n; i) { // 先更新前缀balance if (s[i] () balance; else balance--; // 如果balance小于0说明原串本身已经非法答案必为0 if (balance 0) { cout 0 \n; return 0; } for (int r_old 0; r_old n; r_old) { if (dp[i][r_old] 0) continue; int b_old balance - 1 - r_old; // 注意这里应该是上一个平衡值 // 需要小心循环开始时的balance已经是处理完第i1个字符后的值 // 这里先统一用旧状态时的前缀平衡做检查 } } }上面这段我在循环里写了个容易混淆的地方实际写代码时建议把“上一个前缀平衡”单独记录代码会更直观#include bits/stdc.h using namespace std; const int MOD 2012; int main() { string s; cin s; int n (int)s.size(); vectorvectorint dp(n 1, vectorint(n 1, 0)); dp[0][0] 1; int balance 0; // 处理完i个字符时的原串前缀平衡 for (int i 0; i n; i) { int old_balance balance; // 处理当前字符之前的平衡值 if (s[i] () balance; else balance--; if (balance 0) { cout 0 \n; return 0; } for (int r_old 0; r_old old_balance; r_old) { int val dp[i][r_old]; if (val 0) continue; int b_old old_balance - r_old; // 理论上b_old不可能小于0因为r_old范围限制了 if (s[i] () { // 染红 dp[i 1][r_old 1] (dp[i 1][r_old 1] val) % MOD; // 染蓝红色不变 dp[i 1][r_old] (dp[i 1][r_old] val) % MOD; } else { // s[i] ) // 染红要求红色未匹配数大于0 if (r_old 0) { dp[i 1][r_old - 1] (dp[i 1][r_old - 1] val) % MOD; } // 染蓝要求蓝色未匹配数大于0 if (b_old 0) { dp[i 1][r_old] (dp[i 1][r_old] val) % MOD; } } } } // 只有原串全局平衡并且红色未匹配为0时才是合法方案 int ans (balance 0) ? dp[n][0] : 0; cout ans \n; return 0; }复杂度是O(N²)N1000时跑起来非常轻松。2.5 几个容易踩的细节第一模数是2012。这个数很小而且不是质数意味着组合数那套带逆元的做法在这里根本不能乱用。不过本题全程只有加法不需要除法所以不用怕。真正要注意的是中途所有加法都要取模有人只在最后取一次中间状态早就爆了int甚至long long了。第二原串本身可能是非法括号串。如果某个前缀的balance小于0或者最后balance不等于0答案直接是0。原理是如果红色子序列和蓝色子序列各自合法那么全局串的前缀balance等于红色未匹配数与蓝色未匹配数之和一定非负最后全局balance也是0。反过来如果全局串非法不可能存在任何一种合法的红蓝染色方案。第三状态转移里的“染蓝”分支在遇到 ) 时一定要检查b_old 0。这个检查非常容易被忽略漏掉的话会把非法状态当成合法状态累加答案会偏大。我当时就因为这个WA了好几次后来把转移拆开一点点对才找到。3. Moo递归字符串的分治定位法3.1 题面还原定义字符串序列 S_kS_0 mooS_k S_{k-1} m (k2个o) S_{k-1}举几个例子S_0 mooS_1 moo mooo moo moomooomooS_2 S_1 mooooo S_1中间的o数量是4个因为k2时k24。现在给你一个正整数N找到某个K使得S_K的长度不小于N然后输出S_K的第N个字符。N可以非常大题目范围我记得能到10^9级别这个规模注定了不可能把字符串真正生成出来。3.2 核心思路永远不要构造字符串看到这种题第一反应是把S_K直接拼出来然后按下标输出。但想想长度增长的速度S_0长度3S_1长度10S_2长度24S_3长度54……每一轮大约是上一轮长度乘以2再加上中间段整体接近3倍增长。到K30左右长度已经轻松超过10^9。想在内存里存下这个字符串基本等于自杀。正确的想法是“我根本不需要完整的字符串只需要在递归结构里定位”。S_k的结构是三段式左段是S_{k-1}中段是一个m加一串o右段又是S_{k-1}。所以我只要知道每一层的长度就能通过和左段、中段比较判断目标位置到底落在哪一段然后递归进去。这就好比在一棵完全二叉树里找第k个节点你不需要先把整棵树建出来你只要在每个节点知道左右子树的大小就能一路向下定位。3.3 长度表与分治三种情况定义len[k]为S_k的长度。递推式len[0] 3 len[k] 2 * len[k-1] (k 3)注意中间段的长度m占1个字符o有k2个所以一共k3个字符。最容易写错的地方就是这里有人会顺手写成k2后面全崩。前几个值klen[k]0311022435441165242649671006可以看到长度增长很快K超过25就已经上亿了。用long long存完全够。定位函数solve(k, pos)表示在S_k中找第pos个字符如果k等于0直接查moo里第pos个字符。设left len[k-1]mid k3。如果 pos left说明目标在左段递归 solve(k-1, pos)。如果 left pos left mid说明目标在中段。中段第一字符是m其余全是o所以当 pos left1 时返回m否则返回o。否则目标在右段递归 solve(k-1, pos - left - mid)。这三种情况覆盖了所有可能而且每次递归都会让k减1深度最多几十层根本不用担心栈溢出。3.4 完整代码与易错点#include bits/stdc.h using namespace std; long long len[50]; char solve(int k, long long pos) { if (k 0) { // S0 moo if (pos 1) return m; return o; } long long left len[k - 1]; long long mid k 3; // 1个m (k2)个o if (pos left) { return solve(k - 1, pos); } if (pos left mid) { if (pos left 1) return m; return o; } // 落在右段 return solve(k - 1, pos - left - mid); } int main() { long long N; cin N; len[0] 3; int K 0; while (len[K] N) { K; len[K] 2 * len[K - 1] K 3; } cout solve(K, N) \n; return 0; }这个代码基本没有悬念但有几个细节值得单独拿出来说。第一索引从1开始。题目说第N个字符N从1开始代码里所有比较都用闭区间差一个字符的错误很隐蔽。比如在k0的基例里我就用pos1判断是否是m而不是用pos0。第二递归右段时pos要减去leftmid只减left或者只减mid都会错。这里不需要加1因为第leftmid1个字符会变成右段的第1个字符。第三预处理len表时K的更新顺序要小心。我是先K再算len[K]利用的是len[K-1]已经算好的值。如果你写成先算再K要确保下标别越界。3.5 实测中的体会我第一次做这题时Python写了个直接拼接的版本K到25就卡到无法执行内存直接爆掉。后来改成只维护长度表哪怕N到10^12也能瞬间出结果。这类“看起来要构造巨大对象”的题在USACO里出现过很多次核心原则都是一样的能用长度、个数、引用关系描述的东西就尽量不要真的把它展开。另外Moo这题的递归结构非常建议手推一遍S_1和S_2把所有边界位置标出来比如第1个、第3个、第4个、第10个字符分别是什么再去对代码。边界题靠眼睛看是很难发现的必须自己推一组小数据验证。4. 把两道题串起来看白银组真正考的是状态设计4.1 冗余信息的识别与删除Balanced Cow Breeds和Moo一个是计数DP一个是字符串分治看起来八竿子打不着但思维内核出奇地一致识别出哪些信息是冗余的然后果断抛弃。Balanced Cow Breeds里如果你同时维护红、蓝两个未匹配数状态就是三维但你发现两者之和恒定于是蓝未匹配数可以由红色未匹配数和原串前缀平衡算出来这就是删掉冗余维度的过程。Moo里如果你试图维护整个字符串内存就爆了但你发现字符串内容具有递归结构只需要维护每层长度这也是删掉冗余信息的过程。很多选手在Silver阶段卡住不是算法储备不够而是没有养成动手前先问一句的习惯“我的状态里有没有哪一维决定了另一维我要维护的这堆东西里有没有哪一部分可以由其他部分推导出来”养成这个习惯比多背十个模板都有用。4.2 这个思维在后续题目里的反复出现后来我继续刷2013年、2014年、2015年的USACO发现白银组反复在考同一类东西看起来吓人的题绕了一圈还是让你做信息压缩。比如有的题给你一张很大的图但能到达的节点很少有的题给你一个超大的排列但你可以通过维护若干极值来跳过大部分元素。本质都是“别把所有东西都存下来”。到了Gold组会开始出现线段树、树链剖分这种具体数据结构但Silver阶段最值钱的其实不是数据结构而是这种抽象能力。把问题化成更小的状态空间很多时候比优化常数管用得多。4.3 如果用来做模拟赛建议调整做题顺序如果拿这一场做限时模拟我的建议是先做Moo再做Balanced Cow Breeds。原因很简单Moo的思路一旦想到代码30行以内就能写完属于“越想越顺”的题Balanced则需要更多的状态推导时间而且转移细节多放在后面有充足余量去验证边界。USACO的题目顺序本身是按难度排的但如果自己做了取舍也可以根据得分效率调整策略。真实比赛里稳拿容易分永远是第一优先级。5. 把这套题物尽其用的复盘方法5.1 限时训练的具体操作不要一上来就看官方题解。我的做法是拿到题面先花10分钟分析数据范围和状态定义然后动手写写不出来就回头想“我现在的状态里是不是有一维是不必要的”。每道题给自己30分钟超时了就只看一点提示比如“想想前缀平衡”或者“试试只维护长度”然后继续想。即使最后真的没AC也要先把错误的复杂版本写出来哪怕是个O(N³)的暴力也要让它能输出正确答案。这样做的意义是建立一个可对照的基准后面优化完才能确认结果对不对。我当时自己练题所有暴力版本都留着优化一版就对比一版定位逻辑错误特别快。5.2 代码细节自测清单结合这两题整理一份自测清单每道题提交前挨个过一遍题目检查点Balanced Cow Breeds原串前缀balance是否出现过负数Balanced Cow Breeds最终balance是否为0否则输出0Balanced Cow Breeds)染蓝分支是否检查了b_old 0Balanced Cow Breeds每步加法是否取模2012Moolen表的递推式中间段长度是否为k3Moo递归基例S0的索引从1开始能否覆盖N1,2,3Moo右段递归时pos是否减去了leftmidMooN是否用long long读入这个清单可以直接抄进你的刷题笔记每场做完对照检查一遍能省下大量罚时。5.3 变体拓展练习如果这两题你都顺利AC了我建议做两个变体变体一把Moo的中间段挪到前面S_k 中间段 S_{k-1} S_{k-1}。这时候分治定位的三种情况顺序变了判断逻辑也要跟着调整但核心的“利用长度表递归定位”思路完全没变。写一遍这个变体你会对递归结构更敏感。变体二给Balanced Cow Breeds加一个限制红色奶牛数量和蓝色奶牛数量必须相等。此时rbd_i这个结论仍然成立但还多了一个“最终红色数量 蓝色数量”的约束问题会变得更加立体。你可以先想想这道变体是否还能用二维DP做通想不出来也没关系这个过程本身就是收获。说实话我自己当年刷完这一场最大的收获不是AC了哪道题而是亲眼看到了“状态压缩”到底是个什么东西。后来再遇到看起来很吓人的题目我都会先想它哪些维度是虚张声势的哪些信息其实早就被别的东西决定了。这个习惯帮我省下了不知多少无谓的复杂度。如果你也准备打USACO白银组我建议把这两道题放进本周训练计划亲手推一次状态再写一遍代码。别看答案哪怕多花一个晚上那个自己悟出来的瞬间远比看十篇优化解析更有价值。
返回列表