刷算法题的人都知道,最打击自信心的往往不是那些需要想半天的难题,而是你一眼就觉得“这不就是基础题吗”的题目。我最近帮人复盘某校OJ基础4-6题,才发现这三道题比想象中更能暴露问题:样例能过,一提交就WA;本地跑得好好的,一换输入就出乱子;甚至有人因为多了一个空格,整道题判错。这篇文章就用我练习时选的三道经典基础题——素数输出、最大公约数与最小公倍数、字符分类统计——把完整的读题、编码、调试、复盘过程走一遍。不管你在哪个平台刷题,只要遇到类似编号的基础题,这套思路都能直接用。
1. 先把三道题翻译成“能直接写代码的话”
很多新手拿到基础4-6题,习惯性先打开编辑器,照着样例猜逻辑。这不是完全不行,但大概率会漏掉关键信息。我的习惯是先把题目要求转成三句非常朴素的话:输入是什么、处理什么、输出什么。这三句话写清楚了,后面写代码就只是填空。
1.1 基础题翻车,九成出在“题目读完就开始写”
你可能会问:基础题还能翻车?太能了。常见情况是样例输入只有一组,于是代码写死成单次处理;或者样例输出没有显示行尾空格,就以为空格无所谓;再或者题目说“输入一行字符”,你直接用scanf("%s")去读,结果只要字符串里有空格,后面全被截断。这些都不是算法问题,而是对输入输出的理解不够精确。
OJ的判题机是逐字符比较你的输出和标准输出,多一个空格、少一个换行,都可能被判定为Wrong Answer。所以拿到任何题目,第一件事永远是把输入输出的边界条件抠清楚,而不是着急写循环。
1.2 我练的基础4:输出2到n之间的所有素数
题目描述大概是这样的:输入一个正整数n(2 ≤ n ≤ 1000),输出从2到n之间的所有素数,每个素数占一行。样例输入是10,样例输出是:
2 3 5 7这道题考的是循环嵌套和素数判断。注意它要求“每个素数占一行”,意味着输出末尾是换行符,而不是空格或逗号。很多人喜欢把结果存到一个数组里再统一输出,这个思路没问题,但直接边判断边输出更省事,也能避免数组越界的问题。
1.3 我练的基础5:求最大公约数和最小公倍数
题目描述:输入两个正整数a和b(1 ≤ a, b ≤ 10^9),输出它们的最大公约数(gcd)和最小公倍数(lcm),两个数之间用一个空格分隔。样例输入是“12 8”,样例输出是“4 24”。
这道题表面考数学,实际考的是对辗转相除法的理解,以及对整型溢出问题的敏感度。后面我会专门讲为什么最小公倍数不能直接写成a * b / gcd(a, b),这里先记住:读入时一眼看到10^9,心里就该警醒——这个量级已经逼近32位整数的上限了。
1.4 我练的基础6:统计一行字符串的字符类别
题目描述:输入一行字符(可能包含空格),长度不超过1000,统计其中大写字母、小写字母、数字和其他字符的个数,按顺序输出四个整数,用空格分隔。样例输入是“Hello 123!”,样例输出是“1 4 3 2”。我们来验算一下:H是大写,ello是4个小写,123是3个数字,空格和感叹号都算“其他字符”,所以是2个。
这个统计逻辑本身很简单,难点在于“一行可能包含空格”。如果不知道这个前提,你用scanf("%s")读入,读到空格就停了,样例可能都过不了。这道题是后续文本处理类题目的雏形,非常值得认真对待。
2. 素数题:暴力判断其实完全够用,但边界必须想清楚
基础4的n最大只有1000,所以完全没有必要套复杂筛法,用二重循环暴力判断每个数是不是素数,计算量也就是几千次除法,对OJ来说几乎是零负担。
2.1 最直接的二重循环写法
#include <stdio.h> int main(void) { int n, i, j; int is_prime; scanf("%d", &n); for (i = 2; i <= n; i++) { is_prime = 1; for (j = 2; j * j <= i; j++) { if (i % j == 0) { is_prime = 0; break; } } if (is_prime) { printf("%d\n", i); } } return 0; }这段代码的逻辑是:外层循环枚举2到n的每个数,内层循环从2试到sqrt(i),一旦发现i能被某个数整除,就说明它不是素数,直接跳出内层循环。如果内层循环完整走完都没找到因子,is_prime保持为1,就输出。
2.2 为什么用j * j <= i而不是j <= sqrt(i)
这是我特别想强调的一个细节。用sqrt(i)再强转成int,虽然也能得到正确结果,但会有两个隐患:第一,sqrt需要引入math.h,编译链接也要加-lm,在某些在线评测环境里容易出幺蛾子;第二,浮点数存在精度误差,如果i恰好是某个完全平方数,sqrt(i)可能得到6.999999999,强转成int就是6,导致少检查一个因子。
j * j <= i用的是纯整数运算,既没有浮点转换,也没有精度问题,还顺带把内层循环次数压到平方根级别。这种写法的性能更好,代码也更干净。
2.3 如果n变大,该怎么办
基础4虽然只需要暴力,但不妨碍我们多想一步:如果哪天遇到n=1000000,二重循环就会超时。那时应该用埃氏筛,核心逻辑是先假设所有数都是素数,然后从2开始,把每个素数的倍数全部标记为合数。代码片段大概是这样的:
int is_prime[1000001]; for (i = 2; i * i <= n; i++) { if (!is_prime[i]) { for (j = i * i; j <= n; j += i) { is_prime[j] = 1; } } }这个写法的好处是时间复杂度接近O(n log log n),处理100万级别数据绰绰有余。但我不建议在基础题里一上来就写筛法,因为题目范围明确写着1000,暴力解法已经是标准答案,没必要为了“秀操作”引入额外复杂度。
2.4 提交时最容易错的:输出格式
素数题本身不难,很多人却挂在格式上。比如把样例输出误解成“2 3 5 7”这种空格分隔,代码写了printf("%d ", i),一提交就WA。再比如最后一行多输出一个换行,虽然很多判题系统不敏感,但严格模式下也会判错。
我的建议很简单:题目说“每个数占一行”,就老老实实写printf("%d\n", i);题目说“空格分隔”,就在每个输出后跟一个空格,或者用一个flag控制首元素前不加空格。格式这种东西,不要靠猜,直接看样例输出最稳妥。
3. 最大公约数题:三行核心代码背后的数学直觉
基础5这道题,难点不在代码,而在你信不信辗转相除法。很多同学背了公式会写,但一被问到“为什么这样循环就能得到gcd”就卡住。只背结论的人,遇到变种题容易懵。
3.1 辗转相除法为什么是对的
辗转相除法的原理是:对于两个整数a和b,gcd(a, b) = gcd(b, a % b)。也就是说,用a除以b得到余数r,然后原问题就变成求b和r的最大公约数。因为能同时整除a和b的数,一定也能整除a % b,所以这个转化不会改变公约数的集合。
举个例子,gcd(12, 8):12 % 8 = 4,于是问题变成gcd(8, 4);8 % 4 = 0,所以答案是4。整个过程中,两个数越来越小,直到余数为0,最后一次的非零除数就是最大公约数。这个过程用循环实现就是:
int gcd(int a, int b) { while (b) { int temp = b; b = a % b; a = temp; } return a; }递归写法更短:
int gcd(int a, int b) { return b ? gcd(b, a % b) : a; }两种写法背后是同一个原理。我一般推荐先用迭代版,因为递归版虽然简洁,但初学者容易搞混调用顺序,而且如果题目数据极端,递归层数可能会增加,虽然这里不会爆栈,但养成用迭代的习惯更稳。
3.2 最小公倍数必须“先除后乘”
求最小公倍数的公式是lcm = a * b / gcd(a, b),这没错,但在编程里要反过来写:lcm = a / gcd(a, b) * b。为什么?因为a * b可能会溢出。
我记得这道题给的范围是a和b最大到10^9,两个数相乘就是10^18,这已经超过32位int的21亿上限了,也超过很多OJ里int能表示的范围。即使你在代码里用了long long,也仅仅是在本题安全,换个更大范围的题目照样可能翻车。先除后乘的好处是先把结果缩小到不会溢出的范围,再做乘法。
3.3 完整代码,顺便处理多组输入
很多OJ的基础题不会只测一组数据,而是会在后台放很多组测试用例,按文件结束符EOF结束。如果代码只读一组就退出,只能过样例,后台数据可能全挂。所以读入部分建议写成while(scanf("%d%d", &a, &b) != EOF)。
#include <stdio.h> int gcd(int a, int b) { while (b) { int temp = b; b = a % b; a = temp; } return a; } int main(void) { long long a, b; while (scanf("%lld%lld", &a, &b) != EOF) { long long g = gcd(a, b); long long l = a / g * b; printf("%lld %lld\n", g, l); } return 0; }这里我把a和b都声明成long long,然后用%lld读入。有的同学图省事用int,在本题数据有10^9的情况下,虽然gcd本身不会溢出,但a * b会。直接用long long能从源头规避问题,成本几乎为零。
4. 字符统计题:缓冲区残留和getchar的经典大坑
基础6考察的是字符处理,很多初学者会在这里卡很久。表面上是统计字符分类,实际上暴露的是对输入缓冲区的不理解。
4.1 为什么不能用scanf("%s")读入
scanf("%s")遇到空白字符(空格、制表符、换行)就会停止读取。题目说“输入一行字符,可能包含空格”,如果用%s读,输入“Hello 123!”,实际读到的只有“Hello”,后面全部丢掉,统计结果必然错误。
正确的思路是逐字符读入,直到读到换行符为止。这里可以用getchar():
#include <stdio.h> int main(void) { int upper = 0, lower = 0, digit = 0, other = 0; int ch; while ((ch = getchar()) != EOF && ch != '\n') { if (ch >= 'A' && ch <= 'Z') { upper++; } else if (ch >= 'a' && ch <= 'z') { lower++; } else if (ch >= '0' && ch <= '9') { digit++; } else { other++; } } printf("%d %d %d %d\n", upper, lower, digit, other); return 0; }4.2 为什么必须用int ch而不是char ch
这是C语言里一个隐蔽的坑。getchar()的返回值类型是int,因为除了字符ASCII码之外,它还要能表示EOF,而EOF通常是-1。如果你把返回值直接塞给char,在部分平台上char是无符号类型,-1会被截断成255,导致ch != EOF的判断永远不成立,程序可能陷入死循环。
更常见的表现是:本地测试明明输出正确,一提交就超时或WA。排查半天,最后发现是char ch的问题。所以这类题统一用int ch接收getchar()的返回值,养成习惯就不会踩这个坑。
4.3 排查“样例过了但WA”的完整链路
我之前帮A同学看这道题时,他贴出代码后我先让他跑样例,结果本地输出完全正确。但OJ上就是WA。后来我们一行行检查,发现问题不在统计逻辑,而是在他这次提交的代码里,前面还有一个scanf("%d", &n)的读入操作。执行完scanf后,输入缓冲区里会残留一个换行符,紧接着调用getchar(),读到的第一内容就是这个残留的换行符,导致后面的字符串统计直接从第二行开始,数据全乱。
这个问题在OJ的字符串类题目里特别常见。题目有时候会先输入一个整数,再输入一行带空格的字符串,如果你直接用scanf读整数然后gets读字符串,必挂。解决办法有两种:一是在scanf之后用一个循环把缓冲区的换行符清空;二是改用fgets读整行,再从字符串里逐个字符处理。我建议初学者多熟悉fgets,因为它在处理“包含空格的整行输入”时更稳:
char line[1005]; fgets(line, sizeof(line), stdin);fgets会连换行符一起读进来,统计时要记得判断line[i] == '\n'时应略过,不过因为你只统计字符串的字符类型,换行符会被算进“其他字符”。如果题目明确说统计到回车为止,最好在读入后手动去掉末尾换行,或者循环时排除它。
4.4 字符判断用字符常量,不要用ASCII码数字
ch >= 65 && ch <= 90这种写法在功能上没问题,但可读性很差。写成ch >= 'A' && ch <= 'Z',一眼就能看出这是在判断大写字母,还能避免记错ASCII码。同理,数字判断写成ch >= '0' && ch <= '9'。这类基础题虽然短小,但代码风格会影响你调试的效率,干净清晰的判断条件能帮你减少很多不必要的分心。
5. 把基础题当“体检报告”:AC之后值得做的三件事
三道题都AC后,别急着关页面去刷下一批题。基础题最大的意义不是那几分钟的快感,而是像体检报告一样,告诉你哪些基本功还需要加固。我每次重做这类题,都会额外做三件事。
5.1 整理一份“防WA检查清单”
这比多刷十道题还管用。下面是我基于这三道题总结出来的自查表:
| 题目 | 最容易翻车点 | 我的检查方法 |
|---|---|---|
| 基础4 素数输出 | 输出分隔符和内循环边界 | 每行一个换行,用j * j <= i判断 |
| 基础5 gcd/lcm | 乘法溢出、多组输入 | 用long long,先除后乘,while(scanf...!=EOF) |
| 基础6 字符统计 | scanf残留换行符、getchar的EOF | 用int ch接收,必要时fgets读整行 |
这张表在后续做更复杂的题目时也可以复用。很多WA根本不是算法想错了,而是输入输出类型、溢出或缓冲区的问题,一份清单能让你快速锁定方向。
5.2 把函数封装成自己的“代码零件”
基础5里那种gcd函数,值得单独保存起来。后面刷题你会不停用到最大公约数:分数化简、同余运算、扩展欧几里得,甚至一些字符串题里也会间接用到。用一个文件专门收集这种短小而通用的函数,慢慢就会变成你自己的代码库,写题时直接复制过来,能省下大量调试时间。
5.3 往三个方向做思维延伸
三道基础题的背后,都有对应的进阶方向:素数题往上走,是埃氏筛、线性筛、区间筛,再到质因数分解;gcd/lcm往上走,是欧几里得算法的扩展,比如扩展欧几里得解不定方程;字符统计往上走,是词频统计、字符串哈希、文本处理。我不是让你现在就去学这些,而是在心里留个索引,之后碰到相关题目时,知道它们是从这些基础点长出来的。
最后再分享一个小习惯:每次提交通过后,把样例输入改几个边界值再跑一遍,比如n=2、n=1000、a=1、b=10^9、字符串全是空格等。基础题最大的陷阱不藏在算法里,而是藏在边界和格式里。把这一步做成了本能,后面刷复杂题时你会省掉大量因为低级错误带来的返工。