最近把某校OJ平台上的一批ACM基础题系统地过了一遍,题号是2352、2549、2564、2565、2567、2572、2609、2607、2707、2708、2719、2721、2723,一共13道。说实话,我原本以为这种入门阶段的基础题一天就能推完,结果断断续续花了两周的时间,中间踩了无数个坑:本地跑得好好的,一提交就WA;明明思路对了,却因为多打一个空格被PE;还有几道直接RE,排查半天才发现是数组开小了。这篇文章不是标准题解,而是把这批基础题背后的共性考点、正确的刷题方法以及错误排查经验整理出来,给刚接触OJ、准备系统训练算法的同学一个参考。
1. 这批基础题在考什么:表面是题号,实际是基本功
1.1 字符串与格式化输出:基础题的主战场
把2352、2549、2564这一批题号过一遍,你会发现一个非常明显的倾向:字符串处理占了相当大的比重。这类题不会直接告诉你“这是一道字符串题”,而是把任务伪装成“统计某个字符出现了多少次”“把一行文本按规则重组”“处理形如某种格式的编号”。解题的第一步往往是正确读入字符串,第二步是按规则遍历或拆解,第三步才是严格按题目要求的格式输出结果。
为什么ACM基础题这么偏爱字符串?因为在真实竞赛中,字符串是最容易埋坑的输入形式。scanf("%s")遇空格就停,gets能读整行但容易读取到缓冲区残留的换行符,strlen返回的长度不含结尾的'\0',但char数组必须为'\0'留位置。这些知识点在语法书里只是一行两行的说明,落到OJ上就是一次次WA。我从这批题里得到的第一个教训就是:字符串题做得不熟,不是因为你不会循环和判断,而是因为你根本不了解输入函数到底替你做了什么。
1.2 模拟、数学与规律题:把问题翻译成代码
除了字符串,这批题号里还有相当一部分属于模拟题和简单数学题。模拟题的特点是把一个现实规则原封不动搬到题目里,比如按某个公式计算、按某个序列生成结果,解法本身没有难度,但步骤多、分支多,特别考验代码的组织能力。做这类题的时候,我习惯先在纸上把规则改写成伪代码,再翻译成C/C++。直接上手硬写,代码大概率会越改越乱。
数学类基础题则是另一个极端,规则短,数据量可能很大,核心是推公式或者找规律。这类题要注意一个容易被新手忽略的点:数值范围。很多题目看起来只有几个简单运算,但中间过程或者最终结果的量级可能远超int。做题前先看一眼题目给的数据范围,只要单次运算结果可能超过二十亿,就果断用long long。否则样例可能全过,一旦压测数据的数值大了,就会因为溢出输出一堆负数或者错数。
1.3 多组输入与EOF:读懂OJ的“潜规则”
这批题有一个共同特征:题目描述里经常会写“输入包含多组测试数据,处理到文件末尾为止”。很多第一次刷OJ的同学会在这里卡住,大意是“我该怎么知道输入结束了?”答案就是读scanf或cin的返回值。scanf返回成功读入的变量个数,如果读不到数据了,函数返回值会变成EOF,循环自然结束。while(scanf("%d", &n) != EOF)就是最经典的多组输入模板。
这里最关键的认知是:OJ的评测机不会等你交互,它是把一批测试数据一次性喂给你的程序,你的程序自己判断何时终止。写死“只读一组数据”,或者用for循环猜测一个输入次数,都会导致WA。我自己在这件事上栽过好几次跟头,后来总结出一个判断口诀:题目里出现“多组输入”“直到EOF”“若干组”这些字样,一律用while循环包起来,绝不含糊。
2. 刷题前先搞懂测评机:为什么本地好好的,提交就错
2.1 OJ到底怎么判你的程序
很多刚刷题的人有一个致命误解,认为OJ和学校考试一样,是老师看代码给分。实际上评测的过程完全是自动化的:OJ把你的代码编译成可执行文件,然后运行它,自动喂入一组或多组测试数据,再把你程序产生的输出和标准答案逐字节比较。只要有一个字符不一样,这题就不算通过,具体错误类型包括WA(答案错误)、PE(格式错误)、RE(运行时错误)和TLE(超时)。
这个机制解释了绝大多数“玄学问题”。为什么本地输出正确,提交却WA?因为本地只测了样例,OJ跑的是更全面的边界数据。为什么输出看起来一模一样却PE?因为肉眼忽略了一个空格或换行的差异,评测机可不会忽略。理解“逐字节比较”这一点,就会明白做题的底线不是“差不多对”,而是“和答案一模一样”。
2.2 从输入输出格式反推代码结构
做题时我通常先看Output部分,再回头看Input。输入输出格式其实比题目文字更接近代码结构。比如Output里写着“每个测试数据输出一行,行末无多余空格”,那代码里就要设计成“先打印第一个数,之后每个数前面加空格”,而不是“打印每个数后跟一个空格再退格”。这两种写法在样例里看不出差别,但在多组数据时很容易多出一个空格。
建议把题目给的两个样例复制到文本编辑器里,开启“显示空格”功能,仔细看每个空格和换行在哪里。很多PE都是因为输出样例和你的程序之间差了一个换行位置。这个习惯很笨,但极其有效。我在刷2565、2567这类格式敏感的题目时,就是靠这种方式避免了好几次无效提交。
2.3 一个能应付大多数基础题的读写模板
这里放一个我自己常用的模板,它覆盖了数字、字符串、单行文本以及多组输入四种场景:
#include <stdio.h> #include <string.h> int main() { int n; char s[1005]; // 多组数字输入:读一个整数,处理一次 while (scanf("%d", &n) != EOF) { // 处理 n 的代码 } // 多组单行字符串输入:注意用 getchar 吃掉换行 while (gets(s) != NULL) { // 处理字符串 s 的代码 } // 数字+字符串混合输入:读完整行 while (scanf("%d", &n) != EOF) { getchar(); // 吃掉数字后面的换行符 gets(s); // 再读字符串 // 处理代码 } return 0; }需要特别提醒的是,gets在较新的编译环境里可能提示不安全,部分OJ的C++提交会直接报编译错误。遇到这种情况,可以改用fgets(s, sizeof(s), stdin),它会连换行符一起读进来,记得手动清除末尾的'\n'。不管用哪个函数,本质上都是要理解缓冲区的状态,而不是死背函数名。
3. 实操记录:从读题到AC的四步流程
3.1 先把样例“翻译”成人话
拿到一道题,我现在的习惯是先不写代码,而是把输入样例和输出样例手工推一遍。推的过程中把题目规则用自己的话重新叙述一遍,写在一张草稿纸上。比如“输入一个字符串,要求统计某个字符出现的次数”,我理解成“遍历字符串的每一位,遇到目标字符就计数”。这一步看着简单,却能筛掉大量因为误解题意导致的WA。
做这批题时,我在2707和2708两道题上就吃过亏:题目描述里有一个“如果相等则按某某规则处理”的条件,我当时没认真读,直接忽略了特判,结果样例恰好没覆盖这种情况,提交后被边界数据打回。后来我学会了一个方法:读完题后,把“如果、当、否则、只有”这些转折词圈出来,逐一确认代码里有没有对应分支。几乎每一道WA背后,都藏着一个被忽略的条件。
3.2 自己造边界测试用例
样例通过只是最低标准。评测机不只用样例,还会用你完全看不到的边界数据:最大值、最小值、空串、单个字符、连续多个空格、第一个数据就是特殊值……所以写完后,必须自己造边界用例来测程序。
针对这批基础题,我通常这么造数据:如果输入是整数范围0 <= n <= 10000,就测n = 0和n = 10000;如果输入是字符串,就测只有1个字符的串,再测接近题目最长限制的大串;如果题目说“可能有多个空格”,就专门造带连续空格的输入。这里有一个特别重要的心态:造数据不是为了验证程序是对的,而是为了验证程序还没崩。当年我刷2609的时候,自以为写得很稳,结果把最长字符串塞进去,程序直接RE——数组少开了一个字节,'\0'无处安放。
3.3 本地调试和中间值打印
写代码这件事,老手和新手最大的区别在于,新手希望一遍AC,老手默认自己会WA,所以留足调试手段。我常用的调试方式是在关键位置加printf,把中间变量的值打出来。比如处理完一个字符串后,先打印它的长度和每个字符,确认输入解析正确,再进入核心逻辑。
不要小看这个土办法。排查字符串题的WA时,最容易犯的错是:你脑补的输入和程序实际收到的输入根本不是同一个。明明输入是“abc 123”,因为用了错误的读入方式,程序里的字符串可能是“abc”或者干脆是空串。把读入的变量立刻打印出来,能直接暴露问题。调试完记得把多余的输出注释掉,很多人改完代码忘了删调试语句,结果多输出了几行,又变成WA。
3.4 提交后的反馈处理
第一次提交没AC太正常了。我的处理流程是:如果是WA,先不动代码,重新读一遍题,重点看有没有漏条件;读完觉得题目没问题,再检查边界数据;边界也没问题,才去看算法逻辑。如果是PE,那就逐字符对比输出结果。如果是RE,优先查数组大小和非法访问。
这个过程最好固定下来,形成肌肉记忆。刷OJ不怕错,怕的是错了之后没有章法地乱改,把原本对的地方也改错了。我在刷2719的时候连续WA了三次,每次都是看到错误后凭感觉改一行再交,改来改去不过是碰运气。后来我强制自己写了一个checklist,按流程排查,才真正定位到是初始化位置错了。
4. 最容易踩的坑:这些错误我几乎每道题都遇过
4.1 字符串相关的经典翻车现场
先说字符串处理里三个最容易翻车的点。第一个是数组长度:char s[100]只能放99个字符,因为最后一位要留'\0'。题目说字符串最长99,你至少得开101才保险。第二个是strlen()每调用一次都会遍历整个字符串,把它放在循环条件里会让复杂度从O(n)变成O(n^2),数据量一大就超时。第三个是直接使用strcmp比较字符串时,一定确认两个字符串都已经以'\0'结尾,否则会越界读取随机内存,表现就是“时好时坏”,有时候AC有时候RE。
字符串题还有一个隐蔽陷阱:字符的大小写。题目要求“不区分大小写输出大写A”,但你却按ASCII码比较直接忽略了'a'的转换。做题时养成习惯,把所有涉及字母比较的地方,要么统一转成大写,要么统一转成小写,别在一个程序里混着来。
4.2 scanf、getchar、gets混用时的新行符陷阱
混合读数字和字符串是基础题的高频考点,也是WA重灾区。问题根源在于输入缓冲区:scanf("%d", &n)只读取整数,把后面的换行符留在缓冲区;接着执行gets(s)时,gets会把这个残留的换行符当成一整个空行读进去。结果是程序根本没读到真正的字符串,后续所有处理都建立在错误数据上。
解决办法很简单:在scanf("%d")之后加一句getchar(),把换行符吃掉。更稳妥的做法是统一用fgets处理整行输入,再用sscanf从字符串中解析数字。我个人更推荐后者,因为fgets读一整行,完美绕开了数字和字符串混用时的缓冲区问题。你在2572这类既要求读整数又要求读字符串的题上如果一直WA,先检查的应该是这里,而不是算法。
4.3 输出格式:空格、空行、换行一个都不能少
输出格式错误最常见的两种:一种是“行末多了一个空格”,另一种是“每组数据结束后少了一个空行”。前者通常发生在循环打印数组时,直接写成printf("%d ", a[i]),最后结果就是每个数的末尾都带空格,OJ会把最后一个空格算成错误字符。
正确的写法是:把第一个数单独打印,后面的数前面加空格。伪代码如下:
for (int i = 0; i < n; i++) { if (i > 0) printf(" "); printf("%d", a[i]); } printf("\n");后者则经常出现在“每组数据间空一行”的题目里。注意是“组间空行”,最后一组后面不能多空行。稳妥的办法是:第一组正常输出,从第二组开始,在输出前先打印一个换行。
4.4 数组越界只报RE不报WA
数组越界特别坑人,因为它的行为不可预测。有时候程序碰巧没报错,给出了看似正确的结果;有时候直接段错误,OJ报RE。问题的根源很简单:你在写一个越界位置时,把程序变量区的其他数据覆盖了。比如某个数组a[100]长度不够,你把数据写到了a[100]和a[101],恰好覆盖了另一个用来控制循环次数的变量n,于是n变成大数或者负数,程序表现完全失控。
排查越界问题时,我的土办法是把所有数组开大一点。全局数组定义成1000005这种“略大”的规模,而不是严格按题目最大值开。多出的几百字节在内存里不算什么,但能帮程序躲过很多越界风险。判断是不是越界,还有一个特征:数据量一大就RE,数据量小就正常。遇到这种规律,九成是数组开小了。
5. 常见问题排查:WA、PE、RE、TLE的定位思路
5.1 WA(答案错误):先查边界,再查算法
WA是OJ上最常见的反馈,也是最容易让人心态爆炸的。我的排查顺序固定不变:第一步,重新读题,确认是否漏了条件;第二步,核对数据范围,确认是否存在int溢出;第三步,检查所有变量是否在每组数据开始时都重新初始化;第四步,手工把样例跑一遍,对比中间变量;最后才考虑是不是算法思路有误。
初始化问题特别阴险。如果是多组输入,上一组数据残留的计数器、标志位、数组元素会影响下一组结果。做2708时我WA了很久,最后发现是一个统计变量在循环外定义,每次累加都没清零。这个错误的隐蔽之处在于,如果只跑一组样例,结果完全正确;一旦跑多组,第二组起就开始错。
5.2 PE(格式错误):差一个空格都算错
遇到PE,程序的核心逻辑多半没问题,纯粹是输出格式和标准答案不一致。定位方法是把程序输出保存到文件,再把标准样例保存到另一个文件,用diff命令逐字符对比,或者打开文本编辑器的“显示空格”功能。
以Linux下的对比为例:
./my_program < input.txt > my_output.txt diff -y my_output.txt sample_output.txtdiff会明确标出两行之间的差异点,多出来的空格、多出来的空行、缺少的换行都会显示出来。另一个高频PE来源是拼音输入法全角字符:题目要求输出英文冒号,结果你打成了中文冒号,肉眼完全看不出来,评测机却能识别。写输出字符串时,尽量直接从题目描述里复制,而不是手打。
5.3 RE(运行时错误):崩溃点怎么找
RE的常见原因按出现频率排序是:数组越界、除数为零、空指针访问、递归过深导致栈溢出。最简单的定位方法是用调试器运行程序,让它崩溃,然后查看调用栈。以GDB为例:
gdb ./my_program run < input.txt btbt命令会打印出现崩溃时的函数调用栈,直接告诉你崩在哪一行。如果不想用调试器,还有一个暴力但有效的办法:在代码里分段加printf("line %d\n", __LINE__);,跑一遍看看最后输出到哪个数字,崩溃点必然在那之后。基础题一般没有复杂的递归结构,RE排查花费的时间不应该超过五分钟。
5.4 TLE(超时):基础题也会有的性能问题
基础题很少会卡时间,但如果你用了过于低效的写法,照样会TLE。最常见的低效写法是:在循环内部反复调用strlen()来写循环条件,或者用三层循环解决本可以两层循环解决的问题。把输入规模代入复杂度公式估算一下:数据量是10^4,O(n^2)算法勉强能过;数据量到10^5,O(n^2)就危险了。
遇到TLE时,先别急着换算法,可以试试把cin/cout改成scanf/printf,输入输出缓冲区的开销有时候占掉一大半时间。如果改了输入输出还是超,再考虑优化循环体里的重复计算。我刷2721和2723时各碰到过一次TLE,最后定位原因都是小细节:一个是在循环里反复用strlen,另一个是用冗余的字符串拼接方式。
| 错误类型 | 最可能的原因 | 优先排查顺序 |
|---|---|---|
| WA | 漏条件、初始化缺失、溢出 | 读题 → 边界值 → 初始化 → 算法 |
| PE | 行末空格、空行数不对 | diff输出 → 全角字符 → 换行位置 |
| RE | 数组越界、除零 | 数组大小 → 除数为零 → 调试器 |
| TLE | 复杂度太高、输入输出太慢 | 复杂度估算 → scanf替换 → 优化循环 |
6. 刷完这13道题之后,下一步怎么走
6.1 按知识块补齐基础
基础题的价值在于暴露问题,而不是刷数量。如果你做完这批题发现字符串处理总是卡壳,那就集中做十道字符串专项题,把scanf、gets、fgets、sscanf、strtok这些函数的边界行为彻底搞清楚。如果数学模拟题总是推不出规律,就把等差数列、倍数关系、取模运算这些基础概念复习一遍。带着问题去刷下一批题,效率比漫无目的地按题号硬刷高得多。
刷完这批题后,我觉得最合理的下一步是按专题推进,推荐的顺序是:排序、二分查找、简单贪心、DFS、BFS、简单动态规划。这些专题和基础题之间是有衔接的:排序和二分用到的都是数组和循环,DFS和BFS则更考验递归和状态设计。不要急着去碰网络流、后缀数组这些高难度模块,基础不牢,后面的坑更大。
6.2 建立自己的错题复盘笔记
我从这批题开始养成了一个习惯:每做错一道题,就在笔记里记四条信息——题号、错误类型、出错原因、一句话总结。比如“2565,PE,行末多空格,以后打印数组用‘先首项后空格’模式”。这样的笔记记满一百条之后,你会惊喜地发现自己犯过的错误类型高度重复,真正让你翻车的永远就那么几个点。
复盘笔记还有一个用法:每次参加比赛或阶段自测前,把笔记里的错误类型翻一遍。这比临时翻阅教科书管用得多,因为错的题目是你自己亲手踩过的坑,记忆深刻。我自己的体会是,每次比赛前看一遍错题笔记,相当于给自己打了一针“避坑疫苗”,很多老毛病会在敲代码的瞬间被回忆唤醒,及时停手。
最后再分享一个小技巧
这批题刷下来,我最看重的一个收获并不是AC了多少道,而是“先造测试用例再动手写代码”这个习惯。以前我拿到题习惯直接开写,样例过了就以为自己赢了,提交后却被边界数据按在地上摩擦。现在不管写什么逻辑,只要题目可能被自动评测,我都会强制自己在草稿纸上列出至少三个测试用例:一个普通情况、一个最小值、一个极端情况,然后再开始写代码。一次花两分钟,却能省下后面反复提交排查的一小时。
如果你也正处于“刷题但总是不对”的阶段,别急着追求题量,先把这批基础题吃透。把每个WA都当成一次免费的debug训练,把每个PE都当成对输出格式的提醒。等你把最简单的题做到不再犯低级错误,再往更难的方向走,会顺很多。