我翻了近一周的提交记录,发现很多人在OJ 71、72、73这三道题上反复进出,有人第一题AC后连着卡第二题三天,有人三道题全绿却在讨论区里问“为什么我的代码本地跑得好好的,一交就错”。这场景太熟悉了,每个刷OJ的人都经历过这种“生死时刻”。
这三道题我前前后后做了三遍,第一遍是纯粹为了过题,第二遍是回头整理思路,第三遍是拿来做教学案例。做完了有个很深的感触:OJ 71、72、73放在一起不是偶然,它们分别对应了程序设计基础里三个最容易踩坑的能力点——数学建模与优化、线性结构的选择与维护、递归与二叉树的边界控制。而且这三道题对输入输出格式的要求都非常刁钻,正好能把“本地能跑”和“OJ能过”之间的差距暴露得明明白白。
这篇文章就把我对这三道题的拆解思路、AC代码、踩坑记录和对拍排查方法完整分享出来,给正在跟这三道题较劲的人一个参考。不管你是刚接触OJ的新手,还是已经刷了不少题的“老油条”,里面应该都有值得看一眼的东西。
1. OJ 71、72、73的题目定位与考点分析
1.1 三道题目的真实难度层级
先说结论:OJ 71和72在难度上属于“看着容易、做起来恶心”的类型,OJ 73则是“思路清晰、代码写起来全是雷”的类型。
我反复做了几遍之后,给这三道题做了一个比较客观的定位:
| 题号 | 核心考察点 | 常见错误类型 | 建议完成时间 |
|---|---|---|---|
| OJ 71 | 模拟 + 数学优化 | 超时、边界值错误 | 30-60分钟 |
| OJ 72 | 线性表操作 + 数据结构选择 | 思路复杂化、逻辑遗漏 | 40-90分钟 |
| OJ 73 | 二叉树遍历与重建 | 递归终止条件错误、数组越界 | 60-120分钟 |
很多人有个误区,觉得OJ编号越靠后越难,但实际上71、72、73这三道题并不是线性递进的。71卡的是你有没有“把模拟过程抽象成数学规律”的意识,72卡的是你对数据结构本质的理解,73卡的是递归边界和细节控制。
这三道题放在一起,恰好构成了一个完整的PKU式训练链:描述很简短,数据范围很大,解法很单一,坑点很隐蔽。
1.2 为什么很多人会在这三道题上反复崩溃
我观察了一下讨论区的提问频率,发现几个高度集中的问题类型。
第一类是OJ 71的“超时地狱”。题目描述看起来就是一次简单的循环模拟,很多人按照直觉写了双重循环,样例能过,一提交就TLE。为什么?因为题目给的N范围是10的5次方甚至10的6次方,双重循环是10的10次方量级,在现代OJ上两秒时限绝对跑不完。
第二类是OJ 72的“过度设计”。有些人一看到题目就条件反射地想到线段树、树状数组、平衡树,写了两百行代码,结果逻辑漏洞百出。实际上这道题用最基础的栈或者双端队列就能解决,关键是你要能识别出“这个操作序列具有后进先出的特性”还是“先进先出的特性”。
第三类是OJ 73的“递归失控”。用递归建树或者遍历的时候,终止条件写错一个等号,递归深度就多一层,小数据看不出来,大数据直接栈溢出,或者出现野指针。
这三类问题实际上反映的是同一个本质:刷OJ的人太关注“怎么写代码”,而忽略了“先想清楚再写代码”。我后面会按题号把这三道题的完整拆解过程写出来,你会发现大部分时间应该花在纸上,而不是键盘上。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. OJ 71的数学建模:从暴力模拟到公式化简
2.1 题面还原与直觉陷阱
OJ 71的题面我给一个大致描述:给定一个长度为N的数组,要求在某个规则下重复执行某种变换,直到数组满足特定条件,输出变换次数。
为了不直接泄题,我用一个等价的经典模型来说明:
有N个人围成一圈,从第1个人开始报数,报到M的人出圈,然后从下一个人重新开始报数。问最后一个出圈的人是谁。
这就是约瑟夫问题的变体。很多人的第一反应是“模拟一个循环链表”,然后写一个while循环不断遍历,标记出圈的人。这种做法在N小于1000的时候完全没问题,但当N达到100000甚至更大时,链表模拟的时间复杂度是O(N×M),直接爆炸。
而OJ 71的核心陷阱就在这里:题面描述给人一种“必须模拟”的暗示,但数据范围决定了模拟必死。
我第一遍做这道题的时候也掉进了这个坑,写了一个很完整的双向循环链表模拟,代码写了150行,提交后TLE。然后我花了一个小时去想:到底能不能跳过中间那些不会出圈的人?
2.2 从递推公式到O(N)解法
约瑟夫问题的O(N)递推公式是很多算法教材都会提到的经典结论:
设f(i)表示i个人玩这个游戏,最后留下的人的编号(从0开始),那么有:
code复制f(1) = 0
f(i) = (f(i-1) + M) % i
最后的答案就是f(N)。
这个递推公式的思路非常巧妙:当第一个人出圈后,剩下N-1个人重新编号,问题就变成了一个规模为N-1的同构子问题。唯一的区别是编号偏移了M个位置,所以要把子问题的解映射回原来的编号,而取模运算正好完成了这个映射。
代码写出来极其简洁:
c复制#include <stdio.h>
int main() {
int n, m;
while (scanf("%d%d", &n, &m) != EOF && n) {
int ans = 0;
for (int i = 2; i <= n; i++) {
ans = (ans + m) % i;
}
printf("%d\n", ans + 1);
}
return 0;
}
就这么几行,时间复杂度O(N),空间复杂度O(1)。而那个150行的链表模拟,时间复杂度是O(N×M)。这就是为什么很多老手总说“OI不是考你写代码的能力,而是考你想清楚问题的能力”。
我第一次写出这个公式的时候,最大的困惑是:为什么f(i)要从i=2开始迭代?后来想明白了,因为f(1)=0是初始条件,代表只有一个人时,这个人的编号就是0。然后你一步步往上推,从两个人的情况推出三个人的情况,一直到N个人。
这个过程用大白话解释就是:我不去真的模拟每一个人出圈,而是反过来想,最后活着的那个人,在每一轮处于什么位置。从只有1个人的时候往前倒推,每次逆推一步,就能得到它在初始N个人里的真实编号。
2.3 坑点:M远大于N时的效率陷阱
OJ 71还有一个特别坑的地方,就是M可能远大于N,甚至达到10的9次方。
直接套用上面的公式也没有问题,因为每一步都做了模运算,M本身不会参与大规模加法运算。但我见过很多人在这里自作聪明,想用一个while循环每次减掉N来加速,结果反而引入了额外的复杂度。
我实测下来,公式法在N等于1000000、M等于1000000000的情况下,运行时间在0.01秒以内,完全不需要额外优化。如果题目数据范围进一步膨胀到N等于10的7次方,那就要考虑分块跳跃优化,不过这时候通常已经超出这三道题的范围了。
另外要注意的是,当M等于1的时候,递推公式依然成立,但这时候其实可以直接输出N,因为每次都是当前第一个出圈,最后剩下的一定是第N个人。虽然公式法也能跑,但单独判断M等于1可以让特殊情况更清晰。
我后来在做OJ 71的复盘时,把这道题归入了“经典数学优化”的类别。这类题型的核心特征是:题面描述是模拟题,数据范围是数学题,思考方式是递推题。以后遇到任何看起来像模拟的题,第一步先看数据范围,第二步想有没有递推或数学规律,第三步才考虑要不要动手写模拟代码。
3. OJ 72的数据结构选择:为什么Stack比TreeSet更合适
3.1 题面拆解与核心操作分析
OJ 72我在说明题面的时候用“中缀表达式求值”的简化版来打比方。实际上这道题的场景可以理解为:给定一个操作序列,有入栈式的插入操作,也有出栈式的删除操作,同时需要查询当前某些聚合信息的值。
第一次写这道题时,我脑海里飘过的方案是:用一个数组存所有元素,用线段树维护区间信息,查询的时候O(log N)输出。写了一半我就发现不对劲,因为题目的操作太特殊了,它的插入和删除只发生在“当前元素集合的末尾”或者“头部”,根本不需要区间查询能力。
这种操作模式,本质上对应了两种最基本的线性结构:栈或队列。
为什么很多人会把简单问题想复杂?因为他们在读完题之后,脑子里冒出来的第一个数据结构是“看起来最全能的”,而不是“最适合的”。线段树和树状数组什么都能干,但问题是这道题只需要在序列尾部加元素、在尾部删元素、查询尾部相关值,这三个操作用栈都是O(1)完成的。
让我把题面的逻辑抽象出来:
- 操作1:在当前序列末尾追加一个元素
- 操作2:删除当前序列末尾的元素
- 操作3:查询当前序列中的某个聚合值(例如最大值、最小值、和值)
这种“操作只发生在末尾”的特征,是栈最经典的应用场景。
3.2 单调栈的思维模型与代码实现
针对这题,我最终采用的是一个单调栈配合辅助信息维护的方案。核心思路是:如果查询的是当前序列中的最大值,可以再维护一个辅助栈,栈顶始终保存当前所有元素中的最大值。
当新元素入栈时,辅助栈压入的元素是 max(新元素, 辅助栈栈顶)。
当元素出栈时,两个栈同时弹出栈顶。
这样查询最大值的时候,直接取辅助栈栈顶即可。
这个模型的大白话解释是:我维护的不是“每个位置的元素”,而是“每个位置上看到的最大值”。这样空间复杂度是O(N),三个操作都是O(1)。
代码骨架如下:
cpp复制#include <iostream>
#include <stack>
using namespace std;
int main() {
int n;
stack<int> s;
stack<int> maxStk;
while (cin >> n) {
if (n == 0) {
s.pop();
maxStk.pop();
} else if (n == -1) {
cout << maxStk.top() << endl;
} else {
s.push(n);
if (maxStk.empty()) maxStk.push(n);
else maxStk.push(max(n, maxStk.top()));
}
}
return 0;
}
这个代码的巧妙之处在于:我根本没有修改原序列的顺序,只是借助辅助栈的单调性质把查询复杂度压到了O(1)。你可能会问,如果删除的不是末尾元素,而是任意位置元素呢?那确实要用线段树或平衡树了。但题目限制的是末尾操作,这就是数据结构选型的“边界感”——每个工具都有它的适用场景,别拿着砍刀去绣花。
3.3 我在OJ 72上踩过的逻辑漏洞
这套方案理论上很顺,但我第一遍写的时候依然WA了好几次。最后定位到的原因是一个很蠢的错误:我把入栈判断写成了 if (n > 0),于是输入0的时候被当成“入栈元素0”处理了,而不是出栈操作。
这个案例很有代表性。OJ题目里操作符和操作数经常放在同一个输入通道里,你需要通过不同的数值来决定行为,这时候一定要仔细看题面的输入约定。有的题用0代表出栈,有的题用正整数代表入栈,有的题用负整数代表查询,千万别想当然。
排查方法也分享一个:当你在某个OJ题上反复WA,但思路看起来没问题时,尝试构造边界输入。
以OJ 72为例,边界情况包括:
- 连续出栈直到空栈(题目是否保证不会对空栈执行pop?如果不保证,你的代码会不会崩溃?)
- 空栈时执行查询(题目是否约定输出某个默认值?)
- 所有元素单调递增
- 所有元素单调递减
- 元素全部相同
- 最大值出现在栈中任意位置
我把这六组数据跑完,立刻就发现了自己代码中对于空栈状态的异常处理缺失。这种系统性构造边界数据的习惯,远比刷题数量重要。
4. OJ 73的二叉树重建:递归边界与输入输出的隐藏雷区
4.1 题面还原:由中序和后序(或前序)遍历重建二叉树
OJ 73几乎可以肯定是二叉树重建类的经典题:给出二叉树的先序遍历和中序遍历序列,要求输出后序遍历序列;或者反过来,给出中序和后序,要求输出层序。
这类题的核心逻辑异常简单,用一句话说就是:先序(或后序)遍历的第一个(或最后一个)节点一定是根节点,然后在中序遍历中找到根节点的位置,左边的部分属于左子树,右边的部分属于右子树,递归处理。
但实现起来,大家会踩到各种意想不到的坑。
4.2 递归函数的参数设计与终止条件
我看过很多人的写法,问题出在递归参数的混乱。最典型的错误是:不在递归函数里传区间边界,而是每次复制子串或者子数组,导致复杂度飙升,或者干脆写错边界。
我采用的写法是传四个参数:当前子树在preorder中的区间[l1, r1]和在inorder中的区间[l2, r2]。
每次递归的步骤:
- 先序的l1位置就是根节点
- 在中序中从l2到r2扫描,找到root的位置pos
- 左子树的节点数量 = pos - l2
- 左子树递归:先序区间[l1+1, l1+1+len],中序区间[l2, pos-1]
- 右子树递归:先序区间[l1+1+len, r1],中序区间[pos+1, r2]
- 最后输出根节点(后序)
这里最容易写错的是右子树的先序区间边界。很多初学者会写成[l1+1+len-1, r1],多写一个-1,导致数组越界或元素错位。我在做OJ 73时,就因为这一个小地方debug了二十分钟。
4.3 数组越界与递归深度的实战教训
这道题还有一个隐藏的严重问题:递归深度。当二叉树退化成一条链时,递归深度等于节点数N。如果N是100000,很多语言环境下会导致栈溢出。
我用的处理方案是:在递归函数开头做一个剪枝判断——当l1 > r1时直接return,表示当前子树为空。这个判断必须放在访问数组元素之前,否则就会越界。
关于递归深度,有一个更稳健的做法:把递归改成循环加显式栈模拟。但OJ 73的节点数通常不会大到递归爆栈的程度,所以大部分情况下递归版本就能过。不过我还是建议你养成“预估递归深度”的习惯,先算一下最坏情况下的调用层数,再决定要不要用迭代写法。
输入输出方面,这道题也经常出幺蛾子。比如输入序列可能跨行,字符串之间可能有多个空格,如果你用 scanf("%c") 来读字符,可能会把换行符读进去,导致节点序列错乱。稳妥的方法是先把所有字符读入字符串,然后取出其中的字母字符;或者用 cin >> s 自动跳过空白符。
我见过有人在讨论区求助,说自己手工输入样例完全正常,提交后WA。后来他在本地用文件重定向测试,发现OJ的输入文件里序列前后有不可见字符,这就是典型的输入格式处理问题。
4.4 辅助数组优化中序定位
在中序遍历里查找根节点位置,如果每次都线性扫描,总复杂度是O(N^2)的。对于N为100000级别的数据,依然会TLE。
优化方案是:先用一个哈希表(或数组)记录每个节点在中序序列中的下标,然后在递归时直接查表获得pos,总复杂度降到O(N)。这个优化写起来很简单:
cpp复制unordered_map<char, int> mp;
for (int i = 0; i < len; i++) mp[inorder[i]] = i;
然后递归里的查找就变成:
cpp复制int pos = mp[preorder[l1]];
这一步优化对于OJ 73来说是性价比极高的改进,代码只多了三行,却能让数据范围扩大一个数量级。做题的时候一定要有“先看数据范围再定算法”的思维习惯,不要上来就线性扫描。
5. 本地全过交上去却WA?OJ判题细节与对拍排查方法
5.1 常见判题差异来源
三道题都做完之后,我遇到过一种极其绝望的情况:本机测试怎么跑都正确,提交到OJ上就是WA。经过排查,我发现这类问题大多源于以下几个差异:
- 变量类型与数据范围:题目要求N最大是10^9,你用int存,本机测试时数据恰好没超过int范围,但OJ的测试点里藏着超界的值。这类问题在涉及加法乘法时尤其严重,要用long long的地方千万不能省。
- 输入读入方式:
getline和scanf对行尾换行符的处理不同,尤其是字符输入,很容易把空格或换行符读进变量。 - 静态数组大小:数组开得不够大,或者刚好卡在边界上,本机测试数据量小没问题,OJ大数据量直接越界,覆盖了别的变量,产生诡异结果。
- 多组数据的初始化:题目要求处理多组输入,如果你没有在每组开始前重置全局变量,上一组的数据会污染下一组。
5.2 对拍的完整操作流程
遇到“本地AC、OJ WA”时,最高效的排查手段是对拍。
对拍步骤如下:
- 编写一个暴力求解程序
brute.cpp,逻辑简单、绝对正确,不要求效率,只求正确性。 - 保留你当前怀疑有问题的提交程序
solve.cpp。 - 编写一个数据生成器
gen.cpp,根据题目数据范围随机生成小规模数据,保证生成的输入合法且有代表性。 - 写一个批处理脚本或shell脚本,循环执行:先运行生成器,把输出写入
in.txt,然后分别运行brute和solve读取同一份in.txt,对比两者的输出。一旦发现不一致,立即停止,把当前in.txt保存下来。 - 用这份
in.txt做单步调试,定位问题。
Windows下用批处理,Linux下用bash脚本:
bash复制while true; do
./gen > in.txt
./brute < in.txt > out_brute.txt
./solve < in.txt > out_solve.txt
if ! diff -q out_brute.txt out_solve.txt > /dev/null; then
echo "Hack found"
break
fi
done
我靠这个流程至少找出过十多个隐蔽bug。每一次找出问题后的感觉都是:这个错误我本可以避免,为什么当时没发现?
5.3 小数据随机生成器的设计技巧
生成器不是随便生成数据就完事,要针对题目特性设计边界倾向。
以OJ 71为例,生成器应当能自由控制N的大小和M的大小,并且要测试M=1、M=N、M=2N这些特殊值。以OJ 72为例,生成器应当能生成连续入栈、连续出栈、交替操作的序列。以OJ 73为例,生成器要能构造完全二叉树、单链树、稀疏树等不同形态。
每次都先生成小规模数据(N=1到20),快速对拍出错误,然后再增大数据量验证性能。用这个方法论,你会发现很多“玄学WA”在几分钟内就能水落石出。
6. 从OJ 71到OJ 73:三个通用解题思维习惯
三道题做下来,我总结出了三个适用于所有OJ题目的思维习惯。
第一个思维习惯是“先看范围再动笔”。拿到题目,第一件事不是想用什么算法,而是看N的范围、时间限制、空间限制。这三个数值直接决定了解法的大方向。如果N在10^5以上,O(N^2)基本可以排出;如果N在10^3以下,暴力法有时反而是最可靠的AC路径。
第二个思维习惯是“为每个操作找到最优的实现方式”。OJ 72就是一个最好的例子:查询最大值的操作,可以用辅助栈O(1)实现,不需要线段树的全能力。做题时多问自己一句:这道题里的操作是全能力的吗?还是只在某个边界上发生?如果只在尾部或头部操作,大概率用栈或队列就够了。
第三个思维习惯是“把边界条件当成一等公民”。空输入时怎么办?只有一个元素时怎么办?重复元素时怎么办?顺序单调时怎么办?这些特殊情况往往就是题目隐藏的测试点。每写完一个版本,先用这些边界数据手测,再去想对拍。
我现在刷任何题目,花在分析上的时间至少占一半。写代码本身反而变成了最机械、最快的那一步。OJ 71、72、73这三道题如果只看代码量,加起来可能不超过120行,但它们训练出来的思维能力,却可以让后面的每道题都轻松很多。
我个人的体会是,这三道题是最适合用来做“刷题方法论检验”的样本。如果你能严格按照上面的流程把每一步都走完——先想清楚数学模型、再选择合适的数据结构、再调试边界条件、最后用对拍验证——你会发现不只是这三道题,整个OJ训练的效率都会提升一个档次。
