其实今天这个标题,一开始是我在日记软件里随手起的。补题日记这个系列我写了快两年,每次比赛结束之后,不管打得怎么样,我都会留一篇记录。26-1-12 指的是 2026 年 1 月 12 日,也是这个系列的最新一篇。今天有点特殊,因为我连续欠了三场比赛的题没补,从早上十点坐到晚上十一点,总算把债还掉了一大半。这篇文章就把今天最有价值的部分记录下来:三道不同考点的题目复盘、一个折磨了我两个小时的 Bug、一次复杂度翻车现场,以及我对"补题"这件事本身的一些新想法。如果你也在搞算法竞赛,或者正在为了面试刻意刷题,这篇日记里的思路重建过程、复杂度估算方法和调 Bug 过程,应该能给你一些参考。
1. 为什么"补题"比刷新题更值钱
1.1 比赛后的"难受瞬间"才是真正的学习信号
经常有人问我,自己也打比赛,也刷题,为什么进步这么慢。我一般会反问一句:你比赛没做出来的题,后来是怎么处理的?大部分人的回答分成两类,一类是"等题解出来看了一眼,觉得自己差一点就想到,就没管了",另一类是"太难的放着,以后再补,然后就再也没有以后了"。这两个答案的问题是一样的:你只是把题看懂了,但你没有把自己的思考过程重新走一遍。
我说一个自己的体会。比赛时被某道题卡住的那个瞬间,大脑里其实发生了很多事情:我试过什么思路、为什么走不通、在哪个地方开始绕圈子、是不是对某个算法有错误的预设。这些信息非常值钱,但如果不立刻记录下来,隔一天就忘干净了。补题就是把那个瞬间重新捡起来,用冷静的状态去审视当时的自己到底缺在哪一步。这比做十道新题更能说明问题,因为新题只会暴露"这道题我不会",而补题能暴露"我为什么不会这一类题"。
1.2 补题的三个层次:看懂、会写、能讲
我把补题分成三个层次,今天的三道题刚好分别对应了这三个层次。
第一个层次是"看懂"。看完题解或者听完别人的讲解,点头说"哦,原来是这样"。这个层次最廉价,但也是很多人停下来的地方。第二个层次是"会写"。合上题解,自己从头把代码写一遍,能过题,这就开始有价值了。第三个层次是"能讲"。隔一天之后,不看任何资料,能把这道题的思路讲给别人听,包括为什么这样设计状态、为什么会想到这个算法、最关键的转化点在哪里。到了这个层次,这道题才算真正长在你身上。
我今天的补题原则就是:每道题至少走到第二层,重点题必须走到第三层。你可能觉得这很费时间,但事实证明,一道真正吃透的题,顶得上十道"看懂"的题。
2. 今日三道题复盘:从不会到会的完整路径
2.1 第一道:区间划分类 DP,难点在预处理
先说我今天补的第一道题,一道很典型的区间划分 DP。
题目大意是这样:给定一个长度为 n 的正整数数组,要把数组划分成若干个连续段,每一段的代价是该段内最大值和最小值之差的平方,求整个数组的最小划分总代价。数据范围是 n ≤ 2000。
我当时在比赛里卡住的原因很蠢:我一开始就觉得这题应该是 DP,状态定义也想出来了,设 dp[i] 表示前 i 个元素的最小划分代价,转移就是枚举上一段从哪里开始:
dp[i] = min_{0 <= j < i} (dp[j] + cost(j + 1, i))但没有提前算好 cost 数组的话,每次转移都要把区间重新扫一遍找最大值和最小值,总的复杂度是 O(n^3),n = 2000 的时候大概是 8×10^9 次操作,任何评测机都不可能跑完。我当时纠结了很久,一直在想怎么优化这个转移过程,反而没意识到自己真正缺的是一个 O(n^2) 的预处理。其实只要先花 n^2 的时间把所有区间的最大值、最小值求出来,存进两张表里,cost 就变成了 O(1) 查询,整个 DP 直接降成 O(n^2)。
我后来补题的时候,把预处理写成了递推式,而不是对每个区间重新扫描:
for (int i = 1; i <= n; ++i) { mx[i][i] = mn[i][i] = a[i]; for (int j = i + 1; j <= n; ++j) { mx[i][j] = max(mx[i][j - 1], a[j]); mn[i][j] = min(mn[i][j - 1], a[j]); } }这里有一个很关键的观察:区间 [i, j] 的最大值,可以由区间 [i, j-1] 的最大值和 a[j] 推出来。也就是说,最大最小值表可以按右端点递推出来,不需要在枚举 j 的时候再套一层循环去逐个数比较。这个"递推代替重复扫描"的思路,在很多 O(n^2) 预处理的题里都是核心思想。
预处理完,DP 本身反而特别简单:
for (int i = 1; i <= n; ++i) { dp[i] = 1e18; for (int j = 0; j < i; ++j) { int w = mx[j + 1][i] - mn[j + 1][i]; dp[i] = min(dp[i], dp[j] + w * w); } }这道题补完,我给自己记了一条经验:当 DP 转移里出现"某种区间内部信息"时,先停下来算一下,这个信息能不能全部预处理出来。如果能,那复杂度通常一下子就降一个量级。很多时候你不是不会 DP,而是被重复计算拖死的。
2.2 第二道:二分答案加贪心,思路记住后会有肌肉记忆
第二道题是个典型的"最小值最大化"问题。
题目大意:一条直线上有若干个补给点,需要安排 k 个基站,每个基站有一个相同的覆盖半径 R,要求所有补给点都被至少一个基站覆盖,求最小的 R。
这个题我在比赛里是有想法的,我知道这种"让最大值最小"的题基本都靠二分答案。但我栽在了一个贪心细节上:我当时是从左往右,每遇到一个没被覆盖的点,就把基站放在这个点正上方,覆盖半径是 R。乍看没问题,但实际是错的,因为把基站放在当前点正上方,向左方向的覆盖能力全部浪费了。正确的贪心是:遇到第一个没被覆盖的点 p,就把基站放在 p + R 的位置,这样基站既能覆盖 p,又能尽可能多地覆盖右边更远的点,向右的覆盖范围最大。
补题的时候我发现,这个"错误"其实很典型。很多人二分答案都能想到,但 check 函数里的贪心策略想得不够细。check 本身很简单:
bool check(int R) { int cnt = 0; int i = 1; while (i <= m) { ++cnt; int cover = pos[i] + R; // 基站放在 pos[i] + R while (i <= m && pos[i] <= cover) ++i; } return cnt <= k; }然后二分 R 的范围,从 0 到最大坐标差。二分答案的复杂度是 O(log V),每次 check 是 O(m),整体非常快。
这道题补完之后,我的收获不只是"贪心要往右放"这一点,而是对二分答案有了更系统的认知:二分答案的本质是把最优化问题硬生生转化成判定问题,一旦转化成功,问题难度就大幅下降。以后再看到"最小化最大值""最大化最小值""满足某个条件的极限值"这类描述,我脑子里第一反应就是二分答案加一个贪心或者 DP 的 check。
2.3 第三道:分层图最短路,状态维度的扩展
第三道题让我补得最痛快,因为它涉及一个我早就听过但一直没真正用过的技巧:分层图最短路。
题目大意:给一个 n 个点 m 条边的无向图,可以最多免费走 k 条边,求从 1 号点到 n 号点的最短路径长度。n、m 在 10^5 级别,k ≤ 10。
正确做法是:把原图复制成 k+1 层,第 i 层表示"已经免费使用了 i 条边"的状态。每条普通边仍然连接同一层里的两个点,边权不变;同时,从第 i 层的 u 到第 i+1 层的 v 连一条边权为 0 的边,表示"使用一次免费机会经过这条边"。最后从第 0 层的 1 号点出发,跑一次 Dijkstra,答案就是所有层里 n 号点的最短距离。
我当时在比赛里没做出来,主要是没建立起"把状态放进节点里"的思维。我一直盯着原图想,觉得"最多免费 k 条边"这个条件没办法在边权上处理。实际上,分层图的本质就是用节点维度去承载状态信息,把原来一个点拆成多个"带状态的点",图就变成了普通最短路问题,剩下的交给 Dijkstra 就好。
补题代码的关键部分大概是这样的思路:
struct Node { int v, layer, dist; bool operator<(const Node& other) const { return dist > other.dist; } }; // 建图时: // 同层边:add(i * (k + 1) + layer, j * (k + 1) + layer, w) // 跨层边:add(i * (k + 1) + layer, j * (k + 1) + layer + 1, 0)这里我特别想提醒一句:分层图的层数 k 不能太大。这道题 k ≤ 10,所以节点数是 n × 11,在 10^6 级别,Dijkstra 完全扛得住。但如果 k 是 10^5,你就得换个思路了,那种情况通常会转化为"在最短路基础上删掉最大的几条边"之类的贪心,不是简单分层能解决的。所以分层图是一把很好用的刀,但要留意适用范围。
三道题补完,我最大的感受是:这些考点我其实都听说过,但真正到了赛场上,能不能在正确的时间想到正确的工具,靠的不是"知道",而是"练过"。补题就是补这个"练过"。
3. 调了两小时的那个 Bug:全局变量与递归栈的联动翻车
今天原计划补三道题就收工,结果第二道题结束后,我顺手点开了一道以前比赛的题,想看看当时卡住的那题现在会不会做。然后我就掉进了一个持续两个小时的 Bug 深渊。这道题本身不难,是个树的遍历加区间统计,需要写一个递归函数去统计每棵子树的某种信息。我当时为了图省事,把"当前子树的最小值"和"当前子树的最大值"设成了两个全局变量。
3.1 现象:样例全过,一提交就错一堆
一开始样例全部通过,我心里还挺美。结果一发评测,WA 了一大片,而且错的数据点毫无规律。我第一反应是算法本身不对,于是把递归逻辑从头读了一遍,没发现问题。又构造了几种边界情况,单点、双点、链状树,全部能跑对。这就很邪门了:逻辑看起来没问题,普通数据也对,但一上大数据就乱。
3.2 定位过程:靠打印递归进出栈找真凶
我开始在递归函数里加调试输出,打算把每次进入子树和离开子树的瞬间都打印出来。跑了一个小数据之后,我发现在处理第二棵子树的时候,打印出来的"当前最大值"居然是处理完第一棵子树之后的值,而不是回溯完之后该有的值。我瞬间明白了,问题就出在那个全局变量身上。
我把"当前子树最大最小值"设计成全局变量的初衷是省事,不想在递归函数里多传参。但全局变量在递归里的行为是:只要你不显式恢复,它就会一直保留最近一次赋值的结果。我的递归逻辑是进入子树前更新 mx,子树的子树又更新 mx,等这个子树处理完返回,我既没有把它还原成父节点该有的值,也没有把它保存在局部变量里,于是下一棵子树读到的 mx 就是被"污染"的旧值。在深度大的树上,这种污染会一层一层传导,最后整个统计结果都是错的。
这里给新手一个非常直观的类比:全局变量就像一块公共黑板,任何在你之后进来的人都会看到你留下的字。递归函数里如果没有在出口把黑板擦干净,后进来的分支就会抄到上一份作业的答案。
3.3 修复思路与预防写法
修复其实很简单,递归里需要跟随调用栈变化的信息,只能通过参数传递,或者用栈结构显式维护。我当时改成在 dfs 函数签名里加两个参数,进入子节点时传入新值,每一层递归各算各的:
void dfs(int u, int fa, int cur_mx, int cur_mn) { bool is_leaf = true; for (int v : g[u]) { if (v == fa) continue; is_leaf = false; int new_mx = max(cur_mx, val[v]); int new_mn = min(cur_mn, val[v]); dfs(v, u, new_mx, new_mn); } // 用 cur_mx, cur_mn 做当前子树的统计 }这个写法的关键是:传给子树的不是同一个变量的引用,而是经过计算后的新局部变量。这样每一层递归都有自己的临时值,不需要"回溯恢复"这个动作,天然不会互相干扰。或者你不想改函数签名,就要保证在递归出口处显式恢复全局变量。但我个人强烈建议用参数传值,别用全局变量来保存递归过程中的中间状态,因为手动恢复的代码很容易写漏,尤其在一个函数里有多个 return 分支的时候,漏一个就是半夜调 Bug。
这个 Bug 虽然浪费了我两个小时,但收获也值:凡是递归里需要"跟着调用栈走的临时信息",一律不要用全局变量;如果必须用,也要在出口处无条件恢复。这个教训我记在补题笔记的第一页。
4. 一次复杂度翻车:为什么 O(n^2) 能过而我的挂了
今天的补题过程里还发生了一次小规模的复杂度翻车事件,值得单独拿出来说。
4.1 翻车现场:同样的复杂度,别人的过了我的超时
第一道区间 DP 题,我最初写的代码在比赛时直接 T 了。我本来以为是自己常数太大,赛后去翻同场选手的通过代码,发现人家的复杂度也是 O(n^2),但他过了。这一度让我很不理解:一样的量级,凭什么他能过我不能过?
后来我把两份代码放在一起对比,发现了三个关键差异。第一,他的最大值和最小值预计算用的是 int 数组,我用的是 long long。n = 2000 的时候,两个 2000×2000 的 long long 数组大概占 64MB,而 int 数组只要 32MB,内存访问成本也更高。第二,他的 DP 转移内层循环里只做加减和取 min,我竟然把 cost 计算拆成了函数调用,虽然编译器大概率会内联,但代码结构上就比人家多了一层抽象。第三,也是最致命的一点,他用了快读,而我用的是 cin 且没有关同步。当输入量到了几十万级别,这个差距就会被放大。
4.2 常数优化清单:什么值得做,什么不值得做
这里列一个我在实测中总结的常数优化优先级,从上到下性价比依次递减:
| 优化手段 | 效果 | 适用场景 |
|---|---|---|
| 开 O2 编译优化 | 对循环密集型代码提升明显,几乎白拿 | 所有 OI/ACM 环境默认开启,日常练习记得开 |
| 快读 / 关掉 cin 同步 | 数据量在 10^6 以上时收益巨大 | 任何输入量大的题目 |
| int 代替 long long | 多维数组、大循环里收益明显 | 数值范围确定不会爆 int 的地方 |
| 预计算代替重复扫描 | 直接降一个复杂度量级 | 任何出现重复区间计算的地方 |
| 减少不必要的函数调用 | 收益看编译器,一般不大 | 内层热循环里建议手动展开 |
有一点要说清楚:常数优化不能替代算法优化。如果复杂度本身就不对,任何常数优化都救不回来。这是我踩过很多次坑之后才真正接受的结论。时间复杂度是"大方向",常数是"细节",大方向错了,细节再精细也没有意义。
4.3 现场口算复杂度的经验公式
我平时在比赛里快速估算复杂度,靠几个经验值。现代 OJ 上一秒钟大概能跑 10^8 次简单整数运算,这个量级上下浮动很大,取决于评测机、语言、内存访问模式。保守起见,算法整体操作数控制在 10^7 以内最稳。
做复杂度估算的时候,我会先看数据范围,再去匹配算法方向:
| 数据范围 | 可接受的复杂度 | 典型算法 |
|---|---|---|
| n ≤ 20 | O(2^n) / O(n!) | 状态压缩枚举、状压 DP |
| n ≤ 100 | O(n^3) | 基础 DP、Floyd |
| n ≤ 2000 | O(n^2) | 区间 DP、预处理后 DP |
| n ≤ 10^5 | O(n log n) | 排序、二分、线段树、Dijkstra |
| n ≤ 10^6 及以上 | O(n) | 线性递推、差分、单调队列 |
这个表不是绝对的,但它能帮你在开写之前就排除掉错误的算法方向。我当时如果能在动手之前用这个表估一下,就不会写出那个 O(n^3) 的代码,白白交一发 T。
5. 我的补题笔记方法:一道题怎样才算"吃透"
这里专门写一下补题笔记的方法,毕竟补题日记这个系列的核心就是记录。这套方法是我自己摸索出来的,不一定适合所有人,但在实践里确实帮我避免了很多次"补过等于没补"。
5.1 一张笔记卡片需要记什么
我每补一道题,不论难度如何,都会在本地维护一个 Markdown 文件,按日期排列。每道题固定记这五项:题目链接或题号、我的错误点、一句话解题思路、复杂度、可迁移的经验。
格式大概是这样的:
### 2026-01-12 区间划分 DP - 我的错误点:只想着优化转移,忘了预处理 cost 数组 - 一句话思路:dp[i] = min(dp[j] + cost(j + 1, i)),cost 预处理后 O(1) 查询 - 复杂度:O(n^2) 时间,O(n^2) 空间 - 可迁移经验:转移中出现"区间内部信息"时优先考虑全区间预处理"我的错误点"这一项最重要。因为题解写的是"正确做法",而你需要记录的是"你离正确做法之间隔了什么"。只有把错误点写下来,下次遇到同类题,你才能在关键时刻想起"上次我就是在这里翻车的"。
5.2 隔天重做:对抗"看懂错觉"的有效手段
只记笔记其实还不够。我还有个原则叫"隔天重做":补完一道题,第二天早上,假装自己从没见过题解,重新把这道题做一遍。如果还能独立写出来,说明这道题真的补进去了;如果写不出来,说明昨天的"我会了"只是假象。
这个原则针对的是"看懂错觉"。很多人补题的时候,看完题解觉得每一步都有道理,特别通畅,但这种通畅恰恰是危险的,因为题解替你完成了最难的"从 0 到 1"的跨越。隔天重做就是强行把从 0 到 1 这个过程还给你自己。我实测下来,大概有四分之一到三分之一的题,隔天重做的时候会卡住。这说明当时根本没有内化,只是短期记忆的流畅而已。
5.3 看题解的正确姿势:分步看,不一次看完
还有一件事想特别说,就是怎么看题解。我现在的习惯是:一道题拿到手,先自己想至少两个小时,把想到的思路、为什么失败都写在草稿纸上,然后才去看题解。看题解也不是从头到尾全部看完,而是只看第一段话,或者只看题目给出的关键提示,然后合上题解,继续自己往下想。这样做的道理很简单:最困难的一步往往是"从题目到思路"的那一步,如果一次把整个题解看完,你就永远错过了自己跨越这一步的机会。
这个方法会显著拉长补一道题的时间,但拉长的这部分时间恰恰是最有效的。用句圈里常说的话来概括:补题不是把题补完,是把"我为什么想不到"这件事想明白。
6. 今天补完题之后的几个反思与下一阶段计划
一天补了三道题加一道复习,坐了一整天,腰酸背痛,但脑子特别清醒。趁着这个状态,我把今天暴露出来的问题整理了一下,顺便给自己排了下个阶段的训练方向。
第一个问题很明显:我对"把问题转化成状态维度"这类思想还不太敏感。分层图这道题我听过无数次,但比赛里还是想不到。这说明听和会之间缺了大量练习。接下来我打算集中补一小段时间的分层图和状态压缩相关的题,每次遇到这种题,不管会不会做,都先在笔记里写清楚"这题的状态维度是什么"。
第二个问题:我在递归和回溯类代码上的习惯不好,喜欢用全局变量省参数传递,结果今天用两个小时 Bug 买了教训。接下来写递归函数之前,我会先想清楚哪些信息是"跟随调用栈走的",这些信息必须走参数,不走全局变量。
第三个问题其实是好事:今天三道题补完之后,我明显感觉到自己对二分答案和区间 DP 的熟悉度上了一个台阶。特别是二分答案,赛后重新做这道题的过程,让我第一次真正理解了"最优化转判定"这个操作的威力,以前我只是会用,现在才算是想明白为什么能用。
最后分享一个今天的小插曲。下午补完第二道题的时候,我突然给自己加了个任务:把今天补的每道题,都用一句话向完全不懂算法的室友解释"这题在干嘛"。结果第一道区间 DP,我这句话讲了五分钟都没讲明白,室友听完更糊涂了。这个经历让我意识到,"能用一句话说明白"这件事本身也是一个标准,而且是一个相当高的标准。以后每补完一道题,我都打算试着做一次这句话练习,讲不明白了,说明自己脑子里还有一团浆糊。
补题日记不追求产量,追求每篇都真正把问题想透。今天这篇写得比平常长,因为那个全局变量的 Bug 实在值得记一笔。如果你也在写补题笔记,强烈建议把"我的错误点"那一栏坚持记下去,并且严格执行隔天重做。过几个月回头翻,你会看到自己是怎么一步步从"会看题解"变成"会做题"的。