很多练C语言的同学都绕不过某高校SSE在线练习平台这道坎,尤其是C语言部分的编程练习38,江湖人称“看起来五分钟能写完,提交三次不一定过”。题目本身一句话:输入一行字符,分别统计英文字母、数字、空格和其他字符的个数。一开始我是不屑的,这种题不就是if-else连环套吗?结果真在平台上交了三次,WA了三次,最后才发现坑全在输入输出细节上。这篇文章不教你怎么背答案,而是把我从第一次WA到最终AC的完整心路、代码演进和排查逻辑都摆出来,给正在刷这个系列的人一个可复现的参考。
校名和平台全称我就不点名了,懂的人自然知道是哪套题,平台大家习惯叫SSE,练习编号到38,这个位置意味着你不是在学语法,而是开始接触真正的字符串处理和输入输出边界问题。这篇文章适合两类人:一类是正卡在这道题上反复提交不过的初学者,另一类是C语言基础还行、但想看看字符统计类题目还能挖出多少细节的进阶读者。下面直接开始。
1. 练习38到底在考什么:它不是一个if-else题
1.1 题目复述与第一版直觉代码
题目要求很直白:输入一行字符,分别统计其中英文字母、数字、空格和其他字符的个数。我当时的反应跟你现在可能一样——这不就是逐个字符读,然后分类计数吗?三分钟写出的第一版大概是这个模样:
#include <stdio.h> int main() { char ch; int letters = 0, digits = 0, spaces = 0, others = 0; while ((ch = getchar()) != '\n') { if ((ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z')) { letters++; } else if (ch >= '0' && ch <= '9') { digits++; } else if (ch == ' ') { spaces++; } else { others++; } } printf("字母=%d 数字=%d 空格=%d 其他=%d\n", letters, digits, spaces, others); return 0; }这段代码在本地跑,输入“Hello World! 2024”,输出“字母=10 数字=4 空格=1 其他=1”,看起来一点毛病没有。我信心满满地提交,结果WA。当时第一反应是平台出问题了,后来才发现问题出得相当隐蔽。
1.2 为什么这道题会卡住前面37题都顺利的人
SSE这套练习的前面37题,大多是单点考察:这道题考if-else,那道题考for循环,再来一道考数组下标。到了练习38,第一次要求你把字符数组、ASCII判断、输入函数选择、循环终止条件、输出格式控制这些知识点串起来用。说白了,这是一道综合题,检验的是“理解”而不是“记忆”。
很多同学前面顺风顺水,是因为每道题都能从课本上找到几乎一样的例题。但字符统计这个题有个特点:书上的例题默认用getchar配合'\n'判断结束,而平台测试数据可能是通过文件重定向输入的,数据流末尾不一定有换行符;书上的例题输出格式可能跟你手里这道题的格式要求有细微差别。这些差异平时不被注意,一旦进了OJ系统,全都变成WA。
我后来复盘时才想明白,这题真正筛掉的人,不是不会写if的人,而是没搞懂“输入函数到底从哪里读数据、读到什么位置算结束”的人。
1.3 OJ评分机制:本地跑通不等于平台过
在线判题系统的工作方式,跟我一开始以为的“看结果对不对”完全不一样。它实际做的是:把你编出来的可执行文件跑起来,把预置的输入文件喂给stdin,然后把程序输出的stdout内容跟标准答案文件逐字节比对。多一个空格、少一个换行、输出顺序不对,全算Wrong Answer,连“格式错误”的提示都不会给你。
这意味着两件事。第一,printf里的格式串必须跟题目描述一字不差,题目要求逗号分隔你就别用空格,要求每个结果占一行你就别挤在一行里。第二,平台不会只跑题目样例那一组数据,它背后可能还有五六组隐藏测试,专门测空行、纯空格、超长行这些边界。你拿本地样例测过就以为万事大吉,恰恰是练习38最容易翻车的地方。
2. 四种输入方式的对比:我为什么弃用getchar和gets
2.1 getchar逐字符:顺手但容易忽略EOF和残留换行
第一版直觉代码用的是getchar。这个方案不是不能用,但有两个细节必须处理。第一个是循环终止条件。在终端里手动输入时,你敲完回车,getchar读到'\n',循环正常结束,一切都很美好。可如果测试数据被重定向成文件输入,最后一行的末尾不一定有换行符,这时候循环会一直读下去,直到碰到EOF。正确的写法应该把条件改成:
while ((ch = getchar()) != '\n' && ch != EOF)第二个细节是ch的类型。getchar的返回值是int,不是char。因为EOF被定义为-1,如果char在某个平台上是无符号类型,那ch永远不可能是-1,EOF判断就会失效。这也是很多人在OJ上莫名其妙死循环的原因之一。
另外还有一个经典场景:如果前面某道题让你先用scanf("%d", &n)读了个数字,缓存区里会残留一个换行符,下一个getchar会先把这个'\n'读走,导致统计结果什么都不对。处理办法是在scanf之后手动加一个getchar()吸收掉。这些都是练习38不会明说、但实际跑平台一定会遇到的东西。
2.2 scanf读字符串:从根上读不了空格
第一次WA之后,我怀疑是getchar的EOF判断有问题,于是换了第二种思路:用scanf一次性读入一整行。
char str[100]; scanf("%s", str);结果更惨。scanf的%s从输入里读取时,遇到空格、Tab、换行就会停下。题目说的是“一行字符”,这一行里面完全可能包含空格——比如“Hello World! 2024”,scanf只会读到“Hello”,后面的“World! 2024”全留在缓冲区里。最终输出里字母数变成5,空格数永远是0,题目给的样例都不可能过。
这里要说明白,scanf本身不是不能用,而是要用对场景。它适合按单词、按数字去读结构化输入,比如“先读一个整数再读一个整数”,但绝对不适合读“包含空格的一整行”。这个选择错误,正是我第二次WA的直接原因。
2.3 gets和fgets:一字之差,边界天壤之别
既然scanf不行,我改用了gets。gets确实能读整行,而且会自动把末尾的换行符丢掉,本地跑起来效果完美。但gets有一个臭名昭著的问题:完全不检查缓冲区长度。
char str[100]; gets(str); // 如果输入超过99个字符,直接越界写内存如果输入数据是一行超过100个字符的长文本,gets会把多出来的内容一路写到str数组后面,覆盖栈上其他变量的内存。轻则程序崩溃,重则产生随机错误结果。这个函数正因为这个安全缺陷,在C11标准里被正式移除了。练习38的数据量可能不会触发越界,但作为一个养成阶段,不应该用已经被标准淘汰的写法。
替代方案是fgets:
char str[1024]; fgets(str, sizeof(str), stdin);fgets最多读sizeof(str) - 1个字符,并且会把换行符保留在数组里。这一点跟gets刚好相反,也是后面我踩第二次坑的根源。但它提供了边界保护,你可以明确知道这次最多读了多少数据。
四种输入方式的特征放在一起看就很清楚:
| 输入方式 | 读取单位 | 能否读空格 | 是否保留换行 | 边界安全 |
|---|---|---|---|---|
| getchar | 单字符 | 能 | 读入后需判断 | 安全 |
| scanf("%s") | 单词 | 不能 | 不保留 | 不限制宽度,隐患大 |
| gets | 整行 | 能 | 自动丢弃 | 严重,无边界检查 |
| fgets | 整行 | 能 | 保留 | 安全,可控读取长度 |
3. 最终提交版代码逐行拆解
3.1 完整的可提交代码
经过两次WA,我最终提交且AC的版本是这样:
#include <stdio.h> #include <string.h> int main() { char str[1024]; int letters = 0, digits = 0, spaces = 0, others = 0; if (fgets(str, sizeof(str), stdin) == NULL) { return 1; } size_t len = strlen(str); if (len > 0 && str[len - 1] == '\n') { str[len - 1] = '\0'; len--; } for (size_t i = 0; i < len; i++) { char ch = str[i]; if ((ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z')) { letters++; } else if (ch >= '0' && ch <= '9') { digits++; } else if (ch == ' ') { spaces++; } else { others++; } } printf("字母=%d 数字=%d 空格=%d 其他=%d\n", letters, digits, spaces, others); return 0; }这段代码没有用任何高深技巧,核心就两个:fgets读整行,然后手动处理末尾换行。如果你用的平台编译器比较老,只支持C89,那需要把size_t相关的写法换掉,把变量声明都挪到函数开头,for循环里的下标用int。这一点建议看看SSE平台的编译器版本再定。
3.2 为什么是fgets加手动去换行
fgets会把换行符一起读进str数组。比如输入“Hello”,str里实际存储的是‘H’‘e’‘l’‘l’‘o’‘\n’‘\0’。如果不处理,循环遍历到'\n'时,它不属于字母、数字、空格,最终会被统计到“其他字符”里,其他计数直接多1。
解决办法就是读完之后先量一下长度,看看最后一个字符是不是换行,是的话就把它抹掉,长度同步减一。这看起来是微不足道的一步,但正好是OJ题“隐藏边界”最喜欢埋伏的地方。我第二次WA就死在这里。
顺带说一句,strlen返回的是size_t类型,本质上是无符号整数。如果数组里根本没有'\0',strlen会一直往后找,直到碰见一个随机字节的0为止,这就不是你想要的字符串长度了。fgets保证在读到指定长度或换行后一定会补'\0',所以strlen在这里是安全的。
3.3 判定条件该用字符常量还是ASCII码
字母判断我用的是(ch >= 'A' && ch <= 'Z')这个写法,而不是(ch >= 65 && ch <= 90)。两者在ASCII平台上效果相同,但可读性差异很大。写代码的第一读者是两个月后的你自己,看到'A'你知道是字符A,看到65你得去查ASCII表。
另外一个相关细节是顺序。判断数字时我用的是ch >= '0' && ch <= '9',这里单引号是必须的。如果你写ch >= 0,那会把所有ASCII码不小于0的字符都算成数字,包括字母和标点,统计结果直接爆炸。这类错误在OJ题里很常见,因为编译器不会把它当语法错误,运行时才会暴露。
3.4 中文全角字符带来的坑
练习38的测试数据大概率是纯英文和数字,但我在本地手贱试过一个中文输入“你好abc 123”,结果others变成了4而不是2。原因是char在多数平台上是有符号类型,中文字符的UTF-8编码字节高位都是1,转换成char之后就成了负数。我的判断条件都是针对正数ASCII范围写的,这些负数字节自然全部掉进else分支。
如果题目要求你统计中文字符,或者你的生产代码要处理用户输入的中文,那就得把ch声明成unsigned char,或者干脆用宽字符函数。但练习38没必要过度设计,知道这个坑存在就够了。我提它是因为“为什么ch明明看起来是中文字符,却会被归为其他”——这个问题将来你迟早会遇到。
4. 三次WA的完整排查链路:从现象到根因
4.1 第一次WA:空格全部消失,怀疑scanf
第一次提交的是scanf("%s")版本,输出里空格数永远是0,字母数明显偏少。排查方法其实很简单:在本地程序里加上一行调试输出,把读取到的字符串原样打印一遍,马上就能看到scanf只读到了第一个空格前面的片段。
这里有一个通用经验:OJ题出错后,不要盯着统计结果猜,而是先确认“程序到底读到了什么”。你可以临时printf("%s", str)看一下,或者用printf("{%s}", str)把字符串包在花括号里,这样连首尾空格都能看出来。调试完记得把调试语句删掉再提交,不然输出格式又不匹配了。
4.2 第二次WA:其他字符多一个,锁定换行符
第二次我换成gets,本地一切正常,提交照样WA。这次不能再靠瞎猜了,我把测试数据原样写进一个文本文件,用文件重定向的方式跑程序,然后把输出重定向到另一个文件,用十六进制工具看输出内容。这一看就发现了问题:others比其他版本多1。
原因就是gets把换行符丢弃了,而fgets没有。我把代码换成fgets后,忘记处理末尾的'\n',这个换行符被算进了others。本地终端里跑的时候,你敲完回车,屏幕上的输入和输出混在一起,根本注意不到那个看不见的换行。但OJ比对的是精确字节流,多一个1就是WA。
解决办法前面代码里已经写了:len = strlen(str),如果str[len - 1] == '\n',就把它置为'\0'并让len减一。这个操作之后,循环遍历的就是真正有效的字符序列。
4.3 第三次WA:偶尔正确偶尔错,查出数组越界
修完换行问题后,我以为稳了,提交后还是WA。这次更诡异,我在本地跑了好几个用例全对,平台就是不给过。后来我开始怀疑是不是循环边界问题,检查发现有一版代码我写的是for (i = 0; i <= strlen(str); i++),多了一个等号。
strlen("abc")返回3,字符串下标0到3分别是'a'、'b'、'c'、'\0',循环条件i <= 3意味着'\0'也会被遍历到。'\0'的ASCII码是0,不在字母、数字、空格的任何一个范围内,于是被算进了others。本地数据如果规模小,结果可能碰巧看着正常;平台隐藏测试一多,每次答案都差一个数字,表现就是“时对时错”。
这类边界条件问题是最坑的,因为它不会稳定复现。排查方法是用一个最小用例“abc”进去,手动算一遍期望结果,再跟程序输出对照。如果others恒为1,十有八九是你把字符串结束符也算进去了。
4.4 最终AC版本与检查清单
最终把循环条件改成i < len后,提交立刻AC。从第一次WA到AC,总共花了差不多一个下午,回头看每一步都是低级错误,但每一步都对应一个非常具体的知识点:
| 现象 | 可能根因 | 检查手段 |
|---|---|---|
| 空格数恒为0 | 用了scanf读字符串 | 打印读取内容确认 |
| others多1 | fgets保留了换行符 | 检查str的末尾字符 |
| 答案时对时错 | 循环把'\0'也算进去了 | 用最小用例核对 |
| 输出格式不对 | printf格式串与题目不符 | 原样比对样例输出 |
5. 这道题之外,我养成的三个习惯
5.1 用ctype字符分类函数替代手写范围判断
练习38之后,我再写字符分类类的代码,优先考虑ctype.h里的isalpha、isdigit、isspace这些函数,而不是自己手写ASCII范围判断:
#include <ctype.h> if (isalpha((unsigned char)ch)) { letters++; } else if (isdigit((unsigned char)ch)) { digits++; } else if (isspace((unsigned char)ch)) { spaces++; } else { others++; }注意参数要转成unsigned char,这是C标准里的明确要求,因为ctype函数接收的虽然是int,但只支持EOF或者能表示为unsigned char的值。直接传一个char进去,如果char是有符号类型,某些平台上会出问题。这个细节跟前面中文字符的坑是同一类问题。
库函数的好处是语义清晰、可移植性强、不会因为平台字符集差异而出错。练习38里用if也能过,但养成了手写范围的习惯,以后处理更复杂的字符分类时容易埋雷。
5.2 输入函数的安全性,从练习就开始在意
gets为什么被C11移除,不是学术问题,是血泪教训。缓冲区溢出攻击在C语言历史上造成过大量严重漏洞,源头往往就是一个不检查长度的字符串读取。练习38让我第一次正面面对这个选择:用gets确实方便,但方便背后是未定义行为。
fgets读整行安全,但它会把换行符留在缓冲区,这个行为跟gets相反。已经习惯gets的人第一次用fgets都会踩坑。我的建议是:读整行就用fgets,读格式化数据就用scanf配合宽度限制,比如scanf("%99s", str),不要裸写scanf("%s", str)。这个习惯越早建立越省事。
5.3 提交前的五组最小测试用例
最后一次AC之前,我整理了一个测试清单,以后每次做字符处理题都会跑一遍:
| 输入 | 期望letters | 期望digits | 期望spaces | 期望others |
|---|---|---|---|---|
| 空行(直接回车) | 0 | 0 | 0 | 0 |
| Hello World! 2024 | 10 | 4 | 1 | 1 |
| 三个连续空格 | 0 | 0 | 3 | 0 |
| 12345 | 0 | 5 | 0 | 0 |
| a\tb(中间是Tab) | 2 | 0 | 0 | 1 |
这里特别提醒空行测试。直接输入回车,fgets读到的只有'\n',去掉换行后len变成0,others应该是0。如果你写的是if (ch != '\n')这样的循环,空行可能会被当成一个其他字符,结果就错了。
最后分享一个我在SSE平台上养成的习惯:每次提交前,把题目给的示例输入原样存成文本文件,程序跑完后把输出复制出来,用文件比对工具跟示例输出逐字节核对,确认一模一样再点提交。练习38我绕了三次WA,但通过之后,后面所有字符处理类题目基本都是一次过。个人体会是,OJ题目的价值不在AC本身,而在它逼你把输入输出边界想透。后来我做文件解析、协议处理,很多线上Bug都是同一个思路排查出来的——先确认读到什么,再怀疑边界条件。希望这篇文章能帮你少踩一次换行符的坑,也希望你练完这道题,不只是拿到了一个AC,而是真的开始用工程的眼光看C代码。