每年讲到第三章的选择结构,我的班级群就会准时炸开锅。不是没人写得出代码,而是每个人都写出了一版“看似对、一跑就错”的代码。尤其是教材3.2节那三道编程题——在不少C语言入门课本里,它们被排成“分支结构小节练习”,题目本身一句话就能读完,但交上来的作业里能一遍跑通的不到三分之一。
这三道题分别是闰年判断、出租车分段计费、三个整数排序。听着都眼熟吧?很多高校的基础编程题库里都有它们的变体,比如“小玉买文具”“分段函数求值”都是同一个套路。今天我就把这三道题从头到尾拆一遍,从错误写法到正确写法,从运算符优先级到边界值测试,把我这几年批作业时攒下的翻车案例全摆出来。无论你是在校学生、自学编程的初学者,还是偶尔需要带新人的老手,这篇文章都值得你花十分钟慢慢读。
1. 这三道题为什么是新手的分水岭
1.1 三道题的本来面目
先说清楚题目,我用的是最经典的一版,C语言描述,其他语言思路一样:
- 题目一:输入一个年份,判断它是否为闰年。闰年条件是“能被4整除但不能被100整除,或者能被400整除”。
- 题目二:某市出租车计价规则为:起步价8元,包含3公里;超过3公里但不超过10公里的部分,每公里1.6元;超过10公里的部分,每公里2.4元;每趟另加1元燃油附加费。输入行驶里程,输出总费用。
- 题目三:输入三个整数,按从小到大的顺序输出。
这三道题没有任何算法难度,没有数组,没有循环,没有函数。它们唯一用到的语法就是if、else if、else、关系运算符、逻辑运算符,外加一个用来交换两个变量的临时变量。可恰恰是这些最基础的语法细节,把初学者按在地上摩擦。
1.2 真正的考点不在“会不会”而在“熟不熟”
我带过的学生里,能把if语句语法背得滚瓜烂熟的人很多,但一写这三道题就露馅。问题出在哪?
第一,他们对运算符优先级只有“大概印象”,没有精确记忆。&&和||谁先谁后?%和==谁先谁后?括号该加还是不该加?这些判断慢半拍,代码就写不对。
第二,他们对边界值没有敏感度。3公里整算哪一档?10公里整算哪一档?年份等于0怎么办?这些细节教材上不会专门讲,只能靠实战摔出来。
第三,他们不习惯做“验证”。写完了,输入一个正常值,输出对了,就觉得自己会了。很少有人会专门拿边界值、特殊值去测试自己的程序。
所以我说这三道题是分水岭——跨过去,你对选择结构的理解就真正落地了;跨不过去,后面学到循环、数组、函数时,每节课都会因为这里埋下的坑而反复踩雷。
2. 第一道:闰年判断——短路求值与优先级先后
2.1 题目与第一版常见错误
先看题目要求的完整条件:能被4整除但不能被100整除,或者能被400整除。用数学语言拆一下就两句话:
- 条件A:能被4整除 且 不能被100整除
- 条件B:能被400整除
- 最终结果:条件A 或 条件B
很多新生的第一版代码长这样:
#include <stdio.h> int main(void) { int year; scanf("%d", &year); if (year % 4 == 0 && year % 100 != 0 || year % 400 == 0) printf("%d是闰年\n", year); else printf("%d不是闰年\n", year); return 0; }你把这代码跑一遍,2000年、2024年、1900年,结果居然全对。于是学生很高兴地交上来,我却在旁边看得后背发凉。
为什么?因为这行if条件根本没加括号。它之所以能“碰巧”跑对,完全依赖于C语言中&&优先级高于||这个规则——条件被解析成(year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)。如果把题目改一下,或者换一种写法,这个不假思索的“碰巧”下一秒就会翻车。
2.2 运算符优先级是最大的坑
闰年判断这个题目,真正想考的就是你把条件和运算符搅在一起时,还能不能保持头脑清醒。
先列一下这题涉及的运算符优先级,从高到低:
| 运算符 | 含义 | 优先级 |
|---|---|---|
! | 逻辑非 | 最高 |
*/% | 算术运算符 | 次高 |
+- | 算术运算符 | 再次 |
<<=>>= | 关系运算符 | 中 |
==!= | 等于/不等于 | 较低 |
&& | 逻辑与 | 更低 |
|| | 逻辑或 | 最低 |
也就是说,year % 4 == 0会先算year % 4,再比较== 0,这个没问题。&&和||混用时,&&先结合,这也是对的。真正怕的是学生的脑子没跟上这个顺序,以为自己写的是(year % 4 == 0 && year % 100 != 0) || (year % 400 == 0),实际编译器也是这么理解的,但换个上下文,比如把两个条件互换位置,或者误写一个&,结果就完全不是那么回事了。
经验之谈:只要&&和||同时出现,就老老实实加括号。不是C语言不允许不加括号,而是不加括号的代码,读代码的人(包括三天后的你自己)要在脑子里做一次运算符优先级分析,而人脑做这种分析是最容易出错的。代码是写给人读的,顺便给机器执行。
2.3 正确写法与验证用例
我给学生的参考写法是这样:
#include <stdio.h> int main(void) { int year; scanf("%d", &year); if ((year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)) printf("%d是闰年\n", year); else printf("%d不是闰年\n", year); return 0; }括号一加,逻辑一目了然。条件部分的意思和题目要求完全一一对应:左边括号是“能被4整除但不能被100整除”,右边括号是“能被400整除”,两者是或的关系。
写完这道题,我还要求学生在本地跑一组验证数据:
| 输入年份 | 期望结果 | 说明 |
|---|---|---|
| 2000 | 闰年 | 能被400整除 |
| 2024 | 闰年 | 能被4整除且不能被100整除 |
| 1900 | 不是闰年 | 能被4整除但也能被100整除,且不能被400整除 |
| 2023 | 不是闰年 | 普通年份 |
| 4 | 闰年 | 小年份边界 |
这组用例覆盖了闰年公式里的所有分支。如果只看一个2000年就交作业,等于只验证了一个||后面的分支,前面的&&分支到底写没写对,完全不知道。
还有一个进阶问题值得想一想:如果年份是0,程序会输出什么?闰年的定义是对公历年份而言的,0年、负数年份在历史上没有意义,但程序不会管这些。所以严谨一点,可以在条件里加上year > 0的校验,不过入门阶段不强求,知道有这回事就行。
3. 第二道:出租车分段计费——边界值和浮点精度决定成败
3.1 题目说明与计费逻辑拆解
第二道题是分段计费,这类题目在编程题里出镜率极高,变体包括水电费阶梯计价、快递运费、商场打折。核心都是同一个:根据输入落在哪个区间,应用不同的计算公式。
先把题目规则翻译成数学分段函数:
- 当里程 d <= 3 时:费用 = 8 + 1 = 9 元
- 当 3 < d <= 10 时:费用 = 8 + (d - 3) * 1.6 + 1
- 当 d > 10 时:费用 = 8 + (10 - 3) * 1.6 + (d - 10) * 2.4 + 1
注意,起步价8元包含的是“3公里以内”,所以3公里整的时候仍然是9元,跟2.5公里一样,绝不能给3公里整算成8 + 0 * 1.6 + 1再来一遍——虽然结果一样,但逻辑上是错的。然后把燃料附加费这1元放到最后加,或者放进起步价里变成9元,都可以,只要全程保持一致。
3.2 四个高频翻车现场
这个题目我在作业里见过的高频错误,列出来给各位避雷。
错误一:等于号边界丢失。有人写if (d <= 3),没问题;但紧接着写else if (d > 3 && d < 10),这里把10公里整漏掉了。输入10的时候,既不进第一档,也不进第二档,直接掉进最后的else,按超过10公里计费,结果多收了一档差价。
错误二:区间条件写反或重复。有人写else if (d >= 3 && d <= 10),看起来没问题,但前提是前面的if必须是d < 3而不是d <= 3,否则3公里整会匹配到第一个分支,后面的>= 3分支永远没机会执行。这类错误隐蔽就隐蔽在,程序不报错,输入小数字时结果也对,只有当你测试恰好等于3的数字时才会发现。
错误三:浮点直接比较相等。里程是里程表读出来的,计算机里以double存储。3.0这个值在浮点数里并不是精确的3.0,而是多少带一点误差。如果你写出if (d == 3.0)这样的判断,最后能不能进这个分支完全看运气。分段计费场景下不要用==去比较浮点数,用区间判断,比如d <= 3.0。
错误四:燃油附加费忘加,或者加错位置。这个属于粗心。但更有意思的是,有人把1元加到每一档的公式里,结果超过10公里的部分多加了两次。
3.3 正确实现与测试用例表
这是我的参考写法:
#include <stdio.h> int main(void) { double d; double cost; printf("请输入行驶里程(公里): "); scanf("%lf", &d); if (d <= 3) { cost = 8 + 1; } else if (d <= 10) { cost = 8 + (d - 3) * 1.6 + 1; } else { cost = 8 + (10 - 3) * 1.6 + (d - 10) * 2.4 + 1; } printf("总费用: %.2f 元\n", cost); return 0; }注意这里第二档的边界条件我写的是d <= 10,而没有写d > 3 && d <= 10。因为能走到else if这一步,说明d > 3已经隐含成立了。这就是多分支结构的顺序优势——前面的条件把后面的条件简化掉了。写else if (d <= 10),可读性更好,也不容易写错。
测试用例我建议至少跑这五组:
| 输入里程 | 期望费用 | 测试目的 |
|---|---|---|
| 2 | 9.00 | 起步价内 |
| 3 | 9.00 | 恰好起步价边界 |
| 10 | 20.20 | 第二档右边界 |
| 10.5 | 21.40 | 刚进入第三档 |
| 0 | 9.00 | 等价于起步价 |
为什么10公里整是20.20?因为8 + (10 - 3) * 1.6 + 1 = 8 + 11.2 + 1 = 20.2。如果程序输出21.4,说明10公里整被错误地算进了第三档。
我还见过有人把double和printf("%.2f")配合出错——输入时用%lf,输出时用%.2f,这没问题,但有人输入用%d读里程,读进去的全是垃圾值。真遇到这种问题,先检查scanf的格式控制符和变量类型是否匹配,通常能解决一半的玄学Bug。
提示:分段的题目不要在一开始就把所有公式写完再判断。先判断区间,再写公式,顺序别乱。如果后面想改成“超过15公里每公里3元”,只需要改第三档公式和第二个
else if的边界,其余代码不用动。
4. 第三道:三个数排序——交换法的直觉与其边界
4.1 题目、通行错误与一个直觉陷阱
第三道题是“输入三个整数,按从小到大输出”。很多学生的第一反应是两两比较:
if (a < b && b < c) printf("%d %d %d\n", a, b, c); else if (a < c && c < b) printf("%d %d %d\n", a, c, b); // ... 还有四个分支这是一种写法,但极其繁琐,而且极容易漏分支。三个数的排列一共有6种,你得写6个else if,每个里面还要把三个数按照对应顺序打印出来。写的时候脑子稍微短路一下,就漏掉一种情况。程序跑起来通常不会报错,只是某些输入下什么都不输出——因为6种排列没有覆盖完整。
比这更常见的错误是“想当然交换”:
if (a > b) { a = b; b = a; }你以为这是在交换,实际上执行完a = b之后,原来的a值已经被覆盖了,再执行b = a时,b拿到的是a被覆盖后的值(也就是原来的b)。两个变量变成了同一个值,原来的a彻底丢了。这是交换变量最经典的翻车现场。
4.2 手动跟踪一遍排序过程
先看正确解法——比较交换法,核心思想是“让最小值依次沉淀到最前面”。
int a, b, c, t; if (a > b) { t = a; a = b; b = t; } if (a > c) { t = a; a = c; c = t; } if (b > c) { t = b; b = c; c = t; } printf("%d %d %d\n", a, b, c);为什么这样三次比较就能完成排序?我拿3 1 2手动走一遍:
- 初始 a=3, b=1, c=2
- 第一轮
if (a > b):3 > 1,交换,得到 a=1, b=3。现在a是前两个数里较小的那个。 - 第二轮
if (a > c):1 > 2 不成立,不交换。a保持1。现在a是三个数里最小的。 - 第三轮
if (b > c):3 > 2,交换,得到 b=2, c=3。b比c小。 - 输出 1 2 3。
关键套路是:第一轮保证a是a和b中的较小者,第二轮保证a是三者中的最小者,第三轮保证b和c有序。三轮做完,全局有序。
很多学生犯迷糊的地方在于:第一轮之后a和b已经变了,第二轮拿新的a去和c比较,会不会有问题?不会,这正是思路的精妙之处。你只需要记住一句话:每一轮比较都让当前“最左边的位置”变成剩余数字里的最小值。
4.3 三种解法横向对比与扩展建议
除了比较交换法,还有另外两种常见方案:
方案一:枚举所有排列(前面提到的6个else if分支)。优点是逻辑直白,缺点是代码量大、容易漏分支,而且一旦改成4个数排序就彻底爆炸。
方案二:两两比较交换法(上面推荐的)。优点是对三个数来说最简洁,也可以推广到更多个数——想排四个数,就再加一轮if (c > d)之类的比较,逻辑模式完全一样。缺点是需要临时变量,初学者容易在交换步骤上栽跟头。
方案三:用库函数(如C语言里qsort、Python里sorted)。优点是杀鸡用牛刀,绝对正确;缺点是如果这题的教学目标是练习if分支,用库函数等于跳过了练习目标。入门阶段不建议。
我的建议很明确:写排序题,能不用库函数就尽量不用。不是库函数不好,而是排序过程中的交换、比较、覆盖这些都是理解变量和内存的基础动作,亲手拆一遍和自己调sort函数,得到的经验完全不是一个量级。
另外提一句,这三个数的类型是整数。如果把题目改成三个浮点数,代码逻辑一模一样,但scanf里的%lf、printf里的%f千万别写成%d,否则又是经典的“输入一堆乱码、输出一堆鬼”现场。
5. 三道题刷完,我写给学生们的自检测试
5.1 三道题共同暴露的编程习惯问题
每年批完这三道题,我都会发现一个规律:能一次写对的学生,不是因为天赋,而是因为他们有以下几个习惯——而这些习惯跟语法知识本身没多大关系。
第一个习惯:写代码之前先在草稿纸上画清楚逻辑。特别是分段计费这种题,先画数轴,标出3和10两个分界点,再写上每一段对应的公式。画完再写代码,出错的概率下降一半。
第二个习惯:写完以后不急着跑“正常值”,而先跑“边界值”。边界值就是分界点本身:闰年题里的1900和2000,计费题里的3和10,排序题里相等的数和负数。只要边界值对了,正常值基本不会出错。
第三个习惯:把“能编译”和“正确”分开看。很多学生一看到编译通过就长舒一口气,以为万事大吉。其实编译通过只是开始,逻辑对不对完全是另一回事。C语言里很多逻辑错误不报错,比如漏分支、覆盖变量、区间漏掉等号,这些编译器通通不管,只能靠测试用例来暴露。
5.2 一个随手可用的“边界值清单元”
综合这三道题,我整理了一个通用的测试清单,不限于这题,任何分段、判断、排序类题目都能沿用:
| 测试类型 | 测试值示例 | 要观察什么 |
|---|---|---|
| 最小值边界 | 年份0、里程0、排序中的负数 | 程序会不会崩,逻辑是否合理 |
| 区间左端点 | 里程3公里整 | 该进上一档还是这一档 |
| 区间右端点 | 里程10公里整 | 会不会漏进下一档 |
| 区间内普通值 | 里程5公里 | 公式本身是否正确 |
| 跨越区间的值 | 里程10.01 | 第三档是否正常接棒 |
| 相等值排序 | 两个或三个数相等 | 排序是否会重复输出或丢失数据 |
| 逆序数据 | 排序输入3 2 1 | 三轮交换是否全部触发 |
这个清单非常朴素,但每次检查都能救回一两道题。养成习惯之后,写任何程序都会顺手过一遍边界值,出错率肉眼可见地下降。
5.3 个人教学经验的补充
最后分享一点我自己的体会。有不少学生来问我:“这三道题这么基础,是不是会了就行,不用太较真?”我的回答通常是:基础题不要求快,要求稳。这三道题虽然简单,却涵盖了选择结构里最容易踩的所有坑。能把它们写得每一步都经得起追问,后面学到循环嵌套、函数递归时,你才不会一边写一边怀疑自己是不是哪里又漏了边界。
如果你现在还处于刚学编程的阶段,我建议你把这3道题用自己的语言重新实现一遍,不要看任何参考答案。写完之后,再对着上面的测试用例逐条验证。等你能做到“不加括号就知道优先级”“看到浮点数就想边界”“排序前先想到临时变量”,你这关就算真正过了。
这三道题并不难,难的是你愿不愿意在简单的东西上多花一点笨功夫。编程这个手艺,本质上就是靠这种扎实的小练习一点一点堆出来的。