1. 先认识这道题
1.1 这是什么题,为什么值得停下来做一遍
PTA的习题6-7 简单计算器,是我见过的最容易让人“轻敌”的一道C语言题。题目要求用代码模拟一个最简单的四则运算计算器:输入一串没有空格的算式,遇到等号结束,运算数和结果都是整数,运算符包括加减乘除,没有优先级,一律从左到右算。比如输入1+2*10-10/2=,按普通数学规则要先算乘除再算加减,但本题要求按从左到右的顺序:1+2=3,3*10=30,30-10=20,20/2=10,最后输出10。如果除数为0,或者出现四则运算符和等号以外的字符,就输出ERROR。
很多同学看到这道题的第一反应是“这不就是循环读入吗”,结果一提交,不是运行时错误,就是答案错误。原因其实不是语法不会,而是对“优先级相同”这个条件没当回事,以及漏掉了除零和非法字符这两种边界。这道题真正的价值,不是让你学会写一个计算器,而是逼你养成“先把规则拆成边界条件,再动手写代码”的习惯。后面不管是做PTA的字符串逆序、二分查找函数,还是天梯赛L2那些看起来更高级的题,这个习惯都会反复救你。
很多学习者在刚接触这类题时,习惯直接照着样例去猜代码结构,然后提交试错。我的建议是别急着开敲。习题6-7虽然只是入门难度,但它把C语言里几个最容易出错的地方都揉在了一起:字符输入、循环控制、除零保护、非法输入处理、精确输出。任何一个环节没想清楚,都可能让测试点红成一片。把它耐心吃透,比稀里糊涂刷十道题都有用。
1.2 “优先级相同”四个字改变了什么
题目明确写了“四种运算符的优先级相同”,这相当于把小学里“先乘除后加减”的规则彻底废掉。用一个生活化的例子来说,这道题里的计算器不是计算器,而是一个只会从左往右读、读到什么算什么的孩子。它不知道*和/更优先,只知道当前结果和下一个数之间是什么符号,就立刻执行。理解这一点,整道题的算法结构就清晰了:用一个变量保存当前结果,每读到一个运算符和操作数,就立即更新当前结果。
如果把“优先级相同”忽略掉,按照通常的中缀表达式求值思路去写,样例输出就会变成16,和题目要求的10完全对不上。这种错误在本地很难察觉,因为代码本身能编译、能运行,甚至你还会觉得自己“思路很完整”。但在PTA评测机看来,规则已经被你擅自改写了,答案自然不对。所以这道题给所有人的第一课就是:题目描述里每一个限定词都不是废话,尤其“相同”这种词,往往才是真正的考点。
顺着这个思路往下走,你会发现整道题不需要复杂的算法结构。不需要栈,不需要递归,不需要表达式树,甚至不需要把整个字符串保存在数组里。它只需要一个循环,和一个不断被更新、最终被输出的整数。这也是出题人希望你看到的:把问题读透之后,代码可以非常简单。
2. 从读题到思路:把规则拆成状态流
2.1 输入格式里的隐藏条件
题目输入描述一般会写:在一行中给出一个四则运算算式,没有空格,且至少有一个操作数,遇等号“=”说明输入结束。这句话信息量很大。“没有空格”意味着不需要处理分隔符,可以直接用scanf("%c")或getchar()逐字符读取,读到的要么是数字、要么是运算符、要么是等号。如果输入里有空格,我们反而要额外写跳过空格的逻辑,那就又是另一道题了。
“至少有一个操作数”说明算式不会以等号开头,也不会一开始就出现运算符。这意味着第一件要做的事情非常明确:先读一个整数,把它作为计算的初始结果。有的同学上来就打算用scanf("%d%c%d%c...")一串格式串去匹配,这是给自己找不自在。因为表达式长度不固定,你根本不知道循环要跑多少轮。更干净的做法是:先初始化第一个操作数,然后把剩下的“运算符+操作数”当作一组一组的切片,循环处理到等号为止。
还有一层隐藏条件容易被忽略:等号只负责结束标识,不参与计算。也就是说,读到等号的那一刻,程序应当直接输出当前结果并退出,而不是还要再去读一个操作数。很多代码错误地认为“题目说遇等号结束,那我遇到等号后再读一个整数不就行了”,结果等号后面的回车或者其他内容被卷进读取流程,导致输出ERROR。这个问题在我带过的学生里反复出现,后面第4章会专门讲怎么避免。
2.2 把表达式看成“数-运算符-数-运算符”的流水线
一个最简单的四则运算表达式,本质上是一条流水线:第一个数作为起点,后面交替出现运算符和数字,最后用等号收尾。用状态的话来说,就是“初始整数→运算符→整数→运算符→整数……→等号”。第一个整数和后续整数之间的差别在于:第一个整数直接成为结果的初值,后续整数必须被运算符“绑定”在一起处理。
对应到程序结构上,就是先scanf("%d", &result);读入第一个整数,然后用一个死循环反复执行三件事:读一个字符,判断是不是等号;如果不是等号,判断是不是合法运算符;如果是合法运算符,再读一个整数,执行运算。这个顺序不能乱。如果先读操作数再读运算符,你就会发现第一个操作数没有运算符可用,不得不多加一个变量去保存上一个运算符,代码逻辑立刻变得别扭。
这种“先初始化第一个值,再用循环吞掉后续所有项”的模式,在C语言里非常常见。累加求和、累乘求积、连续字符串拼接,都是这个套路。很多同学在习题6-7里第一次真正理解了这个结构,之后遇到类似题目会顺手很多。所以这道题虽然叫“简单计算器”,但它教的是程序设计的通用骨架。
2.3 动手之前先列边界清单
我在做PTA题之前,习惯先把自己能想到的边界写下来。这道题至少要考虑:只有一个操作数就遇到等号,比如5=,应该输出5;除数为0,比如1/0=,应该输出ERROR;出现非法字符,比如1&2=或1+*2=,应该输出ERROR;除法是整数除法,5/2=结果应为2,而不是2.5;连续运算的结果可能为正、负、零,C语言的整数除法向零取整,比如-3/2=结果是-1。
把边界列全,写代码时就不会东一块西一块地打补丁。很多同学提交后看到“答案错误”,第一反应是去改输出格式,其实边界没处理才是主因。尤其是“非法运算符”这个条件,题目说“如果出现非法运算符就输出ERROR”,不少人理解为“只要不是四个运算符就报错”,但实际代码里却把非法字符跳过,继续读下一个,甚至把非法字符当作下一次操作数的一部分,结果自然错。
边界清单不是为了吓唬人,而是为了让你在写代码的时候心里有数。评测机非常“无情”,它会专门准备那些你没想到的输入来攻击你。与其在提交后靠一次次猜测去试错,不如在动手前把清单列清楚。这个习惯一旦养成,后面做任何题目都会轻松很多。
3. 手写实现:一份能直接跑通的参考代码
3.1 完整代码与逐段拆解
下面这份代码,是我在实际调试中整理出来的版本。它既能正确处理合法表达式,也能在除零和非法字符出现时稳定输出ERROR。
#include <stdio.h> int main(void) { int result, num; char op; if (scanf("%d", &result) != 1) { printf("ERROR\n"); return 0; } while (1) { if (scanf("%c", &op) != 1) { printf("ERROR\n"); return 0; } if (op == '=') { break; } if (op == '+' || op == '-' || op == '*' || op == '/') { if (scanf("%d", &num) != 1) { printf("ERROR\n"); return 0; } switch (op) { case '+': result += num; break; case '-': result -= num; break; case '*': result *= num; break; case '/': if (num == 0) { printf("ERROR\n"); return 0; } result /= num; break; } } else { printf("ERROR\n"); return 0; } } printf("%d\n", result); return 0; }第一段scanf("%d", &result)读入初始操作数。有人会问,PTA的输入格式是合法的,为什么还要检查返回值?因为一旦输入不是整数,程序继续往下跑就会使用一个未初始化的变量,后面的行为完全不可预期。检查返回值是一个好习惯,后面每次读操作数时同样检查。
接下来是循环里的字符读取。每次只读一个字符,读到的可能是+、-、*、/、=,也可能是非法字符。先判断是不是等号,是等号就break。为什么不等读完后面的回车再处理?因为题目明确说遇等号结束,等号之后的内容不属于表达式的一部分。如果break之后再读,很可能把输入缓冲区里的\n当成非法字符,输出错误的ERROR。
读到合法运算符后再读操作数。这里要注意顺序:先判断运算符合法,再scanf("%d", &num)。这样做能捕捉1+*2=这类非法输入。因为读到+之后,按正常格式应该读一个数字,但下一个字符是*,scanf发现它不是数字,返回0,所以进入错误处理分支。如果反过来先读数字,或者不检查scanf返回值,num就会保留上一个值,计算结果莫名其妙出错。
除零检查放在case '/'里面,当num == 0时直接输出ERROR并结束程序。这一步必须在执行result /= num之前完成,因为整数除以0会让程序崩溃。这种崩溃不是普通的答案错误,而是运行时错误,测试点直接没有分。
整个代码的思路其实很简单:一个结果变量,一个循环,按题目规则从左到右更新结果。没有栈,没有复杂的表达式解析,也没有必要把整行输入先存起来。你甚至可以把它看作一个带错误检查的累加器。
3.2 从多个if到switch:代码风格也是分水岭
第一版代码用多个if也完全可行,但四个运算符的分支用switch更清楚。switch把每一种运算单独放在一个case里,阅读时一眼就能看到每种运算符对应什么行为。以后如果题目加一个取余运算符%,只需要加一个case,不需要改动外层判断逻辑。
有些同学可能会想用函数指针数组或查表法,让代码看起来更高端。但作为练习题,这不是必需的。PTA只认结果,不认代码风格,但对于学习者来说,代码风格会影响你对程序的理解成本。四个分支的switch是“刚刚好”的复杂度,再复杂就是过度设计。写题不是做架构,能用最直白的方式表达逻辑,本身就是一种能力。
我见过有人把所有运算写成一个长长的if-else if链,也能过,但阅读体验很差,而且很容易把==写成=,或者漏一个分支的break。相比之下,switch的结构更紧凑,分支关系也更明确。虽然这不是AC和WA的区别,但“能跑”和“好读”之间,我建议你从一开始就选择后者。
3.3 为什么计算逻辑可以这么“直白”
因为题目把优先级砍掉了,所以不需要两个栈,不需要把中缀表达式转后缀,也不需要递归下降。每读到一次运算符,就立刻把当前结果和下一个操作数作用一次。本质上就是在一个累加器上不断做加减乘除,从左到右一路滚到底。
这种“直白”恰恰是出题人的意图。它想让你看到,一个看似完整的四则运算计算器,在“所有运算符优先级相同”的约束下,可以简单到只需要一个循环。如果你非要用中缀转后缀的通用方法去实现,最后反而会因为规则不同而出错。做PTA题,最重要的能力之一就是识别题目给出的约束,并用最贴合约束的方法去解,而不是把教科书上的通用方案硬套进来。
代码写到这里,逻辑已经完整。接下来要做的,就是把容易出错的细节挨个核一遍。很多人在这一步草草提交,结果被测试点教育。所以下一章我会把我在实际调试中遇到的坑,一个一个列出来。
4. 常见错误与排查实录
4.1 输出格式是第一个“隐形杀手”
PTA对输出的匹配是逐字符精确匹配的。错误信息必须是大写ERROR,后面跟一个换行。有人写成Error、error、ERR,或者输出成ERROR!,都会挂。样例输出10后面也有换行,虽然有些系统对行末空格不敏感,但换行通常必须存在。最安全的做法是统一使用printf("ERROR\n");,不要自作聪明加空格或感叹号。
还有一个很隐蔽的问题:有人用printf("ERROR")忘记加换行。在某些评测环境下,没有换行会被判错,因为输出需要用换行标记一行结束。所以写完代码后,建议把所有输出语句检查一遍,确认末尾都有\n。这个细节不磕碜,PTA上很多“测试点0答案错误”其实就是输出格式问题引起的。
另外要注意的是,程序结束前只能输出一条结果或一个ERROR,不能中途中重复打印。比如在除零分支里输出一次ERROR后,又因为循环没有退出,后面再次输出一个结果。这样输出就变成了两行,评测机判断结果时自然匹配不上。正确做法是输出ERROR后立刻return 0,结束整个程序。
4.2 除零测试点:为什么是运行时错误
我见过不少同学本地只跑样例,样例里没有除零,于是提交后收到“运行时错误”。原因很简单,输入1/0=时,程序执行result /= num,num是0,整数除以0在C语言中属于未定义行为,大多数运行环境直接触发异常,程序崩溃,PTA返回运行时错误,整个测试点零分。所以写完代码,一定要自己造一组除零输入测一下。
除零的判断位置也有讲究。有的同学在读取运算符后统一判断if (op == '/' && num == 0),这个思路没问题,但前提是num已经被正确读入。如果把判断放在读取num之前,num还是旧值,等于白判断。我在参考代码里把判断放在case '/'里面,和除法运算写在一起,逻辑最紧凑,也最不容易漏。
还有一个容易忽略的点:题目要求“除数为0”输出ERROR,但没有说“除法的结果必须是什么类型”。因为运算数和结果都是整数,所以5/2的结果是2,-3/2的结果是-1。这是C语言整数除法的正常行为,不需要你做任何额外处理。但如果你把它当成浮点数运算,写在代码里就会越改越乱。
4.3 非法字符:不能忽略,也不能吞掉
题目里说的“非法运算符”,包括所有不在+ - * / =集合里的字符。比如&、!、a、.,甚至连续两个运算符1++2=里的第二个+,都属于非法输入。遇到这些情况,程序必须输出ERROR并退出,而不能继续计算。
最容易出现的错误行为有两种。第一种是“忽略非法字符”:读到&之后不报错,而是继续读下一个字符,最后输出一个看似正常的数字。第二种是“吞掉非法字符”:把*当成数字的一部分,或者把字母当成操作数的一部分,最后得到一个根本不合逻辑的结果。这两种都会让你在非法字符测试点上丢分。
为了避免踩坑,代码里的判断顺序很关键:每读一个字符,先判断是不是=,再判断是不是四个合法运算符之一,两者都不是就立刻进入错误分支。不要试图在非法字符出现时用continue跳到下一轮循环。题目要求的就是遇到非法字符立即终止,而不是忽略后继续算。
4.4 一份可以反复使用的本地测试清单
调试这道题,不要只跑题目样例。我建议至少准备以下几组输入,分别验证不同场景。
| 测试输入 | 期望输出 | 验证点 |
|---|---|---|
5= | 5 | 只有一个操作数 |
1+2= | 3 | 基础加法 |
1/0= | ERROR | 除数为0 |
1+*2= | ERROR | 非法运算符/操作数读取失败 |
10-5/2= | 2 | 从左到右+整数除法 |
1+2*10-10/2= | 10 | 题目样例 |
把这些输入保存到文本文件里,用重定向方式运行程序:./calc < test.txt。第一次跑通后,再逐组确认输出是否符合预期。凡是涉及字符读取的题目,本地多测几组边界输入,比直接在OJ上反复提交试错效率高得多。
还有一个小技巧:可以在代码里临时加一些打印语句,观察每次读到的op和num是什么。比如在读到运算符后打印op,在读到操作数后打印num,这样能快速定位是读取顺序错了,还是判断条件错了。排查完记得把临时打印删掉,否则提交时会因为多余输出被判错。
5. 从这道题延伸出去的功力
5.1 它和PTA题库里其它题的共同点
做过习题6-7后,再去做PTA上的字符串逆序、模式匹配、二分查找函数等题目,你会发现一个共性:它们表面考语法或算法,实际在考验“读入边界处理”和“输出细节”的严谨度。字符串逆序要处理空格和换行;模式匹配要注意字符串末尾的\0;二分查找函数要考虑找不到时该返回什么。这些都和简单计算器中“读到等号结束”“读到非法字符报错”是同一种思维模式。
特别是字符读取这一块,scanf和getchar混用时的缓冲区问题,几乎贯穿所有PTA基础题。很多人在简单计算器这里踩过回车当字符的坑后,再去写需要逐字符处理的题,就会下意识先想清楚“下一次读取会不会读到我以为的那个字符”。这个意识,比会写某个函数重要得多。
天梯赛L2的题看起来复杂,但基础题练就的边界感同样适用。比如有的L2题要求输出占位符、处理空行、避免多余空格,如果你在PTA习题阶段就把这些习惯养成了,后面会省很多提交次数。简单计算器只是起点,不是终点。
5.2 如果题目升级成带优先级,该怎么办
很多同学做完这题会想:如果运算符有优先级怎么办?这里快速给一个思路,不展开太多。最经典的办法是用两个栈:一个存放操作数,一个存放运算符。从左到右扫描表达式,遇到数字入操作数栈,遇到运算符则根据优先级决定是直接入栈,还是先把栈顶运算符拿出来算一次。扫描结束后,把栈里剩余的运算符依次弹出计算。这个过程叫调度场算法,也就是中缀表达式求值的常见思路。
但我必须提醒一句:不要为了展示自己会栈,就把习题6-7做成带优先级的版本。题目明确规定优先级相同,你按带优先级做,输出结果就错。写PTA题,严格遵循题目描述是第一原则,而不是按“更通用”的思路去实现。扩展练习可以放到课后,提交前必须切回题目的规则。
这道题也让我想到一个学习建议:同一个问题,可以先做简单版本,再做升级版本。比如先写一个优先级相同的计算器,再用栈去实现一个真正的四则运算计算器。两个版本对照着写,你才能真正体会到“题目约束对算法选择的影响”。这种对比学习,比单纯刷题更有效率。
5.3 这道题真正教会我的调试习惯
在做这道题的过程中,我建议你至少完成三件事:第一,在纸上手动执行一遍样例,算出期望结果;第二,写代码时把每个分支都列出来,尤其注意除零和非法字符;第三,提交前先用本地测试清单跑一遍。这三件事看似繁琐,但能把一道水题变成训练工具。
我自己带学生做这道题时,最常说的一句话是:“不要急着写代码,先把如果我是评测机会怎么出题想一遍。”评测机一定会出正常样例、边界数、除零、非法字符。你把这些都想清楚,代码结构自然会朝着正确方向走。等到写代码时,你会发现自己在用条件判断处理边界,而不是靠直觉碰运气。这就是从“学过C语言”到“会写C语言”的分水岭。
最后再分享一个实际体会:习题6-7简单计算器是我在PTA上见过的最“亲民”的题,但它每年都会让一批人卡住。卡住的原因往往不是不会写循环,而是没有把题目描述里的每一个字翻译成代码分支。如果你也被这道题折磨过,放下“这题太简单”的念头,静下心把边界列一遍,重写一次代码,你会发现自己对C语言输入输出的理解上了一个台阶。