C语言学到结构体这一章,很多人第一次感觉自己不会写代码了。数组、for循环、函数传参都还算直白,可结构体一出来,要把不同类型的数据绑成一个整体,还得让它在数组、指针、函数之间来回传递,不少人是真的晕。最近我把OJ上三道编号连在一起的结构体基础题——商店购物(118th)、挤牛奶(119th)、顺序的分数(120th)——从头到尾做了一遍,发现这三道题恰好把结构体变量、结构体数组、结构体指针、结构体排序这四个最核心的用法全部覆盖了。每一题单独看都不难,但组合在一起,就是一张完整的结构体入门路线图:先学会定义和初始化,再学会把结构体放进数组,然后用它处理区间、处理分数这类非标量数据,最后自己写比较器完成排序。如果你正在被结构体折腾,或者做完题之后想确认自己的解法是不是最稳的,这篇文章应该能帮上忙。我会把每一题的完整结构体设计、关键代码和踩坑点都摊开讲,新手可以直接照着写,写完的也能对照检查自己的代码。
1. 三道题到底在考什么
1.1 为什么说结构体是C语言的"分水岭"
很多教程习惯把结构体讲成"把几个变量包在一起",这个说法对,但太轻了。它真正的价值在于:让程序里的数据模型和你脑子里想的东西保持一致。你想到一件商品,脑子里同时出现的是名称、价格、数量,而不是三个毫不相干的变量名;你想到一段挤奶时间,脑子里同时出现的是起点和终点,而不是两个孤零零的 int。结构体把人脑的"实体"概念直接映射到代码里,这是所有高级数据结构的起点。
分水岭体现在两个层面。第一,思维模式变了:从"面向变量编程"变成"面向实体编程",写代码的第一步从"声明变量"变成"设计结构体"。第二,操作方式变了:以前给函数传三五个参数,现在传一个结构体就够了;以前给 qsort 写比较函数不知道怎么写,现在只要会操作结构体指针就行。这两层能力不突破,后面学单链表会非常痛苦——因为链表节点的精髓也和结构体直接相关。
所以这三道题虽然叫"基础题",但它们是典型的"基础但不简单"。很多人数组排序玩得很溜,一到结构体排序就翻车,根本原因不是排序算法不会,而是对"结构体是一种可以整体操作的数据类型"这个认知还没建立起来。做完这一组题,这个认知应该能立住。
1.2 一图理清三道题的考点布局
先放一张整理好的对照表,后面讲的时候你心里有个谱。
| 题目 | 核心结构体字段 | 主要考点 | 难度点 |
|---|---|---|---|
| 商店购物 | 商品名、单价、数量、小计 | 结构体数组、初始化、格式化输出 | 浮点输出、字段累加 |
| 挤牛奶 | 开始时间、结束时间 | 结构体数组、排序、区间合并 | 边界与重叠判断 |
| 顺序的分数 | 分子、分母、浮点值 | gcd 去重、自定义排序 | 排序比较器、去重 |
从表里能看出一个规律:三题都在练"结构体 + 排序"这个组合。商店购物的数据量小,即使不排序也影响不大,但它让你先把结构体数组和初始化的基本功打牢;挤牛奶必须排序,否则区间合并无从谈起;顺序的分数也需要先枚举出所有候选分数,再按值排序输出。所以这一组题做完,你等于把"结构体场景下的排序"这件事练了三遍,第一遍熟悉格式,第二遍理解逻辑,第三遍学会自己写比较器。
2. 商店购物:先学会把商品"打包"成一个整体
2.1 题目描述与结构体字段设计
商店购物这题我先按最常见的版本说:输入一个整数 n 表示商品种类数,接下来 n 行,每行是商品名、单价、购买数量,最后要求输出每个商品的小计金额和总金额。很多变体还会要求输出"应付金额""实付金额""找零",逻辑都类似。
结构体字段怎么设计?商品名用 char 数组,单价用 double(因为价格一般有小数点,float 精度不够,简单题用 double 顺手),数量用 int,小计金额用 double。看代码:
#include <stdio.h> struct Goods { char name[32]; double price; int count; double total; }; int main() { int n; scanf("%d", &n); struct Goods goods[100] = {0}; // 结构体数组整体清零 // ... return 0; }几个细节必须先说清楚。
第一,结构体定义放在函数外面,方便 main 和其它函数共用。如果你只想在 main 里用,也可以写在 main 内部,但一旦后面要写自定义排序比较器或处理函数,放外面是必须的。第二,struct Goods 是数据类型,Goods 不是——很多新手写 struct Goods 变量时漏掉 struct 这三个字母直接写 Goods x,编译就报错。想要省事可以用 typedef,但基础题阶段我更建议先老老实实写 struct,把类型名和变量名的区分建立起来。
第三,初始化时的 {0} 很关键。它会把整个数组的每个成员都清零,避免后面累加时用到垃圾值。单个结构体初始化可以这样:
struct Goods g = {"苹果", 3.5, 2, 0};但如果商品数量是运行时才知道的,就得先定义数组,再逐字段赋值。
2.2 结构体数组的输入与初始化实操
输入阶段是第一个容易翻车的地方。假设 n 不大,我们直接遍历读取:
for (int i = 0; i < n; i++) { scanf("%s %lf %d", goods[i].name, &goods[i].price, &goods[i].count); goods[i].total = goods[i].price * goods[i].count; }这里必须注意:goods[i].name 是数组名,本身就是一个地址,千万不要再加 &。新手最常见的错误是写 scanf("%s", &goods[i].name),虽然很多编译器不报错,但类型上完全不对,碰到某些严格环境会出问题。price 和 count 是普通成员,所以正常取地址。
小计金额在输入完成后立即算,比输出时再算省事。但如果你想把"录入"和"计算"分开,也可以先只存三种原始数据,后面统一再用循环算 total。我推荐分开处理,因为实际题目里可能不是所有商品都直接给定单价数量,有的需要折扣,有的按重量计价,分开处理的扩展性更好。当然,基础题怎么写都没问题,代码清晰优先。
2.3 小计、总计与浮点输出的三个坑
计算总计时,用 double 累加:
double sum = 0; for (int i = 0; i < n; i++) { printf("%s %.2f\n", goods[i].name, goods[i].total); sum += goods[i].total; } printf("总金额: %.2f\n", sum);这段代码看起来简单,但有三处容易踩坑的地方。
第一,浮点累加误差。理论上 0.1 + 0.2 不等于 0.3,double 也一样。做购物这种量级小的题目通常看不出来,但如果商品数量上千,累加误差可能让你的结果差一分钱。稳妥做法是:小计用"分"做单位,全部转成整数运算,最后再除以100输出。具体说,把单价乘以100存成 int 甚至是 long long,数量乘上去,最后输出时 printf("%lld.%02lld", total/100, total%100)。这样卖 999999 件都不会出现浮点误差。
第二,输出格式。%.2f 会做四舍五入,而且会自动补零,这正好符合人民币金额的习惯。如果你用 float,很多平台上 %f 输出浮点误差可能导致无法精确到分,所以能用 double 就用 double,能转整数就转整数。
第三,名称覆盖。如果商品名这题里用的是中文,scanf("%s", ...) 读中文在某些 OJ 上可能编码出问题。这种情况建议直接用 gets 或 fgets 读整行,再手动处理字符串。大多数在线题库的基础题都默认英文名或纯数字编号,真遇到中文名我不会直接用 scanf("%s")。
3. 挤牛奶:结构体数组与区间合并的完美配合
3.1 题意转换:时间段为什么要用结构体
挤牛奶这道题的原型是 USACO 的经典题 milk2。题目大意:几个农民各自有一段挤奶时间,给出开始时间和结束时间(用分钟数表示,比如 300 表示 5:00,1200 表示 20:00),要求找出:至少有一个农民在挤奶的最长连续时间段,以及完全没人挤奶的最长连续时间段。
这个题如果不用结构体,你会写两个数组 start[i] 和 end[i],同步排序时要同时交换两个数组,非常容易错位。用结构体之后:
struct TimeSlot { int start; int end; };一个农民的一段工作时间就是一个整体,排序时整个结构体一起移动,不会出现开始时间排好了、结束时间还乱着的情况。这就是结构体价值的直观体现:它保证了"同一个实体的多个属性永远绑在一起"。
3.2 按开始时间排序:合并的前置条件
区间合并的第一步是排序,按 start 从小到大排。为什么不按 end 排?因为我们要从左往右扫描,只有开始时间有序,才能保证"当前合并区间"永远是从最左边开始的。如果不排序,输入的顺序乱七八糟,你无法判断当前区间是否还能和后面的区间合并。
用 C 标准库的 qsort 排结构体数组,比较函数要自己写:
int cmp(const void *a, const void *b) { const struct TimeSlot *t1 = (const struct TimeSlot *)a; const struct TimeSlot *t2 = (const struct TimeSlot *)b; return t1->start - t2->start; } qsort(slots, n, sizeof(struct TimeSlot), cmp);比较函数返回负数表示 t1 在前,正数表示 t2 在前,0 表示相等。这里的 return t1->start - t2->start 在 start 范围不大时没问题,如果 start 可能接近 INT_MAX,减法会溢出,稳妥写法是用大小判断:
if (t1->start < t2->start) return -1; if (t1->start > t2->start) return 1; return 0;基础题范围小,减法写法够用,但建议养成用比较判断的习惯。
3.3 一次遍历完成区间合并与边界处理
排序完,核心逻辑就出来了。维护两个变量当前合并区间的左端 curStart 和右端 curEnd,初始化为第一个区间的值。然后从第二段开始遍历:
int maxMilk = 0, maxIdle = 0; int curStart = slots[0].start; int curEnd = slots[0].end; for (int i = 1; i < n; i++) { if (slots[i].start <= curEnd) { // 重叠或相接,合并区间 if (slots[i].end > curEnd) { curEnd = slots[i].end; } } else { // 出现了断层,结算当前区间 int milkLen = curEnd - curStart; if (milkLen > maxMilk) { maxMilk = milkLen; } int idleLen = slots[i].start - curEnd; if (idleLen > maxIdle) { maxIdle = idleLen; } // 开启新的合并区间 curStart = slots[i].start; curEnd = slots[i].end; } } // 最后一轮循环结束时,最后一个区间还没有结算 int milkLen = curEnd - curStart; if (milkLen > maxMilk) { maxMilk = milkLen; }重点解释两个关键判断。
第一,什么时候算"连续"?这里用 slots[i].start <= curEnd,也就是下一段的开始时间不晚于当前区间的结束时间,就认为没有断档。注意等号:例如当前区间是 300 到 600,下一段开始正好是 600,那么严格意义上 600 分钟那一个时刻是有人接上的,题目通常要求把这种首尾相接也算连续。不过有些题库的表述可能略有差异,我建议你两手准备:先写成 <=,如果 WA 就改成 < 再试一次,这属于题目边界定义的常见差异。
第二,为什么循环结束后还要单独结算一次?因为只有遇到"断层"时我们才会结算上一个区间,而最后一个合并区间不一定有断层跟在后面。我自己第一次写这个题就是漏了最后这段结算,样例能过,但换一个"最后一个区间最长"的数据就挂了。这是区间合并题最经典的漏判,后面我会再拿它当重点排查案例。
如果题目里"挤奶时间"按分钟计算,区间的长度就是 end - start。有的版本会把区间定义成"在 300 分钟到 1200 分钟这一时间段内挤奶",长度确实是 900 分钟。也有版本认为每个整分钟都在挤奶,长度是 end - start + 1。遇到这种题看清楚样例再决定,样例永远是对的。
4. 顺序的分数:枚举、去重与自定义排序
4.1 结构体 + gcd:如何保证分数唯一
顺序的分数这题也是一个经典题。输入一个自然数 N,输出所有满足 0 <= a <= b <= N 的最简分数 a/b,按分数值从小到大输出。N 的范围一般不大,比如 160。
先说思路:第一反应是把所有的 a/b 都列出来,b 从 1 到 N,a 从 0 到 b。但如果直接输出,一个分数会以不同形式出现很多次,比如 1/2、2/4、3/6,题目要求只输出最简形式。最自然的办法是:每枚举一个分数,先用最大公约数 gcd 把分子分母约分,只有约分后 gcd 等于 1 的才存下来。这样每个分数值只对应一组最简的 (num, den),天然去重。
结构体这样设计:
struct Fraction { int num; int den; double value; };value 字段存 num 除以 den 的浮点结果,专门用于排序。很多教程会用 double value 存值并排序,这个方案对 N 比较小的题完全够用,但要注意浮点误差可能影响相等分数的排序稳定性。想要完全避免浮点问题,可以用交叉相乘的方式在比较器里比较,我下面会专门写。
生成候选分数的核心代码:
int gcd(int a, int b) { while (b) { int t = b; b = a % b; a = t; } return a; } struct Fraction frac[20000]; int m = 0; for (int den = 1; den <= N; den++) { for (int num = 0; num <= den; num++) { if (gcd(num, den) == 1) { frac[m].num = num; frac[m].den = den; frac[m].value = (double)num / den; m++; } } }这里 gcd(num, den) == 1 就是最简条件。0 和任何正整数互质吗?按 gcd 定义,gcd(0, den) = den,所以只有当 den == 1 时 gcd(0,1)==1,0/1 会被存进去;num == den 时 gcd(den, den) == den,只有当 den == 1 时 1/1 会被存进去。这就精妙地处理了两个特殊分数:0/1 和 1/1 都只出现一次,0/2、2/2 这类不会被重复输出。
4.2 排序比较器的两种写法及风险
现在数组里全是互不重复的最简分数,剩下就是按值从小到大排序。用 qsort,比较器有两种风格。
第一种,直接用 value 字段:
int cmp(const void *a, const void *b) { const struct Fraction *f1 = (const struct Fraction *)a; const struct Fraction *f2 = (const struct Fraction *)b; if (f1->value < f2->value) return -1; if (f1->value > f2->value) return 1; return 0; }第二种,交叉相乘避免浮点误差:
int cmp(const void *a, const void *b) { const struct Fraction *f1 = (const struct Fraction *)a; const struct Fraction *f2 = (const struct Fraction *)b; long long lhs = (long long)f1->num * f2->den; long long rhs = (long long)f2->num * f1->den; if (lhs < rhs) return -1; if (lhs > rhs) return 1; return 0; }交叉相乘的原理很朴素:比较 a/b 和 c/d,等价于比较 ad 和 cb。这个方案不引入浮点数,绝对精确,而且在 N 比较大时不会因为浮点误差让排序错乱。用 (long long) 强转是防止 N 稍大时 int 溢出,习惯很好。我个人的建议是第二种写熟,因为它以后在其它排序题里也能复用;value 字段在交叉相乘方案里其实可以不存,但留着也不碍事。
值得注意,qsort 的比较函数返回值必须是"负数 / 正数 / 0"三种语义,返回 -1、1、0 是最保险的。千万别写成 return f1->value - f2->value,因为 value 是 double,函数返回类型是 int,小数部分会被截断,0.1 和 0.9 相减结果是 0,qsort 会认为它们相等,排序结果完全乱掉。这属于比较函数里相当隐蔽的坑,我见过不少人在浮点结构体排序上栽在这里。
排序完成之后,输出:
for (int i = 0; i < m; i++) { printf("%d/%d\n", frac[i].num, frac[i].den); }如果题目要求每一行输出一个分数,这样就好了。N=5 时输出应该是:
0/1 1/5 1/4 1/3 2/5 1/2 3/5 2/3 3/4 4/5 1/1你可以用这个样例对照检查自己的运行结果。
4.3 完整流程与性能考量
整个题的流程可以归纳成四步:枚举、约分、排序、输出。列出伪流程:
- 双层循环枚举所有分母 b 从 1 到 N,分子 a 从 0 到 b;
- 对每个 (a, b) 计算 gcd(a, b),只有 gcd 等于 1 的才加入结构体数组;
- qsort 按分数值从小到大排序;
- 循环输出。
性能上,N=160 时枚举次数大约是 160*160/2 = 12800,远远小于数组容量 20000,绝对够用。就算 N 到几千,只要数组开够大,这个算法也扛得住。如果真的遇到 N 很大的题目,可以考虑用 Stern-Brocot 树做有序生成,直接跳过排序环节,但基础题阶段完全不需要。
如果你愿意,还可以不存 double value,只在比较器里用交叉相乘。两种都能过,但按我的习惯,字段里多存一个 value 能让主代码更直白,可读性更好。这点见仁见智。
5. 常见问题与排查技巧实录
5.1 qsort 比较函数最容易犯的三种错
做这三道题,qsort 几乎是绕不开的。我把比较函数那点事集中说一下,都是实际中高频踩坑。
第一个错,返回浮点差值。比较结构体里 double 成员时直接用 return a->price - b->price,函数返回 int,精度丢失。轻则排序结果和期望不符,重则 qsort 陷入不稳定状态产生缓冲区相关问题。正确做法是 if/else 返回 -1、0、1。
第二个错,指针丢弃 const 或强转类型写错。qsort 的比较函数参数是 const void,必须强转为实际的 struct 指针类型。有人忘记解引用直接写 return a - b,那是在比较指针地址,不是在比较数据,结果自然是错的。记住通用三步:先把 void转成目标结构体指针,再通过 -> 访问成员,最后返回比较结果。
第三个错,减法溢出。return a->start - b->start 在多数题里没问题,但理论上如果两个值都是很大的正数,差值可能超过 int 范围反而变负,排序逻辑就乱了。用 if 比较更稳。这个习惯在看了大量解答之后会发现是普遍推荐写法,建议直接默认采用。
还有一个和 qsort 本身无关但很常见的错:结构体数组越界。比如 N=5 时分数个数其实接近 N*(N+1)/2,有人随手开 100 的数组,N 稍大就溢出,运行时报错或者输出一堆乱码。开数组之前先估算上限,这是写结构体数组的基本素养。
5.2 区间合并漏掉最后一个时间段的坑
挤牛奶题里最经典的失误就是漏结算最后一个合并区间。很多人的代码长这样:
for (int i = 1; i < n; i++) { if (slots[i].start <= curEnd) { // ... } else { // 结算并重新开始 } }然后发现自己样例输入能过,换一组数据就 WA。原因很简单:如果最后一组数据本身就是最长的一段挤奶时间,循环结束后它只存在于 curStart 和 curEnd 里,从来没被拿去比较过 maxMilk。排查方法也很简单:在循环外面补上我前面写过的结算代码,同时顺手考虑空档的更新——空档只可能在断层时出现,最后一段后面没有空档,不需要处理。
这个错我印象很深,因为它的现象极具迷惑性:样例能过,自查时也发现不了,必须把 print 调试加进去,把每次循环后的 curStart、curEnd、maxMilk、maxIdle 都打印出来,看到最后一个区间根本没被比较过才明白。所以遇到区间合并题,我建议第一版代码就写清楚:循环结束后再做一次"最终结算",这是区间类题目的通用保险丝。
5.3 自查清单:交代码之前过一遍
最后给一份我每次做结构体排序题都会过一遍的清单:
- 结构体定义是否在函数外部(如果需要跨函数使用);
- struct 关键字有没有漏写;
- 结构体数组是否有冗余字段或字段类型是否合理;
- scanf 时数组成员 name 这类 char 数组有没有误加 &;
- 结构体数组是否清零({0});
- qsort 比较函数是否返回 -1/0/1,有没有用浮点估算;
- 交叉相乘比较时有没有强转 long long;
- 区间合并循环结束后的最终结算有没有漏;
- 输出的浮点数位数和格式是否符合题目要求;
- gcd 去重逻辑对 0、1 这两个特殊分数是否正确。
这份清单看着琐碎,但每一条我都踩过对应的坑。结构体题目其实不难,难的是"看起来对但是错"的细节。把这些细节变成肌肉记忆,后续链表、树、图的结构体操作会轻松很多。
最后再分享一点个人体会。做这三道题之前,我一直觉得结构体无非是"语法层面的糖衣",用不用都行。可当我真的用结构体数组把区间合并、分数排序写完之后,再回头看以前用平行数组硬写的代码,才明白结构体不是糖衣,而是让代码和数据模型对齐的脚手架。挤牛奶题里时间段和区间合并,顺序的分数题里分子分母和自定义比较器,每一个场景都在推着你用"实体"的眼光看数据。做完这三题,我建议你先别急着往后学链表,可以自己试着改造一道已经做过的数组题,比如把某个"双数组同步操作"改成结构体数组操作,感受一下可读性和维护性的变化。这个"旧题新做"的过程,比多刷十道新题更能帮你把结构体吃透。祝你也顺利拿下这三个 118、119、120 号基础题。