PAT乙级1102是那种一眼看去很基础、真动起手来却容易到处卡壳的题目。题面通常会给你一批商品记录,里面有编号、单价、销量这些字段,让你算出销售额和利润,最后找出那个“冠军商品”并输出。它不像纯模拟题那样无脑,也不像算法题那样烧脑,但它把结构体定义、自定义排序、浮点数精度、输出格式控制这四个基本功全部串在了一道题里。对正在备考PAT乙级、准备考研机试,或者刚学完C++结构体想找题练手的同学来说,把这道题吃透的性价比很高,后面再遇到同类“结构体+排序”的题目,基本就是一马平川。
1. 先看清这道题到底在考什么
1.1 乙级后半段的典型画风
PAT乙级整体难度集中在语法基础和简单逻辑上。前面的题目很多就是“读入、计算、输出”三件套,你会感受到纯粹的机械感。但到了1102这种靠后的题号,画风会稍微变一下:题面不再是单刀直入地让你算一个数,而是包装成一段业务场景。比如超市要评选销售冠军、学校要统计学生成绩排名、图书馆要找出借阅量最高的书。本质上都是一回事——给你一批带有多项属性的记录,让你按某个或某几个指标排序,然后输出特定结果。
这种题目考察的不是你会不会某个高深算法,而是你能不能把一段自然的文字描述,准确翻译成数据结构加操作逻辑。1102就是这个翻译过程的典型样本。你可以把它理解成“入门排序题”的收官题:过了这一关,你对结构体、vector、sort自定义比较这些内容的理解,就不再停留在抄代码层面,而是能真正独立设计出来。
1.2 四个躲不过去的考点
先说结论,1102这类题目有四个核心考点,缺一个都容易出问题。
第一个是结构体的定义与使用。每条商品记录包含好几种不同类型的数据,你必须用一个结构体把它们捆在一起。第二个是自定义排序规则。按利润降序排,利润相同按编号升序排,这个规则要用sort的第三个参数表达清楚。第三个是浮点数的计算与精度。销售额和利润可能会产生小数,直接拿float或double硬算,结果可能与期望值有细微偏差。第四个是输出格式。保留两位小数、换行、字符串输出,这些细节在判题系统里都是实打实的扣分点。
这四个考点单独拎出来问,很多人都会说“知道”,但真正在限时答题的环境里把它们无差错地组合起来,就会暴露出各种知识盲区。从我的经验看,失分最多的地方不是结构体写不出来,而是排序规则写反、浮点误差导致结果不一致、以及字段顺序读错导致整道题全盘乱套。
1.3 这道题在刷题路线里的位置
我把PAT乙级题目粗略分成三档:送分题、过渡题、硬骨头。1001到1010附近的题目大部分属于送分题,练的是基本的输入输出和if-else循环。1080以后的题目里开始出现更多需要设计数据结构才能解决的问题。1102正好卡在过渡段,往前一步是简单的数组和排序,往后一步就是更复杂的多关键字排序和排名处理。
如果你能不看题解,独立把1102写出来,并且一次通过全部测试点,这说明你的C++基础已经比较扎实了。如果你写得磕磕绊绊,或者看着题解能懂、关上题解就写不出来,那问题通常不在1102本身,而在更底层的数据结构和排序原理。这时候回头补一补结构体、vector、sort的用法,性价比比继续刷下一道题高得多。
2. 数据结构先设计好,代码至少省一半力气
2.1 别再用三个数组硬扛了
我很理解新手拿到1102之后的第一反应:定义三个数组,一个存编号,一个存单价,一个存销量,然后下标对齐地处理。这个思路在数据只有十几条的时候勉强能跑通,但一旦涉及排序就会露出马脚。因为排序时要同时交换多个数组,一个不小心漏掉某个数组,数据和编号就对不上了。排查起来非常痛苦。
用结构体把字段绑在一起,相当于给每条商品信息装进一个“档案袋”。你排序的时候移动的是整袋档案,而不是袋子里的一张纸,从根源上消除了数据错位的风险。这也是为什么我在刷题时一直强调:只要一条记录有两个以上不同类型的字段,就优先考虑结构体或类,而不是散装数组。
2.2 结构体到底怎么定义
我习惯把结构体定义写得精简但完整,尽量把后续要用到的字段都提前想好。以“编号、单价、销量、利润率”为例,一个常用的定义是这样:
struct Product { string id; // 商品编号 double price; // 单价 long long sales; // 销量(用 long long,防止乘法溢出) double profit; // 利润,后面计算后填入 };有些同学会把销售额单独设一个字段,这当然可以。我个人的偏好是:能算出来的字段不一定要存,排序规则里现算也行。但是如果你要输出多个计算字段,或者题目要求在排序中多次使用同一个计算结果,提前存进去会更清晰,也可以避免重复计算带来的浮点误差累积。这里没有唯一标准,核心原则是“读起来像人话,改起来不费劲”。
另外我强烈建议给结构体写一个构造函数,哪怕是最简单的版本。这样在读取每条记录时可以直接构造对象,代码会短一截,也少写几行赋值语句。比如:
struct Product { string id; double price; long long sales; double profit; Product(string i, double p, long long s, double pf) : id(i), price(p), sales(s), profit(pf) {} };当然,不写构造函数、用普通赋值方式也完全没问题,这只是风格选择。重点是结构体里字段的含义要清清楚楚,别把“单价”和“销售额”搞混。
2.3 编号用string还是long long
这个细节很容易被忽略。如果题面的编号是纯数字,而且明确说了没有前导零,那用long long存也不是不行。但问题是,很多场景下编号看起来像数字,实际上本质是字符串。比如“0012”这个编号,你用long long读入,它自动变成12,再输出就变了;万一排序规则里有按编号升序这一条,那“0012”会被放在“12”的位置上,顺序完全错乱。
所以我的建议非常直接:拿不准的时候就用string。string支持直接比较大小,按字典序排序;支持直接输出,不需要额外的转换。在1102这种题目里,string的处理成本可以忽略不计,但能帮你省掉一个巨大的隐患。如果真的需要用整数计算编号,那也要先把字符串读进来再自己转换,而不是直接读成数字。
2.4 排序规则写成函数还是lambda
排序规则是这类题目的灵魂。std::sort的第三个参数可以传一个函数,也可以传一个lambda表达式,两种写法各有拥趸。我的建议是:代码量小、规则简单时用lambda,看起来直观;规则稍微复杂时写成独立函数,方便在函数里加注释、调整逻辑。给一个两种写法的例子:
// 写法一:独立函数 bool cmp(const Product& a, const Product& b) { if (a.profit != b.profit) return a.profit > b.profit; // 利润高的在前 return a.id < b.id; // 利润相同,编号小的在前 } // 调用 sort(v.begin(), v.end(), cmp);// 写法二:lambda表达式 sort(v.begin(), v.end(), [](const Product& a, const Product& b) { if (a.profit != b.profit) return a.profit > b.profit; return a.id < b.id; });两个版本效果一样。要说注意点,就一个:比较函数必须满足“严格弱序”。也就是说,如果a和b等价,a<b和b<a都要返回false,否则sort的行为是未定义的,可能直接乱序甚至崩溃。很多人在这里偷懒,只写return a.profit > b.profit,完全没考虑利润相同的情况。如果题目要求此时按编号升序,那排序结果就会和标准答案不一致。
记忆排序方向也有个小技巧:看到>就是大的往前排,看到<就是小的往前排。想按降序排就用>,想按升序排就用<。一旦发现结果顺序反了,先看这个符号,十有八九是这里写反了。
3. 完整实现:从读入到输出一步步拆开讲
3.1 一份可以直接跑通的代码
下面这份代码是我按常见题面风格写的模板,假设每条记录包含“编号、单价、销量、利润率”。真实题目如果字段顺序不同,调整为对应的读入顺序即可。代码本身可以直接复制到本地编译器跑:
#include <bits/stdc++.h> using namespace std; struct Product { string id; double price; long long sales; double profit; }; bool cmp(const Product& a, const Product& b) { if (a.profit != b.profit) return a.profit > b.profit; return a.id < b.id; } int main() { int n; cin >> n; vector<Product> v; v.reserve(n); for (int i = 0; i < n; i++) { string id; double price, rate; long long sales; cin >> id >> price >> sales >> rate; double saleAmount = price * sales; // 销售额 double profit = saleAmount * rate / 100.0; // 利润额 Product p; p.id = id; p.price = price; p.sales = sales; p.profit = profit; v.push_back(p); } sort(v.begin(), v.end(), cmp); // 输出冠军商品 printf("%s %.2f %.2f\n", v[0].id.c_str(), v[0].price * v[0].sales, v[0].profit); return 0; }如果题目要求输出前几名,把最后那句printf挪进一个循环就行。如果输出字段不只是销售额和利润,根据题目要求补齐即可。
3.2 逐段拆解关键逻辑
先看读入部分。cin在读取数据时会自动跳过空白字符(空格、换行、Tab),所以就算每行数据的空格数量不齐,或者字段之间混着换行,它也能依次正确读取。这里不要自己用getline去逐行解析,徒增麻烦。
再看计算部分。saleAmount是销售额,等于单价乘以销量;profit是利润额,按利润率比例计算。注意利率字段假设是百分数形式,所以除以100.0。我见过有同学写rate / 100,在C++里如果rate是double,/100和/100.0结果一样,但为了可读性,建议写100.0,让读者一眼看出这是浮点除法。
然后是排序部分。sort的默认行为是升序,而这里要的是利润降序、编号升序,所以必须传自定义比较函数cmp。排序完成后,v[0]就是冠军商品。这里有一个细节:vector为空的情况。虽然题目基本不会给n=0,但代码里加一行if (v.empty()) return 0;只多花几秒,却能防止你在极端测试点越界访问v[0]导致运行时错误。我一般都会加上。
最后是输出部分。用printf和cout都能完成,但混用时要小心。printf("%.2f")可以保留两位小数,输出string要用c_str()转成C风格字符串。如果你喜欢用cout,记得加上fixed和setprecision(2),否则默认精度是输出六位有效数字,而不是保留两位小数,很容易多出几个零。
3.3 那些容易写错的输出细节
输出这一关,看似简单,实际栽跟头的人不少。第一个坑是保留小数位的精度处理。printf的%.2f采用的是四舍五入,这在大多数题目中是符合要求的。但如果题目明确说“结果保留两位小数,且不考虑四舍五入”或者“向下截断”,那你就不能直接printf,而要先对结果做floor或者整数化处理,再输出。
第二个坑是cout和printf混用的缓冲问题。有人前面用cout读数据,后面用printf输出,程序有时看起来没有输出,其实是缓冲区没有刷新。虽然大多数情况下程序结束会自动刷新,但在交互式调试或者特定环境下,你会碰到明明代码逻辑没问题、输出却不显示的诡异情况。建议在同一份代码里统一一种输出方式,不要一半printf一半cout。
第三个坑是换行符。题目要求输出冠军商品的信息后换行,那么printf里的\n就不能省。如果是输出前几名,每条记录后面都要有换行。看起来是废话,但我见过很多次,样例输出和实际输出的唯一区别就是最后多了一个回车,结果还被判格式错误。在判题系统里,多一个或少一个换行都可能算答案错误,这不是开不开玩笑的事。
4. 实测翻车现场与排查心得
4.1 三个最容易被扣分的点
我把实际刷题过程中踩过的坑整理成了一张速查表,按出现频率从高到低排列:
| 坑点 | 表现 | 解决方案 |
|---|---|---|
| 排序方向写反 | 冠军变成了最后一名 | 检查比较函数里的>和<,降序用>,升序用< |
| 字段读入顺序和题面不一致 | 整个结果全错 | 读题时先画出每条记录的字段顺序,再写cin语句 |
| 整数溢出或精度丢失 | 大数值测试点答案错误 | 销量用long long,金额计算用double,最终结果再用整数化处理 |
其中第三个点最隐蔽。比如销量几十万、单价几百元,乘出来就是上亿级别,如果用了int,直接溢出变成奇怪的负数。很多同学看到输出负数还以为是公式写错,其实只是类型不够大。
4.2 浮点数比较的隐藏炸弹
我一开始写的比较函数是直接判断两个double是否相等:
if (a.profit != b.profit) return a.profit > b.profit;看起来没毛病,但浮点数在计算机里是用二进制表示的,很多十进制小数无法精确存储。比如0.1这个数,在二进制里是无限循环小数。两条商品记录的利润在数学上可能完全相等,存进double之后却可能一个是23.1999999,一个是23.2000001,if判定不相等,排序结果就和标准答案不一致。
解决思路有两个方向。第一个是改用整数做比较:把所有金额先转成“分”,也就是乘以100再取整,然后用long long存储和比较。第二个是允许误差,比如判断(a.profit - b.profit) > 1e-9才算大于。但在PAT这类判题系统里,最稳妥的还是第一个方向:尽量避免浮点数比较。你可以在计算时就把单价按“分”为单位存成整数,再参与乘法,最后的结果自然也是精确的。
如果题目要求输出单位是“元”并保留两位小数,那就在输出前把分转回元,同时注意转回时的除法和格式化。这个思路能绕开一大半浮点数的坑。
4.3 小样例排查法
代码写完第一次跑不对,不要急着怀疑人生,先造一个小样例自己手算一遍。比如三条商品记录:
- 商品A:单价10元,销量100,利润率10%
- 商品B:单价20元,销量40,利润率20%
- 商品C:单价5元,销量200,利润率5%
手算一下销售额和利润,排个序,得出冠军,再运行代码看输出。如果对不上,就在排序之前把所有商品的中间计算结果打印出来。这一步能迅速定位问题:是计算字段写错了,还是排序规则写反了,还是输出格式不对。千万不要直接拿着完整测试数据去猜,那样只能靠蒙。
我现在的习惯是,刷题时凡是涉及到排序的题,都会先在草稿纸上把“排序之前的数据”和“排序之后的数据”各写一份,再和代码输出对照。这个习惯帮我抓出过很多只在边界测试点才会暴露的逻辑错误。
4.4 数据范围与运行效率
1102这种题的n范围一般不算大,用vector加sort是标准解法。时间复杂度是O(n log n),空间复杂度是O(n)。在乙级的数据规模下,即便n到10万级别,sort也只需要几十毫秒,完全不用自己写快排或堆排序。有些同学一看到排序就紧张,想手写一个“更高效的排序算法”,其实完全没有必要,标准库的sort已经针对各种数据分布做过大量优化,比你手写的绝大多数排序都稳。
另外我要提一下vector的reserve。虽然n未知时可以不写,但如果你知道n的大小,提前reserve可以避免多次动态扩容带来的开销。这只是性能优化层面的细节,不影响正确性,但写出来会让代码更专业。
运行效率还有一个隐形杀手:在排序比较函数里做重复计算。比如每次比较都去重算一遍某个商品的销售额和利润,虽然逻辑没错,但sort内部会调用大量比较,复杂度会凭空多出一个常数因子。更聪明的做法是在读入阶段就把计算字段算好并存进结构体,比较时直接读取,这也是我前面强调“能提前算就提前算”的原因。
5. 一点个人习惯上的建议
刷PAT乙级这段时间,我有一个很深的体会:能在1102这种题上稳定一次通过的人,往往不是代码写得最快的人,而是读题最慢的人。我在实际练习中养成了一个固定流程:先花两分钟把题面的字段、输出要求、排序规则全部画出来,再动手写代码。写代码五分钟,读题十分钟,听起来效率不高,实际上节省了反复修改和调试的大量时间。
还有一个建议是,不要只满足于“AC”。如果这道题你通过了,试着把代码改成用其他方式实现——比如把函数指针改成lambda、把结构体改成类、把printf改成cout。这个过程中踩到的坑,才是真正让你进步的东西。