断断续续在OJ上刷题也有快两年了,从最开始连多组输入都不太会处理,到后来能静下心把一道中等难度的题完整想清楚再动手,中间走过的弯路真不算少。这篇“OJ题(2)”是第二阶段刷题的一个记录,重点想聊一聊比“会不会做”更值钱的那部分经验:边界条件怎么抠、时间复杂度和内存怎么估算、一道题到底是“模拟”还是“图论建模”、提交超时以后又该从哪里下手,以及不同OJ平台之间那些容易让人忽略的差异。
如果你正在准备算法比赛、暑期实习的在线笔试,或者单纯想把算法基础打得扎实一点,这篇文章应该能帮上忙。我不会去复述某道题的具体题解,因为那是“授人以鱼”的事;我更想把做题过程中那些不太会出现在文档里、但几乎天天都踩的东西摊开讲清楚,比如为什么样例过了还是WA、为什么本地一秒跑完提交却TLE、什么时候该用二分而不是哈希。这些都是偏“实战向”的细节,能让你少走很多弯路。
1. 为什么说“做题数量”是最容易被高估的东西
很多新手一上来就给自己定目标:这个月刷50道,下个月刷100道,好像刷得越多就越厉害。我在刚开始刷OJ的时候也是这种心态,总觉得只要量大,水平自然就上去了。但刷到后面发现问题没这么简单——数量只能代表你“见过”很多题,不代表你能在笔试或者比赛里稳定做出来。真正拉开差距的,是你拿到一道陌生题之后的分析过程是否清晰,以及你对自己写出来的代码有没有足够的掌控力。
1.1 每一次提交背后都藏着一组约束条件
OJ题和平时写业务代码最大的区别在于“约束条件”是显性的。题目会告诉你有多少组数据、数据范围有多大、时间和内存限制是多少,这些不是摆设,它们直接决定了你要采用哪种算法。
举个例子,如果一道题的数据范围是 n ≤ 10^5,那么 O(n^2) 的写法在大多数OJ上都会超时,你需要往 O(n log n) 甚至 O(n) 去靠。如果 n 只有 100,那两层甚至三层循环都基本没问题。我早期做题的习惯是直接跳到“用什么数据结构”这一步,完全忽略了数据规模,结果就是思路本身没错,代码写出来却怎么都过不了。
后来我要求自己每次读题先画一个“规模到算法”的映射表:看到 10^5 想到排序、二分、线段树、前缀和;看到 10^3 可以接受 O(n^2) 的DP或 Floyd;看到个位数范围往往要考虑状态压缩或暴搜。这种条件反射花不了多少时间,却能让你在选算法的时候少走很多弯路,也不至于辛辛苦苦写完一个O(n^2)的暴力才发现题目根本不给你这个复杂度空间。
1.2 复盘比开新题更有价值
刷题数量上不去往往是因为时间都花在“开新题”上,而真正让水平提升的关键其实是对已经做过的题进行复盘。我现在的习惯是,每做完一道有价值的题,会在代码仓库里记一段简短备注,包括用到的套路、踩过的坑、有没有更优解。这比AC数好看有用得多。
比如“区间询问、离线处理”这类题,如果第一次接触时只是对着题解敲一遍,那基本等于白做。可如果你能总结出“什么信号出现的时候应该往分块或莫队上想”“离线排序的思路为什么能降低复杂度”,下次再遇到类似题就会有一种似曾相识的直觉。这种直觉不是天赋,而是大量复盘堆出来的模式识别。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 多数人都会栽跟头的四类OJ坑
光有做题流程还不够,做题过程中那些藏在细节里的坑才是真实消耗时间的地方。我总结下来,最容易让人从“我觉得没问题”到“WA到怀疑人生”的坑,大概就集中在下面四类。
2.1 边界条件和数据范围是WA的头号来源
边界条件出问题是OJ刷题中最常见的WA原因,没有之一。很多题目看起来思路完全正确,样例也能过,但一提交就错,问题往往出在数组越界、空数组、整数溢出、区间端点处理这些细节上。
我在做差分数组和前缀和题目时,习惯在草稿纸上手动推一遍首尾几个具体位置,而不是只在脑子里想,这样可以避免“数组要多开一个还是两个”“左闭右开还是左闭右闭”的混乱。还有整数溢出,很多人只顾着把结果定义成 long long,却忘了在乘法过程里中间值也可能溢出。你不妨把每个参与运算的变量都想想它最大可能到多少,再做类型选择。这个习惯在数据范围超过 10^9 的时候尤其重要。
动态规划也是边界问题的高发区。dp[0]、dp[1] 的初始化经常决定你整个递推是否成立;状态转移时跳过的位置稍不留神就会引用到没有初始化过的值。我的做法是把初值状态先写好,再带着最小规模的测试用例手动推一遍递推过程,通常能提早发现一大半问题。
2.2 数据规模不同,解法完全不是一回事
同样一道“统计区间内有多少对数字满足某个条件”,数据范围小的时候直接暴力就能过;数据范围大了,就要考虑排序加双指针、离散化加树状数组、或者分块查询。算法学习里的难点不是背模板,而是能在最短时间内判断出这题该上什么量级的方案。
比较典型的例子是求逆序对:n ≤ 5000 的时候直接用两层循环暴力统计,代码简单又不容易错;但 n ≤ 10^5 的时候,两重循环必然TLE,得往归并排序或树状数组的方向走。再比如求最长上升子序列,O(n^2) 的 DP 能解决的规模有限,一旦数据变大就要换成基于二分的耐心排序法,关键是你要敏锐地察觉到“这题不能用老方法硬解”。
做题遇到 TLE 时,最优应对不是盲目加剪枝或者开O2优化,而是先反过来算一遍当前复杂度在给定数据规模下到底该跑多少次运算。超出 10^7 量级,在大多数OJ上是比较危险的。至于常说的10^8量级能不能过,取决于题目给的时限以及OJ机器的实际性能,不要抱着侥幸心理去赌。
2.3 输入输出格式带来的“隐性差异”
很多人会忽略输入输出格式里的坑,觉得只要数据算对了就行。但OJ判题是拿你的程序输出和标准答案逐字符对比的,多一个空格、少一个换行、行首多一个空白字符,都可能让你WA。
多组数据的题要格外小心,区分清楚是“读到EOF结束”还是“先读一个T,再做T次循环”。这两种模式代码结构完全不同,搞错之后最常见的现象就是第一组数据能过,后面的数据全部错位。我自己早期经常在这种地方栽跟头,所以后来的习惯是先把输入输出框架写好,再一步一步往下填逻辑,而不是想着“主逻辑写完再补输入处理”。
另外一点:频繁使用 cin/cout 又不同步关闭时,输入输出效率可能成为性能瓶颈。如果发现本身 O(n log n) 的算法却超时,可以先检查一下是不是被输入输出拖住了,尤其是在单组数据规模达到 10^5 以上的时候,用 scanf/printf 或者加上 ios::sync_with_stdio(false) 都是值得考虑的优化点。
2.4 浮点精度与取模问题这种“细节病”
浮点比较是OJ里一个经典大坑。两个浮点数几乎不可能绝对相等,所以判断 a == b 往往会出错。更稳妥的做法是比较两者差值的绝对值是否小于一个极小量,比如 1e-9,这在计算几何类型题目里尤其明显。如果题目允许,能用整数计算就尽量别上浮点,用交叉相乘代替除法,用整数比较代替浮点比较,能少掉不知多少精度问题。
取模运算看起来简单,但负数的处理很容易出问题。在很多编程语言里,负数取模的结果和数学意义上不太一致。如果题目要求结果对某个大数取模,并且涉及减法,最好在减法之后加上模数再做一次取模,避免出现负数结果。比如计算 (a - b) % MOD 时,更稳的写法是 ((a - b) % MOD + MOD) % MOD。这个 +MOD 的小动作能在比赛和笔试里救你很多次。
3. 我自己一直在用的一套“一次AC”做题流程
很多同学做题是拿到题立刻敲代码,边敲边想,结果经常是敲到一半发现思路错了,删掉重来。我在刷题量起来之后逐渐改掉了这个习惯,现在尽量坚持一套比较固定的流程,从读题到AC大概分五个步骤走,每一步都会做一些看似琐碎但很有用的事。
3.1 审题阶段:把样例当契约,而不是参考答案
读题不只是把文字读完,而是要读出几个关键信息:题目给的是什么类型的输入、输出必须满足什么格式、数据范围的最大值是多少、时间限制和内存限制是多少。样例只是帮助你理解题意,但样例过了不代表程序正确,因为样例通常不给极端情况。
我在审题时会顺手把题目里的条件转写成自己的话,写在注释里。比如“数组下标从0开始还是从1开始”“如果不存在满足条件的值,输出什么”这类问题,在动手之前就确认好,能有效避免写完卡住。如果题目背景很长,我甚至会先看输入输出样例,猜一下大概要做什么,再回去看题目背景验证,这样理解起来更快。
3.2 设计阶段:先想清楚两件事,再写代码
第一件事是算法复杂度是否满足要求。根据数据范围反推可接受的复杂度级别,确定要用什么算法。第二件事是核心步骤的可验证性,也就是你自己能不能构造几个小数据,手动推出答案,验证思路是否成立。
这个过程我喜欢先在纸上画思路草稿,比如模拟一下双指针怎么移动、递归函数的状态是怎么转移的。把所有分支情况想清楚之后,再写伪代码。伪代码不需要符合语法,甚至只要自己能看懂就行,它最大的价值是强迫你把逻辑结构化,避免一上来就陷入语言细节。
3.3 实现与自查:提交之前需要做的三件小事
代码写完先别急着提交,很多人在这一步省了时间,结果在OJ的WA页面上浪费了更多时间。我在提交前一定会做三件事:
第一步,用边界样例自测。输入最小值、最大值、空数据、仅一个元素、重复元素等,把可能出问题的地方都测一遍。第二步,人工走读一遍核心循环和递归边界,重点检查数组下标是否可能越界、循环终止条件是否正确、递归出口是否在开始处定义。第三步,检查数据类型的取值范围,凡是可能出现超过 int 范围的,都提前改成 long long。
自测的时候还有一个习惯是故意构造当前思路不擅长的数据。比如你写了一个贪心,就专门找那种看起来贪心最容易失效的数据来测,看能不能把你自己的算法推翻。如果没有推翻,说明这个方向至少是安全的;如果推翻了,趁早换思路,省得提交之后被系统打脸。
4. 实战踩坑记录:从TLE和WA中总结出来的排查思路
就算是老手,也不敢保证每道题一次AC。真正重要的是遇到错误之后能不能快速定位问题。这里记录一些我在实际做题中非常典型的报错场景和排查方法,供大家参考。
4.1 TLE(超时)先别急着优化,先定位瓶颈
TLE 是OJ里最常见的非AC结果之一。看到TLE,不要条件反射地觉得“是不是我常数写大了”,先冷静判断瓶颈在哪个环节。我会先看主算法复杂度在最坏情况下大概执行多少次,再看输入输出部分有没有明显拖后腿,最后才去考虑常数级别的优化。
某一次我在做区间和类的题目,用线段树来实现每次都 O(log n) 的修改和查询,当时觉得已经很优了,结果一提交还是TLE。排查到最后发现是输入量太大,我用的是 cin 且没有关同步。把输入换成更快的读取方式之后,同样代码直接AC,耗时降到了原来的十分之一。这个案例说明有时候瓶颈压根不在算法复杂度上。
如果确认复杂度符合要求、输入输出也已经优化,接下来要检查的是算法本身的隐藏常数,比如递归导致的栈开销、STL 容器频繁动态扩容等。把所有能力压到极限还有一种办法是提前算出数据规模下大概的实际运算次数,超出一条安全线就果断换思路。
4.2 内存问题:不要只看“空间限制”那一栏
MLE(内存超限)很多时候不是因为你开的数组比题目标注的内存限制大,而是因为数据结构里隐式分配了许多空间,例如 unordered_map 的链式节点、递归的调用栈、vector 频繁扩容造成的内存冗余。
我的建议是做到对常用数据结构空间复杂度心里有数。一个 int 类型的长度 10^6 数组大约占用 4MB,一个 long long 的 10^6 数组大约占用 8MB。你可以估算一下当前程序里所有主要容器加起来会不会触及几百MB级别的限制。如果在比赛中遇到内存紧的题,能用数组静态实现就少用动态容器,能开一维数组就少开二维。
还有一种MLE容易被忽略,就是递归深度过深导致的栈溢出。深度达到 10^5 以上的递归在部分环境下已经比较危险,如果题目明显需要遍历深度很深的树或图,优先考虑改成显式栈或迭代写法,能稳定很多。
4.3 WA(答案错误)最常见的原因排查表
WA的原因通常比TLE更隐蔽,这里整理一份我从实战里总结的排查顺序,从最基础到最细节逐条走一遍,大部分WA问题都能定位到。
| 排查点 | 具体检查内容 |
|---|---|
| 题意理解 | 是否漏看了输入顺序、输出格式或特殊情况的定义 |
| 数据类型 | 是否在某次运算中出现 int 溢出,尤其在加法、乘法、累加时 |
| 边界条件 | 数组下标是否从0开始,首尾元素是否需要特殊处理 |
| 初始化 | 多组数据循环时,上一组数据的计数器和状态是否清零 |
| 算法正确性 | 贪心是否真的成立,DP状态转移是否覆盖全部情况 |
| 浮点处理 | 是否用 == 比较浮点数,或输出的精度和格式不规范 |
| 取模处理 | 减法结果是否为负、幂次取模是否缺少快速幂步骤 |
每次WA之后我都建议做一次“差异分析”:拿一组比较有代表性的数据,把标准输出和你的程序输出放到一起做 diff,找到差异后反推代码里的问题。用这招比对着代码干瞪眼高效得多。
5. 不同OJ平台之间的差异对做题体验的影响
当你刷题量上来之后,就会发现OJ和OJ之间差异其实非常大。这些差异会影响你的做题体验,也会影响你对某个知识点的熟练度。如果你只在单一平台刷题,可能会对某些场景产生“信息盲区”。
5.1 编译器版本、时限和判空方式的微妙差别
同一个题解在 A 平台 AC,拿到 B 平台可能连编译都不通过。典型问题是不同平台的 GCC/Clang 版本不同,对 C++ 标准支持程度不一样。有些老平台还在用 C++11,而新题目往往用 C++17 写起来更方便。如果你在代码里用了 C++17 才支持的语法,就算提交到能编译的平台上,也建议改成兼容性更高的写法,因为笔试和面试环境未必总是最新编译器。
另一个容易忽略的是时间限制的松紧程度。同一道思路完全一样的题,在 A 平台时限 2 秒、在 B 平台时限 1 秒,那么你的代码可能一边AC一边TLE。这并不代表代码有问题,只是平台机器和新旧程度不同。为了让自己适应这种差异,我在关键算法的模板里会尽量少用常数大的写法,例如能用数组模拟的尽量不用 map 或 unordered_map,这样代码的“平台容忍度”会高很多。
输入数据格式差异也要留意。有的平台所有输入都在一行里,有的平台会跨行给出,如果你用了类似 cin >> x 这样的方式读取,跨行通常没问题;但如果你是字符串处理型题目,就必须想清楚读入中是否包含空白符或行尾回车。不同平台的题目在格式规范程度上统一性并不高,处理输入时保持“只依赖空白符分割、不依赖行号”的编码习惯,能帮你规避很多莫名其妙的问题。
5.2 个人练习时,如何用好平台特性来训练自己
有些比较开放的平台(像一些高校和企业的OJ系统)会保留你每一次提交的源码和判题反馈,这些历史记录是很好的复盘资源。我会不定期翻看过往的提交,看当时是怎么写的、编译器给了什么错误、在哪些步骤改到 AC。这个过程相当于对着过去的自己code review,比看别人的题解更能发现问题。
针对华为OJ这类企业向OJ平台,题目风格通常会更贴近工程场景,边界条件和大型数据处理考察得多。如果你以后要参加这类笔试,建议养成“写完代码先跑边界测试”的肌肉记忆,不要只满足于样例通过。东华OJ这类高校平台则往往作为课程练习使用,题量覆盖范围广,难度梯度大,比较适合打基础时按知识点模块刷。平台之间切换着刷,比死守一个平台更容易拓宽思维。
有一点很重要:不管哪个平台,题目版权和规范都要尊重。练习的时候抄题解不是不行,但抄完一定得自己重新实现一遍,否则很快你会发现,这套题连思路都没留下。
6. 关于“OJ题(2)”这篇记录的一些补充想法
走到这里,算是把第二阶段刷题过程中最想说的经验都整理完了。如果让我给后面的自己留一句话,那一定是:OJ刷题真正锻炼的不是“会做很多题”,而是面对未知问题时的拆解能力、转换能力、以及证明或证伪自己解法的能力。
这种能力不可能靠三天打鱼两天晒网获得,它来自于一次次AC后的思考,更来自于WA和TLE之后的复盘。我在实际使用中发现,给自己定一个具体的练习方向,比单纯追求刷题数量要有效得多。比如这一周只练二分答案,把所有能找到的二分题都做一遍,把边界条件和check函数的写法彻底吃透;下一周再切换到DP或图论。这样每个阶段都聚焦一个点,提升反而比囫囵吞枣地乱刷更快。
最后再分享一个小技巧:准备一个本地题库,把做过的每一道题按知识点标好标签,顺手记录你的思考过程、复杂度分析、以及其他可行方案,哪怕只是三五行也好。隔一段时间回来看,你会清晰地看到自己思维方式的成长轨迹。OJ这条路没有终点,但每一步踩实了,后面就会越走越顺。
