1. 写在前面:赛后复盘比排名更重要
先说结论:CF的Div. 2轮次,是绝大多数普通选手涨分的主战场,也是暴露"会但不熟练、懂但不会用"这类问题的最佳试炼场。Codeforces Round 1086 (Div. 2)这场比赛整体难度分布比较典型,前四题属于"思维到位就能写"的范畴,后两题对代码能力和数学推导的要求明显上升。如果你正处于蓝名冲紫名、或者绿名稳定上分的阶段,这套题非常值得认真复盘一遍,而不是看一眼题解就划走。
我个人的做法是:打完之后先不看题解,把自己卡住的地方标出来,想清楚"当时为什么没想到",再去对照别人的做法。这个习惯比分多刷一套题有用得多。这篇文章不会只贴出标准解法,而是会把每道题背后的思考路径、容易踩的坑、以及我在实际写代码时遇到的细节问题尽量讲透,大家可以直接对着题目复现。
这一场的题目结构大概是这样的:A题是送分热身,B题是经典贪心变体,C题开始就需要一点结构上的观察,D题和E题是思维量最大的部分,F题则偏实现与代码稳定度。其中E题涉及的排列思想和F题涉及的DP优化,在近期的Div. 2轮次里反复出现,值得单独拿出来划重点。
为什么我强调赛后复盘而不是"刷完就过"?因为CF的题目设计往往是多层的:第一眼看到的是表面操作,第二层是背后的数学性质,第三层才是代码里的边界处理。如果你只是看着题解把代码抄一遍,那下次遇到同类型的题还是会卡。下面我就按题目顺序,一个一个拆开讲。
2. A题注意点:别急着交,先把边界跑一遍
A题的具体题意这里先不重复贴全,但它的关键点在于:给了一个简单的操作规则,要求你判断最终某个状态是否可达。很多人在这一题上会犯一个很经典的错误——只在脑袋里"想当然"地推演,觉得样例过了就是对的,结果交上去挂在某个边界数据上。
这种送分题往往藏着一个"看似无害但非常致命"的限制条件。比如有的题会让你把一个数组反复执行某种变换,然后问你是否能变成目标数组。这时候你不能只看几个正向的例子,而是要反过来想:如果某个位置已经满足条件,后续的操作会不会把它破坏掉?这和贪心算法里的"后效性"问题本质上是同一个东西,只不过A题通常把规模放得很小,让人放松警惕。
我自己打这类题的习惯是:先暴力枚举几个小数据,把答案手写出来,再去想通解。不是因为我不会数学推导,而是因为暴力能帮我确定"正确答案的形状"。有时候你公式推得爽,但推出来的东西其实不符合题意,这时候暴力就是最好的裁判。
另外,这题还提醒了一个非常实用的经验:多测题的初始化问题。CF的Div. 2 A经常是多组数据,每次循环开始前有哪些变量要重置,有哪些标记数组要清零,必须养成"先初始化再干活"的肌肉记忆。我曾经在一次比赛中,因为忘记清空一个布尔数组,同一个错误样例反复跑都对,一交就WA,最后耗掉了整整二十分钟,那次教训非常深刻。
具体到这道题,可行的做法是先模拟前几步,找到状态循环的特征,然后判断目标状态是否在循环里。如果题目里的操作是固定的(不是由我们选择的),那么状态转移路径是唯一的,这时候只要一步步走或者利用周期取余就好。如果操作是我们自己选的,那就变成了"能否到达"的判断,要去看每一步的选择能不能调整到目标状态的约束上。
第二个容易忽略的坑是:输出格式。CF的A题有时候会要求输出"YES"和"NO"的大小写,有时候要求每行末尾不能有多余空格。这些细节在本地跑的时候看不出问题,但评判系统是严格匹配的。我建议在做A题的时候,把输出语句单独封装一下,不要随手cout,减少低级失误。
3. 1. 先从送分的 A 开始:看懂操作规则就不难
3.1 操作规则的真正含义
注意,我在标题里写了"1. 先从送分的 A 开始",但正文里如果把所有题目统一按数字排序的话,A题的完整分析就在这里。
这道题的目的是引导你理解"每个操作会改变什么、不会改变什么"。如果你能从每次操作的净效果入手,那么答案往往是一两个条件判断。很多人卡住的原因是试图模拟整个过程,而不是找到不变量。
举个例子,如果操作是"选择两个元素,一个加一、一个减一",那么整个数组的和就是不变的。这时候你只要检查目标和是否等于初始和,再看能不能通过若干次操作把正差额分配出去。这种"找不变量"的思路,在CF的绝大多数题目里都是核心。
A题有时候不会直接给你一对一的变换,而是给出一个规则让你推理。我的建议是:不要一上来就写代码,先在纸上写两三个小例子,把操作执行两三遍,观察规律。如果看不出规律,就把状态空间画出来,哪怕是画在草稿纸上,也比空想强。
3.2 为什么样例只能用来验证思路,而不是用来猜答案
CF的题目有一个特点:样例通常非常友好,友好到会让你产生"这题就这么简单"的错觉。实际上,样例往往只覆盖了最普通的情况,真正的坑都在隐藏数据里。
我对A题的要求是:交之前必须构造一个极限数据和一个反例数据。极限数据看看会不会爆int、会不会越界;反例数据看看自己的判断条件是不是"必要但不充分"。这两个数据如果都能过,这题才算是稳了。
这一题的代码很短,很多人可能在五分钟内就写完了。但如果你因为"写得快"就放松了自检,那还不如不写。我见过太多选手在A题上挂了五六次才过,不是不会,而是压根没检查。
4. B题视角:当你发现"模拟不优雅"时,就该找性质了
B题在CF的定位是"有一点小弯"的题。它通常不会考复杂的算法,但一定会考"你想不想得到这个转化"。这道题也不例外。
经过A题的铺垫,B题往往会引入一个需要观察的结构。比如数组排序后相邻元素的差有某种规律,或者某种操作等价于交换某些值。这里我的经验是:如果一个操作很难直接模拟,那它一定等价于某种简单的操作。
举一个很常见的例子:如果允许你交换数组中任意两个位置的值,那么数组的"多重集"是不变的。如果这题要求的是把数组变成单调不减,那答案往往是"排序后与原数组对应位置比较,有几个位置不同"。这种"操作等价于重排"的观察,一旦想到,代码就是几行的事。
回到这一题,具体地说,你需要关注的是:题目给的变换规则是不是可逆的?如果是可逆变换,那"可达"就变成了"两个状态是否属于同一等价类"。这时候通过找等价类的不变量,就能快速判断。
我还想提醒一点:B题的贪心不是凭空猜的。任何一个看似"直接选最大"的贪心策略,都要先证明"选最大不会让后续更差"。这是贪心算法能成立的核心。很多人在B题翻车,就是因为看到样例里选择最大能过,就直接写了,结果有一个隐藏数据里必须选择次大的。
具体做法上,我建议先把输入读进来,思考五分钟没有思路,就尝试从最终状态倒推。很多B题的正解就是从最终状态往前推得到的。因为正向操作可能有多种选择,而逆向操作往往被限制得很死,反而更容易判断。
5. 2. 再看 B 的实际做法:反推法比正向模拟更省心
5.1 题目给了什么,你要抓住什么
这道B题给了我们一个序列和一系列操作。表面看是在问某个值能不能变成另一个值,但实际上它考察的是"减法思维"。
我不喜欢在题解里只写"这是经典套路"。对我来说,真正有价值的是搞清楚"为什么这个套路在这个位置出现"。以这题为例,正向模拟的问题在于:每一步的选择空间太大,导致你无法用简单的DP或搜索来覆盖所有情况。但如果你把目标反过来看,从终点往前推,每一步的操作会变得非常确定。
比如原操作是"把某个元素拆成两半",那逆操作就是"把相邻的两个元素合并"。合并的规则很明确,可选项很少。这时候你从目标状态反向合并,如果能合并回初始状态,就说明正向可达。
我在实际写的时候,会用一个栈来处理这种反向合并。每次看栈顶的两个元素是否满足合并条件,如果满足就合并并继续,如果不满足就退出判断。这种方法的时间复杂度是O(n)的,非常高效。
5.2 为什么"合并顺序"有时候会骗人
反向合并的一个坑在于:你按从左到右的顺序合并,和按从右到左的顺序合并,得到的结果可能不一样。这就需要在设计逆操作的时候,确保操作的顺序不影响最终判断。
一个判断方法是:观察正向操作的顺序是否可交换。如果正向操作的顺序可以任意调整,那么逆向操作的顺序也无所谓。如果正向操作有严格的先后依赖,那反向操作就必须严格倒叙,这时候通常需要用递归或栈来保证顺序。
这题卡了很多人的地方,恰恰就是这个顺序问题。他们找到了逆操作的规则,但没有用对顺序,导致样例过、隐藏WA。所以我在代码里,总是先写一个朴素的正向模拟(只针对小范围数据),再用反向方法去对拍,确保两边结果一致。这个方法虽然多花几分钟,但能省下后面大量的试错时间。
6. C题的真正难点:不是数据结构,是"你能否换一个角度看问题"
到了C题,思路立刻就不一样了。前面的A、B基本是"看到就能写"的送分题,而C题开始考察选手能否把问题抽象成另一种形式。这道题如果我没记错,和"分段、块操作、区间覆盖"这一类有关。
遇到这种题,我的第一反应是画图。把数组元素用一个数轴表示,把操作看成在数轴上移动某些标记。你会发现,很多看似复杂的操作,在数轴上不过是一个简单的位置映射。
C题的另一个常见切入点是:把区间问题转化为前缀和问题。区间操作的本质是对差分数组的两次单点修改,而"区间信息查询"可以看作对前缀和数组的查询。如果你能完成这个转化,代码会简化非常多。
不过要注意,前缀和优化不是万能的。它要求你的操作和查询满足某种结合律——比如"把区间加上一个值"和"查询某个位置的值"就可以用差分数组加前缀和解决。但如果查询的东西是"区间最大值",那就得依靠线段树或分块了。这一题用不用线段树,取决于数据范围。如果在10^5量级,O(nlogn)完全可接受;如果到10^6,就要想想能不能用O(n)的滑窗或双指针。
我还想特别强调一点:C题做不出来的原因,很多时候不是知识储备不够,而是没有养成"先简化、再建模"的习惯。有了C题的铺垫,后面的D、E题你会更清楚"出题人想让你发现什么结构"。
在这里我给出一道典型题的思考流程:
- 先不看数据范围,把题意用自然语言复述一遍。
- 把操作抽象成一个数学映射:输入是什么?输出是什么?限制是什么?
- 尝试把限制条件写成一个等式或不等式。
- 看这个等式能不能通过排序、预处理、单调栈等工具快速求解。
- 最后才考虑用什么数据结构去实现。
很多人一上来就想着"这题应该用线段树""那题应该用并查集",反而忽略了最根本的建模过程。数据结构只是工具,不是思考本身。
7. D题与E题之间,隔着一道"思维复杂度"的分水岭
Div. 2 的D题通常是一个分水岭:能稳定做出D题的人,基本上都在蓝名以上。这场的D题同样如此——它不是靠堆板子就能过的题,而是需要你把题目里隐藏的约束条件挖出来,然后选择一个看似反直觉的方案。
7.1 D题倾向于考察"约束条件对算法选择的影响"
我举一个非常常见的例子:如果有两个班级分别有n个学生,你需要派若干学生去参加比赛,每次操作会改变两个班级的人数差,那这种题的约束条件往往导向"把所有人数的奇偶性统一考虑"。
因为很多操作对奇偶性的影响是完全固定的:某操作会让一个变量变化1,另一个变化1,那么它们之差的奇偶性不变。只要你发现"奇偶性不变"这一点,题目就会瞬间变得非常简单。
这场比赛D题的关键,就在于你能否发现操作的"不变量"。而不变量通常藏在操作对某个代数性质的保持上。常见的候补有:奇偶性、总和、异或和、模某个数的余数、最大值、最小值的差值,等等。
我在实际做题时,有一个习惯:把每个操作写成"等价变换"的数学形式,然后用三个小数据去验证这个变换的组合效果。如果两个不同的操作路径得到相同的结果,那说明这个变换存在某种交换律或结合律,这对找不变量非常有帮助。
7.2 E题的排列类问题:不是让你排序,而是让你找出"隐藏的偏序"
E题如果涉及到排列和交换,通常不是在考察快排代码,而是在考察"排列中的逆序对"或"置换分解成循环"的概念。这一场E题,至少我看到的解法里,有很多选手是通过分析操作对排列奇偶性的影响来解的。
排列的奇偶性本质上是一个 +1 或 -1 的符号:每交换两个元素,排列的奇偶性翻转一次。如果你的操作是交换两个元素,那么最终排列能否达到目标,就要看操作次数与排列奇偶性的关系。
这里有一个常见误区:不要以为"能通过交换实现"就等于"一定能通过任意交换实现"。如果题目限定了只能交换某几个固定的位置,那你需要判断的就不只是逆序对数量,而是"可交换位置构成的无向图是否是连通的"。只有在连通图内,任意置换才能通过若干次交换实现。
我做这类题时,比较喜欢用并查集来维护"哪些位置之间可以通过操作互相到达"。跑通并查集之后,把每个连通块内的元素分别排序,看能否和目标排列对应。这是很多排列题的经典通用解法:交换可达性 + 局部排序。
E题的实现细节也很多。比如并查集路径压缩的写法,还有对目标位置的坐标映射,都要小心。如果不把"位置坐标"和"当前值"区分开,很容易写错。我的习惯是开两个数组:一个记录当前位置上的值,另一个记录某个值当前所在的位置。这样每次交换、查询都只用O(1)维护,不容易出bug。
8. 3. C 题也可以做:从区间思维到差分优化
8.1 区间操作的常见套路:差分是线程
区间操作如果是"给一段连续的区间统一加某个数"或"统一赋某个值",它的实际作用范围是[l,r],这天然适合用差分数组来表示。
设差分数组d[i] = a[i] - a[i-1],那么区间[l,r]加x,就等价于 d[l] += x,d[r+1] -= x。这样,原本O(n)的区间修改就变成了O(1)的单点修改。最终要得到整个数组,只需要做一遍前缀和。
这题如果采用差分,时间复杂度会非常可观。我写这类题的时候,还会顺手检查一下数据范围,确认是否需要用long long。CF经常在布尔数组和int数组上"设坑"——你以为加一个最大不超过10^5的数,结果累加之后可能到达10^10,int就爆了。
8.2 什么时候不能直接用差分
差分虽好,但要注意它只适用于"可累加、可抵消"的操作。如果操作是"把区间里的每个数都变成它们的最大值",这种非线性操作就不能直接用差分。换言之:差分能处理的只是"同质增减",处理不了"基于数值的选择"。
CF的C题如果考察区间类问题,往往会在"线性"和"非线性"之间做一个权衡。你要先判断操作的性质,再决定用差分、前缀和、线段树,还是分块。很多选手一看到区间加,就直接掏出懒标记线段树,结果发现自己想复杂了,白白浪费时间。
我自己的经验是:先用最简单的方式(O(n^2)暴力)把题目做对,再考虑怎么优化。如果暴力代码能AC,那最好;如果不能AC,你也有了一个对照的正确实现,后面优化出的算法可以和暴力对拍。对拍是竞赛里非常重要的调试手段,尤其适合Div. 2的C、D题。
9. 4. D 题实战:用不变量切入,代码控制在 20 行以内
9.1 什么是"不变量",怎么找"不变量"
"不变量"这个词,听起来高深,其实就是一个在操作过程中始终保持不变的代数量。比如说"数组总和不变""异或和不变""奇偶性不变""逆序对数量的奇偶不变"等。
找一个量的不变量,思路是这样的:
- 看操作中有没有成对的+1和-1,这样总和或差值保持不变。
- 看操作中有没有成对的"交换",这样集合不变。
- 看操作中有没有模运算,这样某种余数不变。
找到了不变量,D题往往就被压缩成一个非常简单的判断条件。比如"如果初始状态和目标状态的奇偶性不同,则无解;否则一定有解"。接下来你只需要构造一组可行操作。构造的时候,可以从目标状态倒推,也可以把问题分解成若干个"小目标"逐步实现。
9.2 D题的代码简洁性
我在写D题时,经常会把代码控制在20行左右。行数少不代表简单,而是因为你只需要做有限次判断和简单循环。相反,行数越多的代码,往往说明你的思路绕了远路。
这里也分享一个经验:如果你发现D题的代码超过了80行,大概率是思路选复杂了。停下来重新思考,或者看看题目的约束条件是否有更强的性质。比如如果n很小,可以直接搜索;如果n特别大,那说明答案一定存在某种O(1)或O(logn)的判断方式,否则数据范围不会开那么大。
D题往往包含2~3个隐藏分支判断。我在代码里会用if-else清楚地标出每个分支,并在每个分支前面加注释说明"这个分支对应的实际状态是什么"。这样不仅自己不容易写错,review的时候也一目了然。
10. 5. E 题全解:排列的交换结构与并查集维护
10.1 把操作看成图上的连通性
这题的核心之一,在于把"可以交换的位置"看成图上的边。如果a位置和b位置可以交换,就在对应节点之间连一条无向边。于是,所有可以互相影响的位置就构成了若干个连通块。
为什么连通性这么重要?因为在一个连通块内部,你可以通过若干次相邻交换,把块内的元素重排成任意顺序。所以,如果你想让每个位置的最终值等于目标值,只需要保证"每个连通块内部的值集合与目标值集合一致"即可。
这个结论非常强,它把"任意排列"问题简化成了"分块重排"问题。写代码时,只需要三步:
- 根据可交换关系建立并查集。
- 对每个连通块,记录它当前拥有哪些值。
- 对每个连通块,记录它目标需要哪些值,比较两者是否相等。
如果在某个连通块里,当前值的集合和目标值的集合不等,就无解;否则一定有解。
10.2 并查集实现上的小坑与优化
并查集本身很简单,但在这一题里需要注意:点的数量是位置数量,你要用位置编号来合并,而不是用值来合并。很多初学者搞混这一点,把值当成节点编号,结果并查集合并得乱七八糟。
我建议把数组命名为pos和target,分别表示"当前某个位置上的值"和"某个位置最终需要的值"。在比较连通块内值集合时,不要在每次查询时重新排序,最好在合并完成后,用一个容器(如multiset或map)来存储当前值和目标值的差。这样每次合并块的时候,只需要维护每个块的"差值统计",可以在O(nlogn)内完成。
如果题目保证操作次数是无限的,那么连通块内部任意重排均可,无需关心操作次数。但如果题目给定了最大操作次数,那就需要对操作次数做额外的分析。这一题至少我看到的标准做法,并没有限制操作次数,所以主要考的就是并查集和集合比较。
10.3 E题的边界条件:不要忘记孤立点
并查集方案还有一个隐藏的边界条件:位置如果没有任何可交换的伙伴,它自己就是一个连通块。在这个孤立块里,当前值必须和目标值完全一致,否则无解。
很多人在写代码时会漏掉孤立点的处理。因为孤立点不跟任何点合并,它的集合大小是1,你如果只遍历"有合并关系的连通块",就会漏掉这些点。我建议在最后统一遍历所有位置,找出每个位置的根节点,再把当前值和目标值都加入对应根的集合里,用multiset或map统计后再比较。这样孤立点也能覆盖到。
11. F题(如果时间允许):DP优化与代码稳定性才是壁垒
F题往往是Div. 2中最难的题目,它不再单纯考思维,而是把思维和代码量捆绑在一起。如果你能稳定写出F题,那你的水平至少在Div. 1的边缘了。
这题的DP优化方向通常是斜率优化、凸包优化、单调队列优化或滚动数组。具体应用哪一种,取决于状态转移方程的形式。
如果是形如dp[i] = min(dp[j] + cost(j, i))的转移,并且cost具有单调性,优先考虑单调队列优化;如果cost是形如(a[i]-a[j])^2的二次函数,就要考虑斜率优化。
斜率优化的核心在于把转移方程写成dp[j] + a[j]^2 - 2*a[i]*a[j]的形式,然后把每个j看作一条直线,用平衡树或Li Chao树来维护。不过,除非你非常熟练,否则不建议在Div. 2的F题里临时现推斜率优化,因为出错的概率太高。
更实用的做法是:先写O(n^2)的朴素DP,确保正确性;然后观察数据范围,如果n是10^5,则O(n^2)肯定超时,再考虑用单调队列/斜率优化优化到O(n)。这样至少能保证你没有思维漏洞,接下来只需要解决性能问题。
F题还有一个难点:代码稳定性。CF的评测环境严格依赖退出码、内存占用、IO速度。建议:
- 使用
ios::sync_with_stdio(false); cin.tie(nullptr);加速输入输出。 - 对于大量读入,使用
scanf或快速读入。 - 数组大小至少比题目最大值大2,防止循环边界越界。
- 动态规划数组尽量用
long long,因为累加和可能超出int范围。
我见过不少选手,明明思路对了,但因为数组开小了、初始化错了、或者忘记取模,而痛失AC。这种失误在F题上尤其致命,因为你已经花了大量时间思考,最后却挂在代码细节上,非常影响心态。
12. 关于这场比赛的三个高频错因总结
- 第一,多测输入没清空。A、B题特别容易中招。记住:每次循环里定义的变量和容器,如果是在循环外声明,一定要在循环内重置。
- 第二,把"集合相等"和"排序后逐位相等"混为一谈。在E题这类排列问题中,如果你只判断了每个位置的当前值和目标值是否相等,那等于没判断;要判断的是整个连通块的值集合是否相等。
- 第三,不要随便用数组下标1和0混用。CF的题经常默认数组从1开始编号,方便前缀和。如果题目是1-indexed,而你习惯0-indexed,记得在边界处做转换。
我个人在这场比赛里的实际排序代码中,还吃了一次"sort排序范围多写一位"的亏。sort(v.begin(), v.end()+1)这种写法在本地没问题,但一旦有越界读取,CF评测时可能直接RE。后来我给自己定了一条规矩:排序范围必须从at()开始确认长度,不许想当然。
13. 赛后怎么从这场比赛中带走更多
如果你看完这套题解,准备动手复现,我建议按照以下顺序练习:
- 先独立把A、B重新写一遍,要求一次AC,不许看之前代码。
- C题可以对照题解总结思路,然后不参考任何代码实现一遍。
- D题和E题重写并查集方案,至少写两遍,确保代码稳定。
- F题如果时间有限,可以先口述解法,再决定是否完整实现。
复现不是抄代码,而是"默写"。默写的过程中,你会发现自己哪个环节真的没理解。
额外提一个建议:把每道题的时间复杂度、空间复杂度、上下界都写下来。比如A是O(n),B是O(nlogn),C是O(n),D是O(n),E是O(nlogn),F是O(n)。这能帮你建立"看到题目就估算复杂度,再判断是否可行"的直觉,而这个直觉在真正的比赛中至关重要。
这场比赛整体来说,前四题是好拿分的题,后两题则真正把选手之间的思维差距拉开了。如果目标是稳定上分,我建议认真做透前四题;如果目标是冲高排名,E题和F题的思路必须烂熟于心。希望大家看完之后,不只是收藏,而是真的打开编辑器,把每一题的代码都重新敲一遍。下一次比赛,这些积累就会变成你的直觉。