很多人刷洛谷的时候会有个误区,觉得“语言月赛”的题一定很简单,随便写写就能过。实际上我见过不少新手在 B3657 这种题上反复 WA 的,不是不会写输入输出,而是被题面里那个“看起来很简单”的计费规则绕进去了。尤其是刚开始学 C++ 的同学,一到条件分支和格式化输出就容易翻车。这篇文章就把 [洛谷] B3657 [语言月赛202209] 公园门票 这道题掰开揉碎讲清楚,包括题意拆解、常见写法对比、提交时容易踩的坑,以及从这道题延伸出去的刷题思路。每个部分都会讲清楚“为什么”,而不是只丢一份代码让你抄。
1. 题目到底在考什么:语言月赛题没有那么“白给”
1.1 语言月赛的定位与 B3657 的难度坐标
语言月赛,顾名思义考的是“语言”本身,不是算法思维。它和 CSP-J 第二轮那种需要建图、推状态转移的题完全不是一个层次,主要就是检验你能不能把“日常生活中的规则”翻译成 C++ 代码。B3657 这道题在语言月赛里属于比较典型的“条件判断 + 简单计算”题目,难度定位大概在入门到普及- 之间。
很多人觉得这种题没营养,其实恰恰相反。语法题最考验的不是你会不会背语法,而是你能不能严谨地把题目的每一个条件都翻译进去,不漏不重。一个判断条件漏了,就是一次 WA;一个输出格式写错,又是一次 WA。B3657 就是这种“你不仔细读题,代码写得再漂亮也白搭”的题目。
1.2 题目大意拆解(据原题意整理)
这道题的背景是公园门票计费,结合洛谷语言月赛的出题风格,题意可以整理成下面这样:
某公园的门票收费标准为:成人票每人 x 元,儿童票每人 y 元,其中 x > y。现在有一批游客前来游玩,其中有 a 名成人、b 名儿童。如果这批游客的总人数不少于 20 人,则可以享受总票价 95 折的团体优惠。请输出这批游客需要支付的总金额,保留两位小数。
输入格式一般是第二行给出四个整数或者说按照题面规定的顺序给出 a、b、x、y,输出格式要求输出总金额,保留两位小数。
这里面真正容易让人犹豫的点有两个:第一,优惠条件判断的是“总人数”而不是“成人人数”或“儿童人数”;第二,打折是在“总价”上打折,不是只给成人票打折,也不是只给儿童票打折。这两个点只要错一个,答案就会偏。
1.3 为什么这种题反而容易丢分
我帮别人 debug 过不少这种入门题,发现一个很有意思的规律:越是觉得自己“已经会了”的同学,越容易在这种题上丢分。新手反而老老实实读题、一步一步算,正确率还高一些。原因很简单,老手容易陷入“惯性思维”,看到门票两个字就觉得是按人头乘单价,忘了看优惠规则;或者看到保留两位小数就直接 printf("%.2f"),却忘了判断输出类型到底对不对。
所以这篇文章我不会直接甩一个最终代码就完事,而是带着你从题意到变量设计、从三种不同写法到踩坑记录,完整走一遍。你把这套思路理清了,以后再遇到语言月赛的 B 题、C 题,心里就有底了。
2. 从“人工算账”到“代码算账”:核心逻辑翻译过程
2.1 先把买票的过程在脑子里过一遍
如果你是售票员,面对这一批游客,你会怎么算钱?我猜是这样的流程:
- 先数一数一共有多少游客,也就是成人人数加上儿童人数。
- 判断一下总人数有没有达到团体票门槛。
- 算出原价:成人票总价加上儿童票总价。
- 如果达到门槛,把原价乘以折扣;否则不乘。
- 收钱、出票。
这个流程翻译成 C++,就是一个“顺序结构 + 一个 if 判断”的事。很多初学者的问题在于,他们总想着“先判断再计算”,结果把顺序搞乱。实际上你完全可以直接先算总人数、先算原始总价,然后再判断要不要打折。因为打折这个操作不会反过来影响“总人数是否达标”这个判断,两个步骤是独立的。
2.2 变量设计:int 还是 double?
接下来聊变量类型。这道题的输入 a、b、x、y 按常规设定都是整数。那么问题来了:存成人人数、儿童人数、成人票价、儿童票价,用 int 还是用别的?
我建议人数和票价都用 int 存。理由有两个:
- 题目输入的本来就是整数,没有小数,用 int 最自然。
- 后续计算总价时,哪怕乘法结果比较大,在 int 范围内也基本够用(后面我会专门讲数据范围和溢出问题)。
但“总价”这个变量建议用 double 来存。因为一旦打折,比如原价 321 元打 95 折,结果是 304.95,这就出现小数了。如果你用一个 int 变量去接收这个结果,小数点后面的部分会被直接截断,答案就错了。
核心计算就两行:
int n = a + b; // 总人数 double total = a * x + b * y; // 原始总价然后判断:
if (n >= 20) { total = total * 0.95; }这段代码的思路非常清晰,变量名也一目了然。就算你把这段代码拿给一个完全没学过编程的人看,他也能猜出大概是什么意思。这就是好的入门代码的标准——逻辑清晰比代码短更重要。
3. 三种写法,从最直观到最精简
3.1 写法一:最朴素的 if 分支
先看最常规的写法:
#include <cstdio> int main() { int a, b, x, y; scanf("%d %d %d %d", &a, &b, &x, &y); int cnt = a + b; double total = a * x + b * y; if (cnt >= 20) { total *= 0.95; } printf("%.2f\n", total); return 0; }这段代码在洛谷上跑是没问题的。它好在哪里?好在你闭上眼睛都能读懂:先输入,再算人数,再算总价,再判断打折。结构是线性的,没有任何多余的花活。
那它有没有缺点?如果你非要说缺点,就是用了scanf和printf,有些刚学 C++ 的同学更习惯cin和cout。这个没关系,两种都能过。只不过用cout的话,保留两位小数要额外加#include <iomanip>并且写fixed << setprecision(2),相对繁琐一点,我后面会具体讲。
3.2 写法二:三目运算符压缩
有些同学喜欢把简单的 if 压成三目运算符,觉得这样代码行数少。确实,这里是这么写的:
#include <cstdio> int main() { int a, b, x, y; scanf("%d %d %d %d", &a, &b, &x, &y); int cnt = a + b; double total = a * x + b * y; total = (cnt >= 20) ? total * 0.95 : total; printf("%.2f\n", total); return 0; }三目运算符的语义是:如果括号里的条件成立,就取问号后面的表达式的值,否则取冒号后面的值。这段代码在功能上和写法一没有任何区别。
但我建议初学者不要一上来就追求这种写法。不是说三目运算符不好,而是当条件复杂的时候,比如有多个折扣档位的时候,三目运算符嵌套起来阅读体验会非常差,debug 的时候也很痛苦。入门阶段先把 if 用熟练,再学三目运算符的压缩技巧,顺序不要反。
3.3 写法三:把折扣逻辑封装成函数
这种方法对这道题来说有一点“小题大做”,但对于养成好的代码习惯是有帮助的。你可以把“计算折扣后的价格”单独抽成一个函数:
#include <cstdio> double calcTotal(int adultCnt, int childCnt, int adultPrice, int childPrice) { double total = adultCnt * adultPrice + childCnt * childPrice; int cnt = adultCnt + childCnt; if (cnt >= 20) { total *= 0.95; } return total; } int main() { int a, b, x, y; scanf("%d %d %d %d", &a, &b, &x, &y); double ans = calcTotal(a, b, x, y); printf("%.2f\n", ans); return 0; }这么做有什么好处?好处在于,你把“业务逻辑”和“输入输出”彻底分开了。以后如果题目要求把折扣改成满 10 人减 10 元,或者改成阶梯折扣,你只需要改calcTotal函数内部,主函数一行都不用动。这在复杂的工程里是很重要的思维习惯。
不过我也要实话实说,入门题这么写确实显得冗余,比赛的时候时间紧张,不太值得。我在这里提出来,是想让你知道:一道题不止一种写法,选择哪种取决于场景。日常练习建议多试试函数封装,比赛就怎么稳怎么来。
3.4 三种写法对比
| 写法 | 可读性 | 代码量 | 推荐指数 | 适用场景 |
|---|---|---|---|---|
| 朴素 if | 高 | 少 | 五颗星 | 大多数比赛场景,首选 |
| 三目运算符 | 中 | 最少 | 三颗星 | 条件简单时可用,条件多别用 |
| 函数封装 | 最高 | 多 | 四颗星 | 日常练习、逻辑复杂时使用 |
4. 提交之后我才发现的四个坑:精度、溢出、格式与读题
4.1 浮点数精度与保留两位小数
这道题要求保留两位小数,这就涉及一个新手很容易困惑的问题:printf("%.2f", total)到底做了什么?
它做的是“四舍五入”还是“直接截断”?答案是:printf对浮点数做保留小数位操作时,是按四舍五入的规则处理的。比如 304.955 会输出 304.96(编译器不同可能有细微差别,但整体是四舍五入方向)。
如果你用的是cout,写法是:
#include <iostream> #include <iomanip> using namespace std; int main() { int a, b, x, y; cin >> a >> b >> x >> y; double total = a * x + b * y; if (a + b >= 20) total *= 0.95; cout << fixed << setprecision(2) << total << endl; return 0; }fixed表示使用固定小数位数模式,setprecision(2)表示保留两位小数。这两个要搭配使用才能达到和printf("%.2f")一样的效果。如果不写fixed,setprecision(2)表示保留两位有效数字,对于 100 这样的数,输出会变成1e+02,直接暴雷。
4.2 乘法溢出:真的会发生在入门题里吗
洛谷的题目虽然简单,但数据范围该开的还是会开。假设 a、b、x、y 都是十万级别的数字,a * x就是 10^10,这已经超出了 32 位 int 能表示的范围(约 21.47 亿)。此时再把它赋给 double,虽然 double 范围大,但乘法这一瞬间发生在 int 环境下,已经溢出了。
所以稳妥的做法是:在做乘法之前把其中一个因子强转成long long或double:
double total = 1LL * a * x + 1LL * b * y;或者干脆在定义时就把票价、人数都定义为long long。虽然这道题的数据范围未必会卡这个点,但养成“乘法前先想想会不会爆”的习惯,对以后做算法题非常有用。CSP-J 的第一题里就有不少人是栽在 int 溢出上的。
4.3 输出格式的隐藏细节
再强调一遍输出格式,因为它真的能让人血压升高。有些题目要求输出“保留两位小数”,但有些题目要求“如果结果是整数,就输出整数;如果有小数,保留两位”之类的花式输出。B3657 按原题要求是保留两位小数,那就不管结果是 100 还是 100.5,都要输出100.00或100.50。
这种“宁可多输出两个 0”的题目,你完全不用考虑“能不能把多余的 0 去掉”,因为题目按格式判分,多一个 0 不少见,少一个 0 就是 WA。
另外要注意:用printf输出 double 时,要写%f而不是%lf。虽然在 printf 中%lf和%f混用在大多数编译器里结果一样,但在 scanf 中,double 必须用%lf。如果你写反了,在有些编译器上是能跑的,有些则直接 UB。为了不出岔子,我一般约定:输入用 %lf,输出用 %f。
4.4 重新读题:有没有多组数据?
还有一个重要问题:B3657 这个题是一次性输入一组数据,还是多组数据直到 EOF?两种题目的代码框架完全不一样。
如果是多组数据,你就要用:
while (scanf("%d %d %d %d", &a, &b, &x, &y) != EOF) { // 处理并输出 }如果是单组数据,上面这种 while 语法也能跑,但如果有输出格式要求,比如每组输出后换行,那还好。可如果题目要求最后一行不加多余换行,就需要额外处理。
我在提交之前会养成的习惯是:先把题目输入描述从头读两遍,确认输入数据组数再写输入代码。这比写完再 debug 省太多时间了。不同的题读入框架不同,有的是while(cin >> a >> b),有的是单次读入。B3657 这道题按原题属于一次输入,不需要 while 循环,但也有同学多加了一层循环导致输出完全对不上,这种失误完全没有必要。
5. 从 B3657 延伸出去:语言月赛题怎么刷才有效
5.1 举一反三:把这道题改造成更高难度
一道题做完就扔是浪费。真正有效的刷题方式是“做一道,会一类”。基于 B3657,可以往下面几个方向改一改,每一个都是不同的考点:
- 改成阶梯折扣:总人数达到 10 人打 95 折,达到 20 人打 8 折,达到 30 人打 6 折。这就从单个 if 变成了 else if 链或 switch 逻辑。
- 改成按身高分类:儿童身高超过 1.3 米要买成人票,1.2 到 1.3 米买儿童票,不足 1.2 米免票。这就涉及浮点数比较和多重逻辑判断。
- 改成团体价和人数的非线性关系:团购价可能是总价超过 200 再立减 50,又有多种优惠不可叠加。这时候你就需要仔细想清楚判断的先后顺序。
这些变形都不涉及高深算法,但能把条件判断、浮点数处理、边界情况这些基本功练得很扎实。语言月赛的 B 题和 C 题,经常就是在“基础语法”之上加一点小拐弯,本质和你做的变形题差不多。
5.2 给新手的三个建议:避开典型的刷题误区
第一,不要只复制代码,要亲手敲一遍。看着题解觉得“我会了”,和真正自己写出来并通过评测,是两种完全不同的体验。很多错误只有在自己写的时候才会暴露出来,比如变量名拼错、少了头文件、分号写成中文分号。
第二,多用输出调试。如果评测机和你自己编译的结果不一致,可以在代码里临时加printf("cnt=%d total=%.2f\n", cnt, total);来看看中间计算是否符合预期。这种调试方式虽然原始,但对入门题来说非常高效,比上调试器或者加断点更直观。
第三,AC 之后看一下题解区其他人的代码风格。你会发现有人用cin/cout,有人用scanf/printf,有人用了一行流式表达式,有人写了结构体。别人的写法可能让你眼前一亮:原来还能这样。但请记住,看别人的代码是为了扩展思路,不是让你全盘照搬。
5.3 我个人的一个小习惯
我做这类题的时候,会特意关注自己“第一次提交”的通过率。如果一次就 AC,说明对这题的条件判断和坑都预判到了;如果 WA 了一两次,我一定会把 WA 的原因记下来,而不是默默改完就过。
比如我第一次写这类“打折”题目时,就犯过“没有先转 double 就直接把 int 乘法结果赋给 double“的错。那个 bug 非常隐蔽,因为我本地样例的输出看起来是对的,只有数据大的时候才会爆。从那之后,我只要看到乘法的两边都是整数,就会条件反射地看数据范围。这种经验,靠看题解是学不来的,只能靠自己踩坑。
B3657 这道题虽然分值不高、代码量极小,但它把“读题、变量设计、类型选择、条件分支、格式化输出”这五个入门核心技能都串起来了。如果你能不看题解,自己一步步把它推出来并且顺利 AC,说明你的 C++ 基础语法已经能够应对语言月赛的大部分题目了。接下来就可以按难度梯度去刷后面的 C 题、D 题,一步一步往普及组靠拢。