打完第 970 场 Div.3 之后,我花了三天时间把那两道赛场上没做出来的题补掉,顺手把整场的思考过程整理成了这份个人笔记。之所以说“整理”而不是“写题解”,是因为我现在越来越确信,对一场 Div.3 来说,你赛后能从复盘里带走什么,比当场多过一道题重要得多。这篇笔记会讲我怎么给复盘分层、怎么把散题归进模板、以及我在这类型中间段位比赛里踩过的一组典型边界坑。如果你正卡在 1300 到 1600 这个分段,或者刚接触 Div.3 想建立自己的刷题节奏,这份笔记应该能给你一些能直接用的东西。
1. 我的复盘笔记到底记什么
1.1 开头先记五个数字
每场比赛打完,我会第一时间在本子里写一行带时间戳的摘要,内容固定是五件事:AC 了几道、WA 了几次、总用时、补题前最后评分区间、赛后重做列表。这五个数字看起来简单,但它能在一分钟之内告诉我这场状态到底烂在哪。
比如 970 这一场,我当场 AC 了 A、B、C、E 四道,D 和 F1 各 WA 两次没调完。WA 的提交里,D 两次都错在数组长度写错,F1 第一次是没开long long,第二次是二分边界写错。把这些数字放在一起看,问题就暴露得很清楚:D 属于思路没大错的实现失误,F1 属于边界条件不熟练。前者说明我码力不稳定,后者说明我对“最大最小”这类题型的条件反射还不够快。有了这个判断,我补题时就不会无脑刷掉整场,而是专门把 D 和 F1 当重点。
1.2 按题型归档,而不是按题目归档
很多人的复盘笔记长这样:A 题怎么写,B 题怎么写,C 题怎么写……写得很完整,但下次遇到类似题还是想不起来。我自己的做法是反着来:先把题目粗分为“枚举 / 贪心 / 前缀和 / 二分 / BFS / DP”几个抽屉,再把题塞进去。一道题可以同时进几个抽屉,无所谓,关键是让“题型特征”和“解题动作”建立关联。
这一场我归出来的抽屉是这样:
| 题型抽屉 | 题目分布 | 我的主判断依据 |
|---|---|---|
| 模拟 / 构造 | A、B | 操作过程可描述,输出受限制小 |
| 前缀和 + 计数 | C、D | 区间性问题,能转成前缀差 |
| 贪心 / 排序 | E | 取最优顺序时往往需要排序 |
| 二分答案 | F1 | 求“最大最小”或“最小最大” |
| BFS / 状态搜索 | F2 | 格子或图上的最少步数 |
这样的归档方式等于强迫我给每道题写了两行“特征签名”。比如以后看到“最多能分成多少段,使每段和不超过 K”,我不是去回忆某道具体题的代码,而是立刻想到“预处理前缀和 + 二分或者双指针”这个组合。这个动作本身,就是复盘的真正意义。
2. 把这一场拆成四类核心题,逐个说透
2.1 模拟与构造题:找到周期就到终点了
模拟题是 Div.3 的送分主力,但也很容易送走我。这类题的特征是给你一个过程、一堆规则,让你输出若干次操作后的状态。A、B 两场我都是十分钟内过的,复盘时我给自己总结了一条铁律:先找循环,再写循环,千万别直接人肉模拟。
举个典型的复盘记录:假设有 n 个位置排成一圈,每个位置有一个方向标记,每轮问你从某个位置出发,能否在 k 步之类到达目标。直接 while(k--) 或者队列模拟在 n 很大、k 很大的时候必挂,但这题实际上每一轮状态完全一样,走完一圈后就是周期重复。我当时的做法是先手动跑三四轮,把周期长度算出来,再用 k 对周期取模,剩下的步数才实际走。这个思路平平无奇,但却是这一整场模拟题里最关键的观察。真正在赛场上出错的人,往往是没意识到“复现”这件事本身就是突破口。
构造题则不太一样。它不给过程,让你直接输出一个可能解。这类题我复盘时只记一个问题:有没有“不变量”。比如让一个数组经过若干相邻交换后变得有序,最小操作次数不是由数组本身决定,而是由它的逆序对数决定。抓住不变量,构造方案就会从一个“碰运气”的过程变成一个有根据的推导。Div.3 的构造基本不会太难,多数只要构造一组“满足条件的排列”或“满足某种奇偶性的方案”,先猜、再证、再写,三步走。
2.2 前缀和与区间计数题:看到区间就想差分与映射
C、D 两道题我都是栽在同一条路上:直接暴力双循环统计子区间,结果要么超时,要么因为前缀和数组下标写错而 WA。赛后我把这类题专门拎出来重新练了一遍,这里把核心套路分享出来。
先说最经典的例子:给定一个只包含 0 和 1 的数组,问有多少个子区间里 0 和 1 的个数相等。如果直接枚举左右端点,是 O(n^2),n 到十万就当场去世。正确姿势是把 0 看成 -1,把 1 看成 +1,然后求前缀和数组 pre。于是问题变成了“有多少对 (i, j),使 pre[i] == pre[j]”,因为只有在两个前缀和相等的位置之间,区间的数字总和才为 0,也就是 0 和 1 个数相等。
实现时我差点又踩一个坑:pre[0] = 0也要算进统计里,否则漏掉从数组开头就满足条件的区间。正确代码大概是:
#include <bits/stdc++.h> using namespace std; int main() { int n; cin >> n; vector<int> a(n + 1); for (int i = 1; i <= n; i++) { int x; cin >> x; // 把读进来的值映射成 +1 / -1 a[i] = (x == 1 ? 1 : -1); } unordered_map<int, long long> cnt; cnt[0] = 1; // 前缀和为 0 出现一次 long long ans = 0, pre = 0; for (int i = 1; i <= n; i++) { pre += a[i]; ans += cnt[pre]; // 如果这个前缀和之前出现过,就能配对 cnt[pre]++; } cout << ans << "\n"; return 0; }注意答案要开long long,因为合法区间数可能远大于 int 范围。这是我 F1 之外第二次被 int 坑的地方,与其说算法不会,不如说“随手开 int”的毛病根深蒂固。前缀和的另一类变种是“区间和能被 m 整除”,套路完全一样,只是把 pre 的值对 m 取模后存到 map 里。这类题刷上三五道之后,基本就是肌肉记忆了。
2.3 二分答案题:先写判断,再写二分
Div.3 里一旦出现“最大化最小值”“最小化最大值”“求最大的可行长度/容量”,基本可以闭眼往二分答案上靠。这一场 F1 就是典型的二分答案题,我复盘时把完整的思考顺序写下来,方便下次卡住时直接对照。
假设我们有若干个桶,每个桶容量有限,要把一串物品按顺序装入桶中,问在“最多分成 k 个桶”的前提下,桶的最小容量是多少。这类问题的关键词是“最小容量”,它同时具备两个条件:越大越容易满足,越小越难满足。单调性存在,于是可以对桶容量做二分。
二分的每一轮里,用一个判定函数去验证:给定容量 mid,能不能把所有物品放进不超过 k 个桶。这个判定函数其实就是一个线性扫描,用当前桶剩余容量记录一下,装不下就开新桶。复杂度是 O(n) 一次判定,总复杂度 O(n log V),完全能跑。我这一场卡住的点不在思路,而在二分边界:
int l = 0, r = 1e9 + 1; while (l < r) { int mid = (l + r) / 2; if (check(mid)) r = mid; else l = mid + 1; } cout << l << "\n";这个写法里,判定成功说明 mid 够大,我们应该收缩右边界;失败说明 mid 不够,左边界往右。整体是“找最小可行值”的标准框架。我以前最喜欢把判断成功和失败写反,然后死循环。复盘时我总结了一个口诀:找最小可行值,满足则右边界等于 mid;找最大可行值,满足则左边界等于 mid。这个记法比背代码可靠得多。
还有一个容易被忽视的细节:二分答案里的答案可以是负数吗?很多时候我们直觉上把左边界设为 0,但如果物品里可能有负数,容量不可能是负的还好说,遇到某些带 pad 的间距类问题,最低可能合法值就真的是负数。所以先认真读题,看边界是从 0 开始还是从极小值开始。970 的 F1 我用l = 0没问题,但我有个朋友在另一道等价题上因为没把 l 设成“所有需求的最小值”而 WA 了一整场。
2.4 BFS 与状态搜索:把“图”画出来再动手
这一场后半段的图论题没让我好过。F2 我选取了一个迷宫最短路的最朴素套路:给定一张地图,从起点到终点,只能走空地,求最短步数。这类题的模板大同小异,BFS 按层扩展,用 dis 数组记录第一次到达某格子的步数,第一次到达终点时输出即可。
但我复盘时真正想记的,不是模板本身,而是“什么时候该用 BFS,什么时候该用 Dijkstra”。Div.3 的图论题绝大多数边权为 1,BFS 就可以秒杀。只有在边权不一致、或者某些状态转移有额外代价时,Dijkstra 才需要上场。我的快速判断方法是:问自己“每一步的代价是不是完全相同”,如果完全相同的,BFS;如果移动代价随位置或方向变化,就 Dijkstra。970 这场里有个状态还带“传送阵”,那就要把状态数组扩展成带层数或带传送信息的二维布尔数组,BFS 照样能处理。
BFS 代码模板也顺手贴在笔记里:
queue<pair<int, int>> que; vector<vector<int>> dis(n, vector<int>(m, -1)); dis[sx][sy] = 0; que.push({sx, sy}); while (!que.empty()) { auto [x, y] = que.front(); que.pop(); for (int i = 0; i < 4; i++) { int nx = x + dx[i], ny = y + dy[i]; if (nx < 0 || nx >= n || ny < 0 || ny >= m) continue; if (dis[nx][ny] != -1 || grid[nx][ny] == '#') continue; dis[nx][ny] = dis[x][y] + 1; que.push({nx, ny}); } }比起代码写法,我更想强调状态数组的长度。很多图论 WA 根本不是算法问题,而是dis的开法错了:比如地图是 n 行 m 列,结果开成了 m 行 n 列,下标一转就数组越界。我这场 D 的两次 WA 全部死在类 似 的 “数组维度写反”上,这一点后面专门讲。
3. 这一场我踩进去的四个边界坑
3.1 int 溢出:别以为只有 1e18 才叫大
赛场上有一种憋屈叫“思路对了,答案类型没开对”。前缀和、区间计数、排列组合,动辄就能达到 O(n^2) 的规模,n 到 2e5 时答案上限就是 4e10,int 根本放不下。我这一场至少有两个地方应该直接long long ans = 0从开头就开对,结果硬是等到 WA 之后才改。复盘时我把所有可能爆 int 的位置列了个清单:前缀和 pre、合法配对数量、乘法中间结果、二分答案返回值的判断函数内部累计和。看见任何“求和”或“计数”关键词,默认long long,你在第一层写类型时就该有这个意识。
3.2 二分死循环:问题出在“趋于零的前进规则”
二分死循环最常见的原因是左右边界收敛时,如果中间值mid永远不变化,while 就卡死了。比如我写“找最大可行值”时,如果代码是这样的:
if (check(mid)) l = mid; // 这里忘了确保 mid 在变化 else r = mid;那么当l = 3, r = 4, mid = 3时,check(3) 为真,l 被设成 3,一下次还是 3,死循环。正确写法要么用mid = (l + r + 1) / 2让中点偏右,要么在check(mid)成立时写l = mid + 1而不是l = mid,然后再考虑最终答案要不要减一。复盘时我给自己定的规矩是:任何二分答案的中点在边界相邻时,必须能保证区间长度至少缩小 1。这个检查比背哪个模板管用十倍。
3.3 下标是 0 还是 1:和的终点和起点都会骗你
前缀和问题里最容易错的一行是pre[0] = 0有没有被算进去。比如统计“区间总和等于 0 的区间个数”,如果你只从 pre[1] 开始往 map 里放,就会漏掉以 1 为左端点的区间。再比如说某些题要求“从 l 到 r 的和等于 pre[r] - pre[l-1]”,那么数组长度必须开成 n+1,for 循环从 1 开始存储,这样 pre[i] 就对应前 i 个数。省掉 pre[0] 的那个人,往往会在边界样例上翻车。
我整理了一个自查清单:第一次出现的下标是不是 0?map 初始化时存没存 pre[0]?循环是从 0 开始还是从 1 开始?输出答案时用 l 还是 l-1?每次写完都要逐个对,电光石火之间能救下不少 WA。
3.4 输入输出性能:刷量很大的时候,std::endl 是敌人
这一场我在补 F2 时,中途用了一段很朴素的 cin/cout,没有加快读,输出还带endl。结果本地样例跑得挺欢,交上去超时。原因无他:大量输出时endl会强制刷新缓冲区,每行一次 flush,性能损耗巨大。复盘后的长期修复方案很简单,把所有模板统一写成:
ios::sync_with_stdio(false); cin.tie(nullptr);同时,输出时用'\n'而不是endl。这个方法能解决 95% 的 IO 性能问题。剩下 5%,比如 n 到 1e6 且输入量超大,再用快读或scanf。对 Div.3 来说,只要把这两行加上,基本告别 IO 超时。
4. 直接抄走:一份能复用的 Div.3 复盘模板
我现在每场打完之后都会直接复制下面这个模板,然后往里面填内容。它足够轻量,让复盘这件事不会变成负担:
## [比赛编号] Div.3 复盘 - 当场完成:A, B, C, E - 补题完成:D, F1, F2 - WA 次数:Dx2, F1x1 - 总提交数:11 ### 按题型的抽屉归档 - 前缀和 + 计数:C, D - 二分答案:F1 - BFS:F2 - 贪心:E ### 每道题一句话教训 - A:模拟题,先算周期,再暴力 - B:构造题,先找不变量 - C:前缀和统计,map 要预置 pre[0] - D:数组维度别写反,看看 n 和 m - E:排序之后贪心,别忘开 long long - F1:二分答案,判断函数返回 bool,边界用 l < r - F2:BFS 状态数组多层,注意传送门 ### 下次目标 - 所有计数答案一律开 long long - 二分模板统一背熟“找最小/最大”两套写法 - F2 类迷宫题限定 30 分钟内调通把这个模板存成本地 markdown 文件,每场比赛一个文件。你会发现,连续记录 5 场以后,自己的高频问题项会非常清晰地浮出来,这就是所谓“u200b数据驱动的刷题”。
5. 补题时顺手做的小事:写一个小型对拍器
打完比赛补题,不是把 AC 代码交上去就完了。我会做一件事:写一个暴力解和一个优化解,然后跑小数据随机对拍。这听起来有点蠢,但它是捕捉“假通过”的最好办法。比如这场 F1 我用二分答案写的代码本地样例全过,但如果我拿它去对拍一个 O(n^2) 的暴力,在 n=20 随机数据下跑 200 组,立刻就能抓到边界错误。
对拍脚本思路也不复杂,核心只有三步:生成随机输入、跑两个程序、逐行比较输出。比如用 bash 快速实现:
for i in $(seq 1 200); do python gen.py > in.txt ./std < in.txt > out_std.txt ./test < in.txt > out_test.txt if ! diff -q out_std.txt out_test.txt > /dev/null; then echo "WA on test $i" break fi done对拍能覆盖到那些你想不到的手工样例,帮我省下了很多次无意义的 rejudge。这个方法特别适合前缀和、二分这类容易边界出错的题。对拍本质上是在告诉我们的脑子:别太相信所谓木一的“通透感”,写下来的代码才算数。
复盘到最后,我觉得最值得记住的不是某道题的解法,而是那三个数字:WA 次数 × 错误类型 ÷ 题目难度。我已经连续三轮 Div.3 都把“计数答案开 int”写进失误清单了,这一场终于靠模板强制改掉了。下一次遇到类似题,我应该可以更早地跳去 F2,而不是在 F1 和边界条件搏斗到收场。