10:24到12:00,整整96分钟,我没有开任何新题,也没有刷任何新知识点,只是老老实实地把过去47天攒下来的模板、错题和思路重新过了一遍。这就是我今天要分享的内容:0x3f训练计划第48天的复习实录。如果你也处在一个长期刷题计划的中后期,发现前面学的东西开始变模糊,这篇文章应该能给你一套可以直接抄的复习方案。
很多人会把“复习”当成一种可有可无的仪式,觉得只要新题刷得多就算进步。但以我过去47天的经验,前两周学的东西如果不主动回顾,基本已经退化成了“好像见过但写不出来”的状态。第48天这个时间点其实很微妙——再往后就是验收阶段,前面埋的坑再不填,后面就得连本带利地还。所以这96分钟我没有用来冲刺新内容,而是全部投给“把旧东西重新焊死在脑子里”这件事。
1. 复习日的计划与思路拆解
1.1 为什么偏偏是第48天安排复习
先说一个反常识的判断:越靠近计划终点,复习的优先级越高。我给自己定的是一个49天训练周期,第48天是最后一个可以完整用来查漏补缺的白天,第49天我只打算做一次模拟实测。如果第48天还在贪心刷新题,那前面47天里遗漏的模板缺陷、错误的思维定式就会被直接带进验收,到时候翻车概率极高。
这种安排背后其实是记忆规律在起作用。新学的算法和解题套路,在前24小时遗忘得最快,一周之后就会出现明显的“提取困难”。我翻了一下自己的打卡记录,发现第一天学的快速幂,到了第10天还能顺利写出来;但第20天学的最小生成树,到第35天的时候我已经需要对着模板才能磕磕绊绊地写完。这说明一个残酷的事实:刷题量不等于掌握度,只有反复提取过的内容才会变成长期记忆。
所以我从第40天开始就强制自己执行一条规则:上午黄金时段不碰新题,全部用来复习旧内容。第48天更是把这条规则执行到极致——连时间段都提前锁死了,10:24开始,12:00收工,中间不允许刷手机、不允许切出去看别的题。复习不是随缘的事,它需要被当成一个正经项目来排期。
1.2 96分钟的时间切分方案
很多人的复习低效,问题出在没有给复习内容划分优先级。96分钟看着不长,但如果只是“打开错题本随便看看”,一上午就废了。我的切分方案是这样:前10分钟翻一遍错题本和模板卡,做一次“漏洞扫描”;接着30分钟专门默写两个高频模板,目标是完全不看参考代码;再花35分钟重做两道旧题,要求自己一次通过;然后用15分钟做口述题解,模拟给一个完全不懂的人讲思路;最后留6分钟记录本次复习暴露出的新问题。
这个时间分配背后有一个原则:输入型复习只占一小部分,输出型复习占大头。翻错题本是输入,默写模板是输出,重做旧题是输出,口述题解还是输出。记忆这个东西很奇怪,你盯着看十遍不如动手写一遍、张嘴讲一遍。我把输出环节压到80%以上,就是因为过去踩过太多次“看懂了但写不出来”的坑。
具体到每个环节,我还会提前设定一个可量化的目标。比如说“翻错题本”不是漫无目的地看,而是要求在封面记下三个问题:最常出错的边界条件是什么?上周卡了最久的一道题卡在哪一步?哪个算法模板需要重新默写?如果拿不出这三个答案,说明复习还没真正开始。
1.3 复习的三个层次:模板、思路、题感
我习惯把复习拆成三个层次,每一层对应不同的检查标准。第一层是模板层,要求“能默写”。比如并查集、线段树、Dijkstra,这些核心模板得做到打开编辑器直接往下写,不需要停下来想下一步该写什么。判断标准很简单:给自己计时,8分钟写不完就说明还有肌肉记忆缺口。
第二层是思路层,要求“能讲清”。光会写模板没有用,你得知道这道题为什么用这个算法、能不能用别的算法、换了数据范围该怎么办。我的训练方式是口述复盘,找一个空房间,假装面前坐着一个小白,把一道题的完整推导过程讲出来。讲到一半卡住的地方,就是你真正的知识盲区。
第三层是题感层,要求“能预判”。这个最难,指的是拿到一道陌生题,能快速识别出它属于哪个考点,知道大概的复杂度上限,甚至能预判常见的坑。题感靠短时间练不出来,只能靠大量旧题复盘累积。第48天我重做旧题时,刻意要求自己在看数据范围之前先猜算法,猜完再验证,这就是在刻意训练题感。
2. 复习内容实录:从模板到错题逐一过
2.1 并查集模板默写:路径压缩与按秩合并
30分钟默写环节,我第一个选的就是并查集。前两天被一道带权并查集的题恶心过,当时把find函数的路径压缩写错了,导致根节点权值更新一直不准。今天直接裸写一个基础版,重点检查路径压缩和按秩合并有没有形成肌肉记忆。
int fa[N], sz[N]; void init(int n) { for (int i = 1; i <= n; i++) fa[i] = i, sz[i] = 1; } int find(int x) { while (x != fa[x]) { fa[x] = fa[fa[x]]; // 路径压缩:把x挂到祖父节点下 x = fa[x]; } return x; } void unite(int a, int b) { int ra = find(a), rb = find(b); if (ra == rb) return; if (sz[ra] < sz[rb]) swap(ra, rb); // 按秩合并:小树接在大树下 fa[rb] = ra; sz[ra] += sz[rb]; }写完我专门在打字的时候把注释也敲了一遍,因为注释能强迫自己明确每一步的意图。这里有两个我经常犯的错,值得重点提醒:一是find函数如果不做路径压缩,在链式数据结构下会退化到O(n);二是合并时如果只按集合大小合并但忘记更新sz,也会影响后续的复杂度保证。今天默写一遍,等于把这两个坑的解决方案重新烙了一遍。
对并查集的复习,我还额外扩展了一个点:当题目涉及到带权信息时,需要维护节点到根节点的偏移量。这个扩展不是今天的学习内容,而是提醒自己,基础模板要能在必要时加参数改造,别死记硬背。
2.2 最短路模板辨析:Dijkstra堆优化与SPFA
默写Dijkstra之前,我先在草稿纸上写了一段判断逻辑:这张图有没有负权边?没有,就上堆优化Dijkstra;有,就得考虑SPFA或者Bellman-Ford。这一步判断比默写模板本身更重要,因为选错算法等于全盘皆输。
using PII = pair<int, int>; vector<PII> g[N]; int dist[N]; void dijkstra(int s) { memset(dist, 0x3f, sizeof dist); dist[s] = 0; priority_queue<PII, vector<PII>, greater<PII>> pq; pq.push({0, s}); while (!pq.empty()) { auto [d, u] = pq.top(); pq.pop(); if (d != dist[u]) continue; // 懒删除:过期的堆元素直接跳过 for (auto [v, w] : g[u]) { if (dist[v] > dist[u] + w) { dist[v] = dist[u] + w; pq.push({dist[v], v}); } } } }这里面的0x3f就是圈内常说的“无穷大”设置,日常初始化用0x3f3f3f3f是因为它大于一般题目允许的最大距离,而且两个0x3f3f3f3f相加还不会溢出int,这一点比直接用INT_MAX稳妥得多。我今天顺便重写了SPFA的判断负环版本,给自己列了一个对比表:
| 算法 | 适用场景 | 时间复杂度 | 注意点 |
|---|---|---|---|
| 堆优化Dijkstra | 无负权图 | O(E log V) | 要配合懒删除或vis数组 |
| SPFA | 有负权边、判断负环 | 平均O(E),最坏O(VE) | 记录每个点入队次数,超过n即有负环 |
| Floyd | 点少、多源最短路 | O(V^3) | 注意k循环在最外层 |
复习最短路时我还逼自己回答了一个问题:为什么Dijkstra不能处理负权边?因为它的核心逻辑基于“已经确定最短路的点不会再被更新”,而负权边可能让一个已经弹出的点再次被缩短。这个本质理解了,你写代码的时候自然就知道什么时候该用SPFA。
2.3 动态规划专题复盘:把转移方程重新推导一遍
动态规划是复习的重头戏,因为DP题的思路非常多,单纯记住题解没有意义,必须重新推导一遍状态定义和转移方程。
我今天先从线性DP开始,复习了一道最长上升子序列的变体。状态定义非常简单,dp[i]表示以第i个元素结尾的最长上升子序列长度;转移时扫描前面所有满足a[j] < a[i]的j,取dp[j]+1的最大值。问题在于,这个O(n²)的转移只适用于n小于等于5000的题目,一旦数据范围变成10的5次方,就得换思路——维护一个tails数组,记录长度为len的子序列末尾能取到的最小值,然后对每个a[i]做二分查找。我在复习时专门把这两种算法写在同一页,逐一对比,并标注“数据范围决定算法选择”。
接着复习了区间DP。区间DP最容易写错的地方不是状态定义,而是枚举顺序:必须先枚举区间长度,再枚举起点,而不是直接枚举起点和终点。我把这个口诀写在笔记本的第一行:“长度在外,起点在中,断点在里,枚举边界全靠len”。如果写成先枚举起点,你会发现在计算较长区间时,它所依赖的较短区间还没有被算出来,整张dp表全是脏数据。
动态规划题目有一个共性测试方法:造一个5以内的样例,人肉跑一遍;再构造一个恰好卡边界的样例,比如n=1、n=2,看初始化是否覆盖了这些情况。很多DP题WA,都是因为dp[0]或者dp[n-1]的边界没处理好。今天复习的两道DP题我都用这个方法快速过了样例,确认了边界无误。
2.4 二分答案与贪心策略的几个老坑
二分答案这类题,最容易翻车的就是边界判断。我复习时用的是左闭右闭的写法,也就是l=0, r=n-1,循环条件写成l <= r,收缩时写成l = mid + 1或者r = mid - 1。这个写法适合绝大多数答案位于数组索引上的场景。但如果答案是一个数值范围而不是下标,就需要换成浮点二分或者左开右闭的写法,我在笔记本上给自己写了一条提醒:“写二分之前先定义好不变式——循环结束时,l和r到底指向什么?”
至于贪心,我给自己的复习结论是:贪心不是用来猜的,而是用来证明的。一道题能不能贪心,要看它有没有贪心选择性质和最优子结构。我重新推导了区间选点问题的经典案例:按右端点排序,逐个选点,每次选当前区间的右端点,能覆盖最多后续区间。当时我第一反应是“按区间长度排序会不会更好”,但实际上只要构造一个反例就立刻排除了。
我一直觉得,二分和贪心的难度不在于“会做某道题”,而在于“能快速判断这道题该不该用二分或贪心”。所以复习这个专题时,我给自己定的标准不是“做过多少道”,而是“能不能在30秒内说出这个算法成立的前提条件”。
3. 实操复盘流程与笔记方法
3.1 把一道旧题彻底重做一遍的四步法
复习阶段我基本不“看题解式复习”,而是采取四步法重做旧题。第一步,只看题目描述,不看任何代码,强行让自己回忆当时的解法。第二步,在白纸上写核心数据结构定义和状态转移方程,写不出来就翻笔记,翻完再合上,继续独立写。第三步,完整写出AC代码,并且要求一次编译通过、一次提交通过。第四步,对照之前留下的错题原因,确认当时的错误点在本次复现中是否已经解决。
这四步法的关键是第二步和第三步之间不要留太长的缓冲。很多人在第二步卡住了就直接看答案,然后感觉自己“会了”,其实那只是“看懂了”,根本不算掌握。我的做法是:卡住就看一眼笔记,但看完立刻合上,必须凭借刚获得的提示把整个代码写出来,写到一半再卡住,就再重复这个过程。这样记忆的加固效果远远好于对着答案抄一遍。
今天重做的两道题,一道是带权并查集的变式,一道是区间DP的优化题。第一道我在第三步时还是出现了一个低级错误:初始化父节点数组时忘了调用init函数,导致find无限递归。这种错误在平时刷题时多半会被调试器发现,但复习时我故意不开调试器,逼自己通过读代码来发现错误。这其实是一种很好的模拟比赛状态训练,因为真实比赛环境里你不能依赖排查工具,只能靠静态检查。
3.2 三种复习方式对比:只看、重写与口述
我尝试过三种复习方式,今天把它们放在一起对比后得出了一套明确的选用标准。
“只看”指的是快速翻阅曾经的代码和笔记,这个方式的优点是快,缺点是遗忘率极高。它只适合刚学完的当天晚上做一次即时回顾,相当于给大脑一个“今晚不再加工这个内容”的信号。过了48小时,“只看”基本就是在骗自己,因为大脑会觉得这个内容已经处理过了,实际上根本没有形成强提取路径。
“重写”是我用得最多、也最推荐的方式,它强迫你从零开始构建完整答案,过程中遇到的每一个卡顿都会暴露出来。但是重写有个问题,就是当你重写同一道题超过第5次,很容易陷入机械抄写状态,所以它更适合“隔一周、隔一个阶段”的节奏。
“口述”是最反直觉但有效的方式。今天我用15分钟讲一道完整的Dijkstra题,讲到“为什么堆里弹出一个比当前dist还大的元素时可以直接跳过”,发现自己得想好几秒才能组织好语言。正是这一停顿让我意识到,我对懒删除机制的理解还停留在“知道有这么回事”,而不是“能清晰解释它的原理”。口述就是一面照妖镜,比任何测试都能更快找到认知盲点。
3.3 时间盒:为什么复习日我放弃番茄钟
很多人推荐番茄钟,但我自己在复习日从来不用,因为番茄钟的“25分钟专注+5分钟休息”会打断连续的心流状态。复习模板类内容时,15分钟的投入不足以覆盖一次完整的默写过程;复习DP题时,25分钟又经常不够用。我的替代方案是时间盒:先给每个复习环节分配一个明确的时间上限,然后在这段时间内只做一个任务,不管有没有做完,时间一到必须切换。
这种做法的优势在复习阶段很明显:它会强制你抓重点。如果一道题重写了30分钟还没有通过,那说明不是“手滑”而是底层理解有问题,这时候继续硬杠下去没有意义,我会暂停并在错题本上标记“下次复习优先处理”。今天我在35分钟里安排了两道题,第一道12分钟过了,第二道耗时接近20分钟,最后还差一点没过,但时间一到我立刻停下,在错题本上补了一条“带权并查集的权值更新公式不熟”。这样做的价值不在于把题全做对,而在于精准定位弱点。
当然时间盒也不是死板地锁死一切。我给两个相邻环节之间留了5分钟弹性缓冲,具体安排是:模板默写提前完成,就把多出的时间加给后面的旧题重做;如果某个环节严重超时,下个环节就要削减。这种流动式时间管理比固定番茄钟更贴近真实的复习需求。
4. 常见问题与排查技巧实录
4.1 复习时脑子里全在冒新题,怎么办
这是复习日最普遍的并发症。我自己的处理方式是建立一个“愿望清单”,把脑子里冒出来的新题和新想法全部写进去,然后告诉自己:“这道题不是不做了,是先记账,复习结束后再处理。”神奇的是,当你把这个念头写下来,它对你的干扰就会迅速下降,因为大脑知道你没有丢掉它,只是暂时寄存。
我还发现一个规律:想开新题的冲动通常和当前复习内容的难度直接相关。如果复习环节过于顺利,你会觉得无聊,想要寻找新鲜刺激;如果复习环节卡壳严重,你会产生挫败感,想逃到容易的新题里找成就感。针对这两种情况,我的对策分别是:给顺利的复习环节临时增加难度,比如要求自己用两种方法写同一道题;给卡壳的环节设定一个明确的“止损点”,比如再看3分钟还看不懂就跳过,绝不内耗。
4.2 昨天才写过的题,今天怎么跟没见过一样
出现这种情况不必恐慌,它恰恰说明你的复习方式是健康的。这里的原理是“必要的难度”:学完当天马上重看,因为答案还在短期记忆里,感觉很容易,但根本测不出真实掌握度;隔一天再写,短期记忆消退,大脑才需要真正去调用长期记忆,这个时候遇到的阻力才是真实的掌握缺口。
我的解决办法是建立一个“24小时挑战清单”。每天学完新内容后,从三道题里挑一道自己觉得掌握得最好的,特意不在当天晚上复习,而是留到第二天上午完成复习日的前置任务时再独立重写。隔了一天还能顺利写出来,才是真掌握。如果写不出来,就把它升级到错题本的高优先级区域,下一轮复习首先处理它。
4.3 复习时间不够用,优先级怎么排
如果把47天积累的错题和模板全部列出来,96分钟根本不够看,所以必须排优先级。我的排序规则很简单:优先处理那些“最近一周内遇到且卡住过”的内容,因为它们正处于遗忘曲线的陡峭段;其次是“高频考点但自己写得不快”的模板;最后才是“已经写过很多遍且从不犯错”的内容。
为了更直观,我把自己的复习内容分成三类,并给出了判断标准:
| 优先级 | 对应内容 | 判断标准 | 今天的处理方式 |
|---|---|---|---|
| P0 | 昨天/前天卡住的题 | 提到算法名还会犹豫怎么实现 | 必须独立重写,不通过不罢休 |
| P1 | 高频模板 | 8分钟内无法完整默写 | 计时默写,错了立刻重来 |
| P2 | 旧题重温和已熟练模板 | 能直接讲出思路但没实践 | 口述题解,顺带扫一眼旧代码 |
按这个标准,今天我只把P0和P1的内容排进了正式执行计划,P2的内容只用碎片时间带过。不要试图把每道题都复习一遍,那种全面但浅层的复习只是给大脑制造一种“今天也很努力”的错觉,实际效果非常有限。
4.4 状态不好时的自救手段
复习日的效率高低很大程度取决于前三天的状态,但总有意外情况。比如今天上午我明显感觉到注意力在前15分钟是涣散的,虽然定了时间盒,但眼睛总想往手机方向瞟。我没有强迫自己和这种状态对抗,而是把一个高难度的重写题往前挪,先做最容易让自己进入心流的事情。
这里有一个经验:状态差时不要从“看错题本”开始,因为被动阅读很难让大脑兴奋起来;应该从“默写模板”这种半自动产出型任务开始,一旦手指开始敲键盘,大脑会很快跟上。等默写完两个模板,注意力已经自然集中了,再回到错题本和重做环节就会顺畅很多。如果状态依然很差,还有一个备选方案是把复习改成“泛读模式”,只阅读三道题的题解思路但不写代码,虽然效果打折,但也比彻底荒废要好。
状态管理说到底是个止损问题。复习日的目标是让大脑在正确的时间做正确的事,而不是做完美的圣人。接受偶尔的状态起伏,提前准备好了B计划,执行起来反而更从容。
写在复习结束之后的两点体会
今天这96分钟下来,我最真实的感受是:复习不是“重复”,而是“重新发现自己不会什么”。平时刷新题带来的快感容易掩盖问题,只有回头做旧题时,那些被跳过的理解漏洞才会一个接一个地冒出来。如果你也正在某个长期学习计划的中后段,我建议你把复习当成一种投资,而不是一种止损,因为你今天花在回溯上的每一分钟,都会在未来的实战里以“少一个低级错误”的形式还给你的代码。
最后分享一个实操小技巧:把最容易忘记的算法模板录成自己口述的语音,走路、吃饭的时候听一遍。用声音给自己讲一遍思路,比拿手机看代码能多利用很多碎片时间,而且录的过程本身就强迫你完成了一次高质量口述。这个习惯我是在第40天左右才养成的,如果更早开始,前面踩的坑还能再少几个。