3月12号晚上十点半,我关掉PTA的提交页面,当天的训练记录停留在L2-032这个题上。距离天梯赛正式开赛还有不到一个月,这个阶段刷题的感受和平时完全不一样——每做一道题都会在心里给它打标签:这是树的遍历,这是并查集,这是栈模拟。如果赛场上碰到同类题,我能不能在十五分钟内写出来?答案直接决定了队伍的整体得分区间。
这篇文章就是3月12日这次训练的真实复盘。我会把当天练的题、踩的坑、总结的考点地图,以及临近比赛这段时间的训练节奏都梳理出来。内容主要针对天梯赛L2级别的备赛,适合正在刷PTA题库准备参加团体程序设计天梯赛的同学参考,尤其是队伍里负责L2中坚分数段的队员。如果你现在处于“L1随便写、L2靠运气”的状态,这一篇应该能帮你把训练方向理顺。
1. 3月12日的训练选题:这个阶段为什么只啃L2真题
1.1 当天的题单与复盘结果
先给出当天实际练习的题目清单。我按“考点覆盖优先”的原则选题,没有刻意挑难题,也没有追求刷新题数量。
| 题号 | 题目 | 核心考点 | 结果 |
|---|---|---|---|
| L2-003 | 月饼 | 贪心、排序 | 一次AC |
| L2-004 | 这是二叉搜索树吗? | 二叉搜索树性质、递归建树 | 参考题解后AC |
| L2-006 | 树的遍历 | 中序+后序重建二叉树、层序遍历 | 一次AC |
| L2-010 | 排座位 | 并查集、关系判定 | 思路对但卡了输入 |
| L2-013 | 红色警报 | 图连通分量、删点操作 | 第一次WA,二刷AC |
| L2-032 | 彩虹瓶 | 栈模拟、边界控制 | 卡了一次边界 |
共6道题,其中一道一次过、一道复现WA后过、一道卡住后修正AC、一道参考题解后AC。从正确率上看不算漂亮,但这正是现阶段训练的正常状态。天梯赛这种比赛,平时做真题如果全部一遍过,反而说明你的选题太简单了。
1.2 选题逻辑:L1保底、L2拉分、L3尽力
先说一个基本的分数结构。天梯赛按往年惯例,L1大概8道题、每题10分,L2大概4道题、每题25分,L3大概3道题、每题30分,总分270分左右。这种分值分布决定了绝大多数队伍的基本策略:L1必须抢着拿满,L2是真正的分水岭,L3属于攀登区,能做出一题就是赚到。
所以我从3月初开始,训练重心已经从“刷通过率高的水题”切换到了“L2真题专项”。原因很简单:L1的题考的是语法熟练度和基本逻辑,在赛场上通常是前40分钟解决的;L3的题往往需要较深的算法积累,短时间内提升有限;而L2的25分题,难度跨度正好卡在“基础数据结构熟练掌握”和“题面解析不踩坑”之间,是性价比最高的训练区域。
3月12日的选题逻辑,就是按照这个思路来的。并查集、树的遍历、栈模拟、图连通分量、贪心、二叉搜索树性质——这些全都是L2的高频考点,每个考点刷一道代表题,比盲目刷十道重复题型更有效。
2. 天梯赛L2考点地图:看到题先判断“它考什么”
2.1 L2题目难度梯度其实很清晰
很多人刷L2会觉得题目杂乱,一会儿二叉树、一会儿链表、一会儿又是字符串,看不出章法。刷到一定量之后就会发现,L2的考点范围其实相当有限,而且有一个明显特点:不考冷门算法,考的是基础数据结构在最常见场景下的应用。
我在3月12日的复盘里重新整理了一份考点清单,按出题频率和性价比排了个优先级。
| 考点 | 代表题目 | 优先级 | 说明 |
|---|---|---|---|
| 树的遍历与重建 | L2-006、L2-011 | 最高 | 中序+后序重建、层序遍历、镜像树等 |
| 并查集 | L2-007、L2-010 | 最高 | 关系合并、连通性判断,模板很简单但应用场景多 |
| 图连通分量/DFS BFS | L2-013、L2-023 | 高 | 删点、染色、连通块统计,注意边界 |
| 栈结构模拟 | L2-032 | 高 | 题面像阅读理解,本质是模拟,逻辑要理清 |
| 链表操作 | L2-002、L2-022 | 中高 | 去重、重排,注意指针顺序 |
| 贪心 | L2-003 | 中 | 排序后按性价比取,注意浮点数 |
| 字符串处理 | L2-008 | 中 | 对称子串等,注意回文边界 |
| 二叉搜索树性质 | L2-004 | 中 | 递归判断、前序转后续,构造时要细心 |
| 堆/优先队列 | 多变体 | 中 | 通常结合排序出现 |
| 高精度 | 不多 | 低 | 偶尔出现,有大数模板就行 |
2.2 模板库的维护方式
有了考点地图,下一步就是建立自己的模板库。我的做法是在本地建一个“天梯赛模板”文件夹,每个考点一个文件,里面不只是抄代码,而是写清楚“这个模板解决什么问题、边界条件有哪些、上次错在哪”。
比如并查集文件里就会留一段注释:路径压缩的递归写法在大数据量下没问题,但如果你不在乎那点性能,可以写成循环避免爆栈;find函数里一定要先递归再赋值,否则路径压缩不彻底。这种东西比赛前翻一遍比临时翻书有用得多。
对于树的遍历重建这类题,模板里要画清楚递归参数的变化。我会在注释里写明:中序的作用是分割左右子树,后序的作用是确定根节点。只要参透这一句话,L2-006和L2-011这类题其实都是一套代码。
2.3 复习时的优先级判断
考点地图不只是用来刷题的,更是用来判断“一道新题值不值得死磕”的。比赛现场时间有限,如果你花了20分钟都没能判断一道题属于哪个考点,那大概率是读题有问题。反过来,如果一眼看出考点,哪怕题目再长,也能很快定位到模板代码,再针对特殊条件做修改。
我建议每周花一点时间更新这份地图:把做错的题归类,把新见的考点补充进去,把已经熟练的考点降级。比如3月12日之后,我就把“树的遍历与重建”从最高优先级划掉了,因为已经连续三道同类题稳定AC,剩下的精力应该放到还没完全吃透的考点上。
3. 真题拆解:当天最有收获的三道题
3.1 L2-006 树的遍历:中序+后序重建层序
这道题考察的是二叉树重建。题目给出中序序列和后序序列,要求输出层序序列。核心逻辑一句话:后序序列的最后一个元素是根节点,在中序序列中找到这个根,左边的就是左子树,右边的就是右子树,然后递归处理。
当时我写的核心构建函数大概长这样:
#include <bits/stdc++.h> using namespace std; const int MAXN = 35; int inorder[MAXN], postorder[MAXN]; int leftChild[MAXN], rightChild[MAXN]; int build(int inL, int inR, int postL, int postR) { if (inL > inR) return 0; int root = postorder[postR]; int k = inL; while (inorder[k] != root) { k++; } int leftLen = k - inL; leftChild[root] = build(inL, k - 1, postL, postL + leftLen - 1); rightChild[root] = build(k + 1, inR, postL + leftLen, postR - 1); return root; } void levelOrder(int root) { queue<int> q; q.push(root); bool first = true; while (!q.empty()) { int u = q.front(); q.pop(); if (!first) { cout << " "; } cout << u; first = false; if (leftChild[u]) { q.push(leftChild[u]); } if (rightChild[u]) { q.push(rightChild[u]); } } }这个题最大的坑不在思路,而在递归区间。很多新手第一次写会懵在postL + leftLen - 1和postR - 1这两个边界上。我习惯用一个具体例子验证:中序为1 2 3 4 5 6,后序为1 3 2 6 5 4,根是4,中序里4左边有3个节点,所以左子树后序是postL到postL + 3 - 1这一段,也就是1 3 2;右子树后序从postL + 3到postR - 1,也就是6 5。把边界代入一遍,再不会错。
层序输出用一个队列BFS即可,但输出格式要注意:最后一个数后面不能有空格。我习惯用first标记处理,而不是去判断q.empty(),这样逻辑更直观。
3.2 L2-032 彩虹瓶:栈模拟的“伪简单”
彩虹瓶这道题,题面描述得很有迷惑性,读起来像是一个工厂流水线问题。我初次做的时候差点被长长的描述绕进去,但本质就是一个栈模拟:小球按1到N顺序装填,有一个容量为M的临时货架(栈),能取走的条件是栈顶编号正好是当前需要的编号。
关键判断逻辑可以写成这样:
vector<int> balls(n + 1); for (int i = 1; i <= n; i++) { cin >> balls[i]; } stack<int> shelf; int need = 1; bool ok = true; for (int i = 1; i <= n; i++) { if (balls[i] == need) { need++; } else { shelf.push(balls[i]); if ((int)shelf.size() > m) { ok = false; } } while (!shelf.empty() && shelf.top() == need) { shelf.pop(); need++; } }这里有一个我踩过的细节:如果新传过来的球不等于当前需要的编号,先不要急着判断失败,先入栈,入栈之后再去检查栈顶是不是正好等于need。因为入栈后有可能栈顶恰好变成了当前需要的小球,这种情况下是合法的,必须继续弹出。我第一次就是因为“入栈后没有立即检查栈顶”而WA了一次。
还有一个容易漏的点:所有球处理完之后,栈里如果还剩元素,要按顺序弹出检查,栈顶必须是从大到小连续递减才能全部装填成功。如果最后栈不为空,或者弹出时出现了编号不连续的情况,就说明失败。
这类题目的共同特点是:逻辑简单,但题面长、条件多。赛场上看到这种题,我第一反应不是紧张,反而是高兴——因为这种题一旦读懂,写起来就是模板级别的,几乎不需要额外的算法知识。
3.3 L2-010 排座位:并查集卡住的一个输入习惯
排座位这道题属于并查集的典型应用。题目会给出若干人际关系,1表示朋友,-1表示敌对。朋友关系是传递的,朋友的朋友也是朋友,所以用并查集合并。敌对关系不传递,单独用一个二维数组标记即可。
核心逻辑如下:
if (enemy[a][b]) { if (find(a) == find(b)) { cout << "OK but..."; } else { cout << "No way"; } } else { if (find(a) == find(b)) { cout << "No problem"; } else { cout << "OK"; } }这里要注意输出格式的完整字符串,少一个点都不行。我第一次WA就是因为在判断输入时,把“关系值”和“查询对”搞混了。具体来说,题目的输入格式是先给N个人员、M条关系、K个查询,然后M行关系,每行是“人1 人2 关系”,最后K行查询。我看漏了“关系”和“查询”是两个独立部分,结果读入顺序错乱,导致后面的并查集全部白算。
经过这个题之后,我养成了一个习惯:任何涉及多段输入的题目,先画一个输入结构草图。哪几行是建图数据,哪几行是查询数据,务必在写scanf或cin之前就梳理清楚。这种错误不是算法不会,纯粹是读题和输入处理的疏忽,但在比赛里同样会要命。
4. 被WA点醒的瞬间:边界条件和读题陷阱
4.1 L2-013 红色警报:一次完整的WA排查链路
红色警报这道题,是当天唯一让我真正陷入“为什么WA了”的题目。题目大意是:一张图上有N个城市,M条道路,敌人依次攻占某些城市,每攻占一个城市后,如果整个国家的连通分量数量增加,就发出红色警报,否则不响。要求按顺序输出每次的结果。
我的第一版思路很直接:每次删除一个城市之后,DFS统计剩余城市的连通分量数,如果连通分量数比删除前多,就输出红色警报。这个思路本身没问题,但我第一次交上去WA了。
我立刻进入排查流程。先不修改代码,而是加了一堆调试输出,把每次删除后的连通分量数都打印出来。结果发现,删除城市后连通分量数有时候不增反减。这显然不符合直观逻辑——删掉一个点,连通分量只可能增加或不变,不可能减少。
问题出在统计连通分量的实现细节上。我用一个数组标记被删除的城市,DFS时遇到被删除的城市就跳过。但在计算连通分量数目的循环里,我把所有城市都作为起点遍历了一遍。当一个城市已经被删除,且它周围没有任何其他城市时,它仍然会被当成一个独立的连通分量被统计进去。这就导致删除后孤立点的数量影响了对真实连通分量的判断。
修复方法很简单:在每次删除城市后,把被删除的城市视为已经访问过,统计时直接跳过,不把它当成一个连通分量起点。代码上只需要在BFS或DFS前把visited[city] = true预置好。
这轮排查花了我将近20分钟。最后复盘时我写了一条很深的体会:图论题WA的时候,首选怀疑的往往不是算法模型,而是“边界节点”的处理。被删除的节点、无效的节点、自环、重边,这些都是传统测试样例不容易覆盖的地方,却是判题机最喜欢埋雷的地方。
4.2 浮点数、空行、数组大小:三个最没有技术含量的失分点
除了红色警报,当天还有几个不起眼但非常影响AC率的细节。
第一个是L2-003月饼的浮点数问题。题目里库存量和需求量可能是小数,如果用int读入,排序和计算都会出错,而且这种错非常隐蔽,不会直接编译报错,只会导致计算结果差一点点。我的经验是:涉及到重量、价格、比例等可能不是整数的量,一律用double读入,哪怕题目给的数据看起来像是整数。这个习惯帮我避开了很多不必要的WA。
第二个是输出格式里的空行。有些题目要求每组输出之间多一个空行,有些要求行末不能有空格,有些要求字符串大小写完全一致。3月12日当天我专门花了几分钟整理了一个“输出格式检查清单”,包含了行尾空格、空行、大小写、百分号、小数点位数。比赛时紧张状态下,这些东西最容易被忽略。
第三个是数组大小。天梯赛的数据范围一般不会特别大,但数组开小了仍然是常见的低级失误。我习惯在写题之前先看一下题目给的最大规模,然后开一个比最大值大5到10的数组。比如题目说N不超过30,我就开35;说N不超过1e4,我就开10005。多出来的几个元素空间代价几乎可以忽略,但能避免最难受的那种“本地越界但判题机WA”的情况。
5. 三小时赛场的节奏控制:训练时就在练的“策略”
5.1 我的时间分配习惯
天梯赛是团体赛,但每个队员的做题节奏会直接影响全队总分。3月12日的训练里有两道题我特意模拟了比赛计时状态,用倒计时的方式逼迫自己按策略推进。下面是我个人习惯的时间分割方案,供参考。
| 时间区间 | 目标 | 策略 |
|---|---|---|
| 0~40分钟 | L1尽可能全过 | 不纠结L1里的复杂题,先写简单暴力的版本,以AC优先 |
| 40~90分钟 | 开始处理L2前两题 | 优先选自己最熟悉的考点题,保证拿到分 |
| 90~150分钟 | L2后两题+复查 | 卡题超过20分钟先跳过,所有AC过的题回头检查格式 |
| 最后30分钟 | 冲刺L3第一题 | 只做有模板的题,尝试不进去就回查L2 |
这个方案的核心逻辑是:比赛比分看的是AC题数和罚时,不看你最后是否做出了L3难题。把能稳稳拿到的25分先攥住,比花40分钟赌一个30分要稳妥得多。尤其对于主力队员来说,你的任务就是把L2的分数拿全,L3属于锦上添花。
5.2 卡题超过20分钟怎么办
卡题是比赛中最常见的心态杀手。3月12日晚上的红色警报,我虽然没有严格计时,但因为反复WA,前后也耗了将近25分钟。事后回想,这个时间如果放在正式比赛中,已经不是一笔划算的支出了。
我的做法是给自己设了一个“20分钟规则”:开始写一道题之后,如果20分钟内没有取得实质性进展,比如还没有AC,或者连思路都完全没成形,就立刻停下。先标记“待处理”,然后去做下一道可做的题。等到L2的其他题都处理完了,再回头用剩余时间处理刚才没做完的题。两个好处:一是避免了在一道题上耗尽情绪和体力,二是回头再读题时,往往能更容易发现前面被忽略的突破点。
这个规则需要提前在训练中演练。如果你平时写题从来不计时,比赛时突然启动20分钟阈值会很别扭。我现在连刷PTA真题都会开计时器,目的就是让这种机制成为肌肉记忆。
5.3 团队协作里L2位要怎么定位
在队伍里每个人的分工不同。如果你和我一样,主要负责L2区间,那么赛前训练的重心就要非常明确:不怕L1题做得慢,因为总有队友会快速清掉L1;你需要的是在L2题目出现时,能稳定输出AC。
所以3月12日以后,我不再花整段时间刷L1题目,只在赛后拿L1来热手,比如开赛前10分钟刷两道找手感。真正的主力训练全部围绕L2展开,而且每道题都要求自己解释清楚“为什么这么解”,而不是“碰巧AC了”。因为一个人解释不清楚的题,比赛时大概率也写不对。
6. 训练日志的复盘沉淀:错题、模板、心态
6.1 错题本怎么记才有用
很多人刷题,AC了就下一道,WA了就改成AC,然后什么都不留下。这样刷多少题效果都有限。我从3月份开始认真记账格式,3月12日的错题记录长这样:
## 3-12 L2-013 红色警报 - 考点:图连通分量、删点 - 错误原因:删除点后统计连通分量时,把已删除的孤立点也当成了连通分量 - 正确做法:初始化 visited[删除点] = true,统计时跳过已删除节点 - 心得:图论题WA优先查边界节点处理好的错题本要包含四层信息:题号考点、当时的错误原因、正确的解决方案、以及一条可以迁移到其他题里的“心得”。这四层缺一不可。如果你只是写“这道题我不会”,那等于没写。只有把错误原因精确到“哪一行代码、哪一个逻辑分支”出了问题,才算一次有效的复盘。
6.2 错题分类比错题数量更重要
当错题积累到一定程度之后,我建议按错误类型重新整理,而不要按题目分类。3月12日的六道题里,我的错误大致可以归为三类:
- 输入处理错误:L2-010的输入段理解错乱
- 边界条件错误:L2-013的删除点被重复统计
- 模拟逻辑错误:L2-032入栈后未立即检查栈顶
每一类错误的解法都不同。输入处理问题靠的是“画输入结构图”;边界问题靠的是“把每个可能为空的节点都在草稿上演算一遍”;模拟逻辑问题靠的是“手动模拟一个小例子再写代码”。把错题按这种方式分类,你才能真正找到自己最薄弱的环节。
6.3 3月12日之后到赛前的训练节奏
记录完这一天,我给自己定了后续两周的训练计划。
第一周,按考点做一次L2真题的“二刷”,重点是我还没拿到满分的树重建、并查集、栈模拟三类。二刷的标准不是重新AC,而是闭上眼睛能默写出核心代码框架,并且能画出递归或者遍历的过程图。
第二周,开始每两天打一套完整的往年真题模拟赛。模拟赛必须严格按3小时计时,中途不暂停、不查资料、不和队友讨论。只有在这种接近真实比赛的环境里,时间分配、心态控制、卡题处理这些策略才能真正得到检验。
每天还会固定花30分钟浏览一遍自己的模板库,把不熟悉的模板单独摘出来重写。这段时间不需要刷太多新题,磨刀不误砍柴工。
3月12日的训练量不算大,但它的价值在于让我对自己的L2水平有了更清晰的判断。以前总觉得天梯赛L2是高不可攀的算法题,现在看透了:它考的就是基础数据结构、读题能力和耐心,任何一个系统刷过PTA的人都能啃下来。把每一次练习都当成正式比赛,把每一道WA都拆开揉碎,比赛的底气就是这样一天天攒出来的。