前些天有个刚接触在线评测的同学问我说,大家都在刷“OJ题目”,但打开题库从第一题看到第一百题,完全不知道从哪下手,做了几道简单题又觉得没什么长进。这个问题其实问到点子上了。OJ题目这四个字,在圈外人看来就是“程序题”,但在经历过一次次 Wrong Answer、Time Limit 熬过来的人眼里,它是一整套包含读题、算法设计、代码实现、系统评测的完整闭环。这篇文章不绑定任何特定平台,也不讲具体题号,只聊我在刷题和给别人讲题过程中总结出来的通用方法论——包括判题原理、刷题策略、题型套路、实战排查。不管你是刚接触编程的在校生,还是准备面试的求职者,或者是想打算法竞赛的爱好者,下面这些内容应该都能让你少走不少弯路。
1. OJ题目背后到底在考什么:判题系统与三座大山
很多新手把OJ题目简单理解成“把功能写出来就行”,这是第一个认知误区。在线评测平台跟你本地运行程序完全是两码事,你面对的不仅是题目本身的逻辑,还有一台只看结果的机器。不理解这一点,后面所有经验都无从谈起。
1.1 判题系统是怎么处理你提交的代码的
你提交一份代码之后,平台的判题系统会经历一个固定流程:先把代码交给编译器编译,编译不通过就直接返回编译错误;编译通过后,系统会用一组又一组预先准备好的输入数据去运行你的程序,采集你的标准输出,再跟答案文件逐字符比对。注意“逐字符比对”这个词,这意味着多余的换行、末尾空格、大小写差异,都可能让你的代码被判成答案错误。
判题系统运行你的程序时,采用的是黑盒测试思路。它不会关心你的代码结构多优雅、注释写得多好,只看固定输入下输出是否正确。更关键的是,同一道题通常有几十个测试点,有的测普通情况,有的专门测极端边界。系统把测试点分成若干子任务,全部通过才算通过整道题,有些OJ还会给部分分或按子任务计分。这意味着哪怕你99%的测试点都过了,只要有一个边界情况没处理,整道题的状态依然是未通过,你照样拿不到分数。
还有一点容易忽略:OJ程序运行在同一台多用户服务器上,每个测试点通常会开启一个独立的进程运行你的程序,加上严格的时间、内存、CPU占用限制,防止恶意代码拖垮整个评测系统。所以你的程序之间不能有任何残留状态,全局变量必须自己初始化,打开的文件要记得关闭,这些在多次提交之间很容易埋雷。
1.2 时间限制与空间限制:一道题的隐性边界
任何OJ题目都会明确标注时间限制和内存限制,常见的是1秒或2秒、128MB或256MB。这两个数字不是摆设,而是在逼你在写代码之前就建立起“复杂度意识”。
一台普通评测服务器每秒大约能执行3亿到10亿次简单CPU指令,但要扣掉语言解释器开销、内存访问、系统调用之后,真正留给你的算法运行次数大概是10^8这个量级。也就是说,如果时间限制是1秒,你的代码主体操作次数超过一亿次,就有很大概率超时。怎么把操作次数控制在范围内?靠的就是估计算法复杂度。
这里有一个我经常给新手看的数据规模对照表,非常实用:
| 数据规模 | 可接受算法复杂度 | 典型算法 |
|---|---|---|
| n ≤ 100 | O(3^n) 或 O(n!) 但需剪枝 | 暴力搜索、全排列 |
| n ≤ 1000 | O(n²) | 双层循环、普通DP |
| n ≤ 10^5 | O(n log n) | 排序、二分、线段树 |
| n ≤ 10^6 | O(n) | 线性扫描、哈希表 |
| n ≤ 10^9 | O(log n) 或 O(1) | 数学公式、二分答案 |
举一个实际例子:题目给出n最大10^5,如果你写了个双层循环的O(n²)解法,最坏情况要跑10^10次,无论如何都超时。这时候你要么把内层循环改成哈希查找降成O(n),要么用排序加双指针降到O(n log n)。先看数据范围再定算法,而不是拿到题就闷头写,这是从新手到老手的第一个分水岭。
空间限制同样要提前心算。一个int占4字节,long long占8字节,char占1字节,100MB内存理论上能开约2500万个int变量。如果你的算法需要二维数组dp[10005][10005],直接就是10000×10000×4字节约400MB,妥妥超内存。遇到这种情况要么优化状态维度,要么改用滚动数组,要么把int换成short。空间复杂度的估算应该出现在编码之前,而不是在收到“内存超限”之后才开始后悔。
1.3 边界条件:让大多数提交WA的元凶
说个扎心的现实:OJ题目给的样例几乎都是最温和的情况,而隐藏测试点里塞满了你想象不到的边界。你本地测试样例能过,提交以后却发现一片红,大概率就是输在了边界条件上。
常见的边界类型包括:输入为空、n等于0、n等于1、数组长度取最大值、元素全部相同、元素全部逆序、数值达到int上限、浮点数精度极接近但不等。每一个看起来不起眼的角落,都可能藏着一个测试点。比如题目说“0 <= n <= 10^9”,你顺手在代码里用了int n然后做加法,相加结果直接溢出成负数,整个逻辑全乱了。这种问题改成long long就是一秒钟的事,但如果你没有养成“看到大数值范围先上long long”的习惯,就会在同一个地方反复翻车。
再比如浮点数判断,OJ题目里如果允许误差,比如绝对误差或相对误差不超过10^-6,你就不应该用“等于”来比较,而应该用fabs(a - b) < 1e-8。这也是为什么很多题解里会出现eps这个变量。边界条件没有统一套路,唯一靠谱的方法就是把题目里的每个约束条件当作一个测试点来设计,提交之前先在心里把所有极端输入跑一遍。
下面是一张常见的判题状态速查表,新手一定要提前熟悉:
| 状态 | 含义 | 常见原因 |
|---|---|---|
| AC | 通过所有测试点 | 无 |
| WA | 答案错误 | 算法逻辑、边界处理、输出格式 |
| TLE | 超出时间限制 | 复杂度过高、死循环、输入输出太慢 |
| MLE | 超出内存限制 | 数组过大、容器无上限 |
| RE | 运行时错误 | 数组越界、栈溢出、空指针 |
| CE | 编译错误 | 语法问题、头文件缺失 |
| PE | 输出格式错误 | 多余空格或换行 |
2. 刷题方法论:这样选题和复盘才不会做无用功
方向不对,努力白费。大部分人刷OJ题目效率低,不是因为不勤奋,而是因为从不规划。今天做一道字符串,明天跳到一个线段树,后天又去看图论,知识点在大脑里一盘散沙。真正高效的做法是把刷题当成建房子,先搭骨架再填细节。
2.1 按知识图谱刷题,别按题号刷题
打开任何一家OJ平台,题目列表都是按入库顺序排的,这个顺序对学习完全不友好。正确做法是给自己梳理一张知识图谱,按专题推进。
基础阶段先覆盖这几块:输入输出处理、枚举与模拟、排序、二分查找、双指针、栈和队列、链表、哈希表、递归。这一步的目标不是做出难题,而是做到“看到题能判断出该用什么数据结构”。中期阶段切入:二叉树、堆、贪心、动态规划、深度优先搜索、广度优先搜索、并查集、最短路、最小生成树。每块至少集中做15到20道题,做完回头总结这一专题的常见套路。进阶阶段再碰:数论、字符串哈希与KMP、线段树、树状数组、网络流、计算几何。
集中刷专题的好处是,你在短时间内反复调用同一类算法,大脑会自然而然形成模式识别。比如连续做10道二分答案的题,后面再遇到“最大化最小值”这类描述,你第一反应就是二分,而不是在考场上靠运气现想。
不同目标的人侧重点也不一样。如果是准备编程竞赛,重点放在思维和推理能力的训练,题型可以偏冷门偏难;如果是准备技术面试,重点放在高频模型上,动态规划、哈希表、树、递归的理解深度远比冷门算法重要。我自己见过不少为了面试硬啃计算几何的人,最后被问一个树上DP却答不上来,这就是典型的战术勤奋、战略偷懒。
2.2 难度分配的黄金配比与做题节奏
很多人的另一个误区是只做简单题,因为简单题AC快、有成就感;或者反过来只死磕难题,一道题憋三个小时,最后心态崩了。我的经验是保持一个5:3:2的配比:简单题占五成,用来保持手感、巩固基础;中等题占三成,这是真正长本事的部分;难题占两成,用来突破上限、锻炼思维。
做题节奏也要控制。拿到一道题,先自己独立思考30到40分钟。这40分钟里可以做三件事:确认题意、设计算法思路、估算复杂度。如果40分钟后依然没有头绪,果断看题解。看题解不是丢人的事,但看题解的方式很重要——先只看思路提示,不直接看代码。把思路理解了之后合上题解自己写一遍,写不出来再打开对照。这样一遍下来,这道题才真正进入你的脑子,而不是你的收藏夹。
每天刷多少题也不是越多越好。我建议用“有效产出”来衡量,而不是提交次数。所谓有效产出,是你真正理解并且自己能复现的题目数量。分散式练习效果好于突击:每天一到两小时的连续投入,远比周末一次性刷十个小时更让人进步。刷题最忌讳的就是三分钟热度,前三天打了鸡血一样一天十题,第四天就荒废了。
2.3 错题复盘比做新题更重要
我见过很多人刷题量很大,但过两个月再问同一道题,他们连当初怎么AC的都不记得。这就是刷题不总结的下场。看题解AC不算会,只有下次遇到同类题能在较短时间内独立写出来,才算掌握。
推荐一个我一直在用的三栏复盘法。准备一个表格或用在线笔记建三个字段:第一栏写题目考点和你的最初思路;第二栏写你卡住的地方,越具体越好,是“没想到用单调栈”,还是“DP状态设计不出来”,还是“边界条件漏了n=1”;第三栏写突破点,也就是题解的思路为什么能想到、跟你原来的思路差在哪里。每周抽时间翻一遍第三栏,你很快会发现很多难题的突破口其实有共同模式。
复盘时还要做“一题多解”。一道题用朴素方法过了,并不代表你应该满足。去看看有没有更高效的做法,树的遍历能用递归也能用栈模拟,最短路既能用Dijkstra也能用SPFA,同一个问题不同复杂度对应不同适用场景。把多个解法都写一遍,下次遇到数据规模更大的同类型题,你才有升级方案的底牌。
3. 核心题型的套路拆解:从读题到稳定AC
刷多了你会发现,OJ题目表面千变万化,底层套路其实没有想象中那么多。模拟、贪心、动态规划、搜索、图论、数论,每个大类的题型都有可复用的解题框架。把这几个框架吃透,拿到任何新题都能快速归类。
3.1 模拟题:读题比写代码难
模拟题是OJ里最常见也最容易被轻视的题型。它的算法含量通常不高,难点在于“把题目描述的执行过程准确翻译成代码”。为什么很多人在模拟题上WA得一头雾水?因为题目里的一句话,可能包含三四个隐含条件,漏掉一个,整个行为就变了。
解模拟题的第一步是拆流程。题目说“系统每秒处理一个请求,超过N个请求时拒绝新的请求”,你就要在纸上把“处理、拒绝、计数、重置”这些动作画成时间线,明确每个操作是发生在哪一秒。第二步是模块化。把每个独立环节抽成函数,比如“判断是否合法”“更新状态”“输出结果”,不要在main函数里写一坨几百行的顺序代码。模块化不只让代码更清晰,更重要的是调试时可以单点验证,出错能快速定位到是哪一个环节。
模拟题还有一个高频考点是字符串和输入输出格式。很多模拟题的目的就是考你解析按行输入、按逗号分隔、处理换行符和多组数据。这里有个无数人踩过的坑:用scanf读完整数后,缓冲区里还残留着换行符,紧接着用getline读字符串,就会读到空行。要么用getchar把换行吃掉,要么全部用cin和getline保持一致。下面是一个处理多组数据直到EOF的典型写法:
#include <iostream> using namespace std; int main() { int n; while (cin >> n) { // 每组数据记得重置状态 // 处理当前组的逻辑 } return 0; }关键点在循环体内一定要重置所有状态变量。若你用了全局数组或vector,每组数据结束后都要清空。很多WA就是上一组残留的数据污染了下一组的结果。
3.2 贪心和动态规划:怎么判断用哪个
贪心和动态规划是OJ题目里最容易混淆的两个大类。两者的适用场景有本质区别:贪心要求每一步的局部最优选择就是全局最优解,而动态规划适用于存在重叠子问题和最优子结构的场景。
怎么快速判断?拿到题目先问自己:当前这一步的选择,会不会影响后面所有选择的可能性?如果当前选择一旦确定,之后每一步都可以用同样的规则独立最优地做下去,优先考虑贪心。比如经典的活动安排问题,按结束时间排序后总是选择最早结束的活动,这就是典型的贪心。反过来,如果当前决策会导致未来的状态发生连锁变化,或者同一个子问题会被重复计算,那大概率是动态规划。比如背包问题,你在这个位置装不装物品,直接影响剩余容量,装了后面也许更优也许更差,没法靠一步贪心解决。
动态规划有一套固定节奏。我总结为五步:确定状态含义、设计状态转移方程、想清楚初始值、确认遍历顺序、定位最终答案在哪。以最基础的爬楼梯问题为例,dp[i]表示爬到第i级有多少种方法,转移方程是dp[i] = dp[i-1] + dp[i-2],初始值dp[0]=1、dp[1]=1,然后从2遍历到n,答案就是dp[n]。这里面的核心思维是“我只关心到当前状态的最优值,不关心过程细节”。
还有一个实操技巧:动态规划题写完转移后,一定要用题目给的样例手推一遍数组。手动推演一遍之后,状态定义有没有问题、初始化对不对、遍历顺序能不能得出正确答案,全部一目了然。千万别一写完就交,状态转移错了编译能过、运行能跑,但结果全错,手推一遍能帮你省下好几次WA。
3.3 搜索题:剪枝是灵魂
搜索题是新手最容易兴奋也最容易崩溃的类型。深搜和广搜的原理很简单:把所有可能的状态都遍历一遍。麻烦的是,朴素搜索的状态空间往往是指数级的,稍微大一点的数据就跑不动了。
深搜适合“是否存在路径”“列举所有可行解”这类问题,实现上多用递归;广搜适合“最短步数”“最少操作次数”这类每一步代价相同的最短路问题,实现上多用队列。一个最直观的记忆方法:问我“有没有解”用深搜,问我“几步能得到解”用广搜。
但真正的分水岭是剪枝。你要是真把每个分支都搜到底,数据一上来必超时。剪枝主要看三个方向:一是可行性剪枝,发现当前状态已经确定无解或不可能达到目标,立即返回;二是最优性剪枝,比如当前记录的答案已经是10,你搜到第11层还没搜完,直接放弃;三是记忆化,把已经搜索过的状态记下来,下次再遇到直接用。把记忆化用到深度优先搜索上,就成了动态规划的另一种实现形式,这也是很多题既可以用记忆化搜索又可以用递推DP的原因。
搜索题还有一个容易被忽略的点:递归深度。OJ平台对进程栈空间通常有严格限制,递归层数太深,本地跑得好好的代码,提交上去直接运行时错误。遇到这类情况,要么用循环加显式栈把深搜改成非递归,要么在语言层面调整栈大小,要么重新设计状态,减少递归深度。我在实际刷题中见过一个同学的代码,递归深度到了100万层,本地Windows上没事,提交到某OJ立即爆栈。这种现象非常典型,值得所有写深搜的人记牢。
4. 实战中的翻车现场与排查技巧
刷OJ题目八成的时间都在跟错误状态打交道。很多人一看到WA就慌,一看到TLE就放弃治疗,其实每个状态背后都有相对固定的排查路径。学会像侦探一样看错误状态,比盲目重写代码有效得多。
4.1 判题状态怎么看:从WA到AC的距离
拿到一个非AC状态,先不要急着改代码。按下表顺序排查是最快的:
| 提交状态 | 第一排查顺序 |
|---|---|
| CE | 本地编译器版本、头文件、语法错误 |
| RE | 数组越界、栈溢出、除零、空指针 |
| WA | 测试边界输入、输出格式、多组数据状态残留 |
| TLE | 算法复杂度、输入输出方式、死循环 |
| MLE | 数组维度、容器占用、递归栈占用 |
| PE | 多余空格、换行符、格式要求 |
我自己最常见的翻车组合是WA加TLE一起出现。这种时候往往不是边界写错,而是算法选型错了,同一个问题用O(n²)解法在本地测试小数据能过,大数据直接超时,但超时过程中产生的中间结果又是错的,于是某几个测试点显示WA,某几个显示TLE。解决思路不是打补丁,而是回到第一步重新设计算法。
4.2 超时与超内存的快速估算
每次提交前花十秒钟心算一下复杂度,能拦截掉一半以上的TLE。具体做法:先看数据范围,再看你的主算法核心循环的嵌套层数。如果n最大是10^5,代码里有个双重循环,最坏10^10次操作,想都不用想,必超时。这时候要么推出O(n log n)或O(n)的解法,要么看看是不是有排序、哈希、二分这些降复杂度的手段可用。
输入输出太慢也是一类隐蔽的TLE来源。在部分OJ上,C++的cin如果不同步stdio,处理几十万行输入时会比scanf慢很多。数据量大的题,建议直接用scanf/printf,或者写一行ios::sync_with_stdio(false); cin.tie(0);。用Python刷题的同学更要注意,多组大数据输入用sys.stdin.read()一次性读进来再split,比一行一行input()快得多。这个技巧看起来不起眼,但能直接把超时变成AC。
内存超限的排查则集中在申请过大的数组和容器上。比如明明n只有10^5,你却在main里开了一个静态二维数组dp[1005][1005],虽然没爆,但加上其他变量和系统库,可能就超了128MB。解决办法很简单:先算尺寸再开数组,能精简维度就精简维度,能用vector动态分配就不要开死。这里有个小经验:OJ题里的内存限制,通常要把“代码段、栈、堆、静态数组”都算进去,不是一个int数组的大小那么简单。
4.3 本地能过、一提交就挂的常见原因
这种情况是所有刷题人的噩梦。代码在自己电脑上跑样例完美,提交到OJ就是错,而且完全摸不着头脑。我总结下来,大概率是下面几个原因之一。
第一个原因,多组数据没有清空状态。本地你只跑一组样例,悄悄风云变幻的全局变量不会影响结果;OJ上每个测试点是独立的数据,但你的程序是连续处理的,如果上一组数据在容器、计数器、访问标记里留下残留,下一组数据就会计算出错误结果。第二个原因是数组越界。本地的内存布局和OJ不完全一样,越界后你的程序可能侥幸没崩,OJ上一跑就RE。尤其注意字符串末尾的\0、循环边界是<=还是<。第三个原因是调试输出没删干净。printf调试信息残留在提交代码里,本地你看着是正常输出加一行调试,但OJ比对输出时差一个字符就是WA。
还有一类原因是评测环境差异。比如本机用的整数大小、char是不是unsigned、递归栈大小、编译优化级别,都可能和OJ不一样。我在讲解时经常提醒:不要在代码里写依赖“特定环境才成立”的写法,比如if (x == INT_MIN)这类在不同环境下表现不同的边界逻辑,能避免就避免。
4.4 提交前两分钟自查清单
养成提交前的固定自查习惯,能极大提升AC率。我每次提交前都会机械式地过一遍下面这张清单:
- 数据范围是否用了足够大的类型,int是否会溢出
- 数组大小是否至少比最大值多开5个,防止越界
- 多组数据相关的标志位、容器、计数器是否清空
- 是否删除了所有调试输出和临时写死的测试分支
- 是否考虑过空输入、n=0、n=1、元素全相等这类边界
- 输出格式是否和题目要求完全一致,包括空格和换行
- 复杂度是否在时间限制范围内,心算过一遍操作次数
- 递归深度是否可能超栈,是否需要改非递归
这套清单最大的价值是稳定。它不依赖你当时的状态,不会因为你熬夜刷题头脑发昏就漏掉关键点。回头看看我踩过的那些坑,十个里面有八个都能用这套清单拦住。
我个人在实际操作中最大的体会是:稳定AC不是靠灵感,而是靠“见得多、归纳套路、提交前严格自查”这三板斧。最后再分享一个压箱底的小技巧:每AC一道题之后,不要急着做下一道,花五分钟看一眼排行榜上用时最短、代码最简洁的提交是怎么写的。尤其是那种思路跟你不同但非常干净的代码,往往藏着对面大佬对问题的更深理解,看完之后把自己代码优化一遍,这个过程比多做两道新题都值。