news 2026/10/15 23:47:03

C语言OJ字符统计题三次WA复盘:输入输出边界与fgets陷阱全解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
C语言OJ字符统计题三次WA复盘:输入输出边界与fgets陷阱全解析

很多练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多1fgets保留了换行符检查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
空行(直接回车)0000
Hello World! 202410411
三个连续空格0030
123450500
a\tb(中间是Tab)2001

这里特别提醒空行测试。直接输入回车,fgets读到的只有'\n',去掉换行后len变成0,others应该是0。如果你写的是if (ch != '\n')这样的循环,空行可能会被当成一个其他字符,结果就错了。

最后分享一个我在SSE平台上养成的习惯:每次提交前,把题目给的示例输入原样存成文本文件,程序跑完后把输出复制出来,用文件比对工具跟示例输出逐字节核对,确认一模一样再点提交。练习38我绕了三次WA,但通过之后,后面所有字符处理类题目基本都是一次过。个人体会是,OJ题目的价值不在AC本身,而在它逼你把输入输出边界想透。后来我做文件解析、协议处理,很多线上Bug都是同一个思路排查出来的——先确认读到什么,再怀疑边界条件。希望这篇文章能帮你少踩一次换行符的坑,也希望你练完这道题,不只是拿到了一个AC,而是真的开始用工程的眼光看C代码。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/15 23:47:03

SpringBoot+Vue产业园区智慧公寓管理系统架构与实践解析

搞产业园区智慧公寓管理系统这套源码的人&#xff0c;十个里有八个是冲着SpringBootVue这个组合来的&#xff0c;但真正把项目跑起来之后&#xff0c;能把它当成“能用的产品”去改的&#xff0c;可能连一半都没有。这倒不是代码多难写&#xff0c;而是这类系统跟普通出租屋管理…

作者头像 李华
网站建设 2026/10/15 23:47:02

PyTorch深度学习实战:从动态图原理到生产部署的避坑指南

1. 为什么“pytorch”这个标题值得单独拎出来聊如果你在技术社区里闲逛&#xff0c;看到有人只甩出一个词——“pytorch”&#xff0c;然后底下跟了一堆讨论&#xff0c;千万别觉得这是水帖。这个词背后藏着的&#xff0c;是一整套从研究原型到生产部署的完整技术栈&#xff0c…

作者头像 李华
网站建设 2026/10/15 23:46:52

CCNA必学:交换机MAC地址静态绑定与端口安全实战指南

1. 从一个真实故障说起&#xff1a;为什么MAC地址绑定不是选修课刚入行那会儿&#xff0c;我在一个中小型企业的网络改造项目里踩过一个经典的坑。当时办公区一台接入层交换机下面挂了二十多台设备&#xff0c;某天开始间歇性断网&#xff0c;用户反馈时好时坏&#xff0c;重启…

作者头像 李华
网站建设 2026/10/15 23:46:30

安全帽检测实战:YOLOv4场景化优化与工业部署指南

1. 项目概述&#xff1a;为什么安全帽检测不是“调个YOLO就能用”的事安全帽佩戴检测&#xff0c;听起来就是个典型的工业视觉入门题——拿YOLOv4跑个检测&#xff0c;标注几百张图&#xff0c;训上几小时&#xff0c;模型输出个框加个标签&#xff0c;完事。但我在某制造园区做…

作者头像 李华
网站建设 2026/10/15 23:46:25

Wiener随机过程与Matlab在锂电池剩余寿命预测中的工程实践

做电池健康管理这些年&#xff0c;我接触过不少寿命预测模型&#xff0c;最后真正落在工程上顺手、可解释、还能给出置信区间的方案&#xff0c;反而是最早接触的Wiener随机过程模型。最近把整套流程用Matlab重新整理了一遍&#xff0c;配合锂电池容量衰减数据做剩余寿命&#…

作者头像 李华
网站建设 2026/10/15 23:46:06

滚动时域最优视角规划:面向真实场景的3D探索决策系统

1. 这不是“找角度”的玄学&#xff0c;而是一套可计算、可落地的3D空间探索决策系统你有没有试过让一个机器人在陌生的地下管道里自主前进&#xff1f;它面前不是一条笔直的走廊&#xff0c;而是不断分叉、塌陷、堆满杂物的三维迷宫。激光雷达扫出一片点云&#xff0c;但只覆盖…

作者头像 李华