C语言数组综合练习:电视节目投票统计的完整解析与代码实现 《C语言程序设计第四版何钦铭、颜晖》第七章的数组部分有一道课后题常年被学生挂在嘴边——“调查电视节目受欢迎程度”。表面上看它就是个普普通通的二维数组练习题几个观众、几个节目、统计票数、排序输出。但我教了这么多年C语言必须说一句这道题被严重低估了。它把数组、循环、排序、格式化输出这些C语言最核心的基础串成了一条完整的链路任何一个环节没吃透代码都跑不出正确结果。很多初学者学完第七章自认为数组已经掌握了但真让他独立完成这道题往往是三种状态一是有思路但不知道代码从哪行开始写二是写完了运行不出结果三是结果出来了一半是错的。这三种情况我都见过太多次。这篇文章不打算只贴一份答案而是把这道题从题目理解、解题思路、完整代码到调试排错、进阶扩展完完整整地讲一遍。不管是刚学完数组需要做作业的同学还是准备计算机二级C语言、正在找综合练习题的人这篇都值得你静下心看完。1. 这题到底在考什么一份教材习题的含金量分析1.1 题目到底让我们做什么原题的大致场景是这样的电视台要对几个节目的受欢迎程度做调查让观众投票。假设有 n 个观众m 个节目每个观众给一个最喜欢的节目投票票数最高的节目就是最受欢迎的节目。要求用二维数组存储投票数据然后统计每个节目的得票数最后按票数从高到低输出节目编号和得票数。把这层壳剥掉核心任务就三个存数据、数票数、排序输出。存数据题目要求用二维数组。这个“题目要求”很关键它是在逼你练习二维数组的使用而不是让你用 m 个一维数组分别记录。数票数本质是从二维数组里按列或者按行做累加把二维数据降维成一维的计数数组。排序输出这里建议用选择排序或冒泡排序因为题目没要求你写快排把简单稳定的排序算法写对比追新求快更重要。1.2 题目隐藏的知识点和能力清单很多同学觉得这道题难是因为它不像前面几章的习题那样“一问一答”而是需要你在一道题里同时调用多个知识点。拆开来看它至少覆盖了以下内容。二维数组的定义和初始化int a[10][5]到底怎么分配内存行和列分别是什么语义。嵌套循环控制外层循环管行还是管列内层循环管什么两个循环换一下位置结果就完全不同。下标与现实的映射关系观众编号从 1 开始还是从 0 开始怎么在数组下标和节目编号之间做转换。计数累加sum[j] a[i][j]这一类操作为什么放在内层循环里为什么每次外层循环开始前要把累加变量清零。排序算法两个平行数组节目编号数组和票数数组如何同步交换。格式化输出printf(%d:%d, i1, count[i])里%d后面的冒号、空格、换行到底怎么安排。代码调试能力运行结果不对的时候怎么定位是输入问题、统计问题还是排序问题。别小看这份清单你能独立把这份清单里的每一条都做对说明你数组这一章是真的过关了。要是只看答案抄一遍那学到的只是“别人帮你排好的代码”不是你的能力。1.3 为什么说这道题很适合做综合练习第七章的习题其实不少但“调查电视节目受欢迎程度”这道题的特殊之处在于它很贴近一个真实的小项目场景。真实项目里最常做的事儿就是收集数据、做统计、按规则排序、展示结果。这道题虽然是课堂版但流程和真实开发没有本质区别。另外这道题的扩展空间非常大。比如加上节目名称变成结构体数组比如把投票方式改成可以弃权比如把数据保存到文件里方便下次继续统计。你每加一个条件代码的复杂度就上一个台阶但这道题的骨架能撑得住这些变化。这一点在文章第四部分我会专门展开讲。2. 解题思路与方案设计为什么用二维数组而不是别的结构2.1 二维数组在这里的语义一维是观众一维是节目先说一个很直白的问题为什么非要用二维数组假设有 3 个观众、4 个节目。你可以定义int votes[3][4]含义是第 i 行第 j 列存放第 i 位观众对第 j 个节目的投票情况。如果观众 i 把票投给了节目 j就把votes[i][j]置为 1否则置为 0。这里有个值得琢磨的点为什么用 0 和 1而不是直接把观众投的节目编号存进去比如观众 1 喜欢节目 3那votes[0][0] 3不行吗行但这样就丢掉了一张关键信息表。二维数组的行和列一旦确定它的“位置”本身就承载了语义。votes[i][j]这个格子天然就代表了“第 i 位观众和第 j 个节目的关系”。你用 0 和 1 来填这个格子是在说“有关系还是没有关系”。如果你把一个数字 3 存进去那你还得额外想这个 3 是节目编号还是票数权重语义容易混乱。打个比方二维数组就像一张座位表行是观众名单列是节目名单格子里打勾就代表这个观众选了那个节目。这张表的好处是任何一个观众和任何一个节目的关系你都能在常数时间内查到而且整张表的结构一目了然。2.2 统计环节的降维思路从二维到一维计数数组有了二维数组下一步要统计每个节目获得的总票数。这里的做法是“降维”把二维矩阵压缩成一维数组一维数组的下标代表节目编号值代表总票数。具体来说定义int count[M] {0}然后遍历观众编号 i对于每个观众找到他投了哪个节目把对应的count[节目编号]加 1。判断观众 i 投了哪个节目有两种常见写法。第一种是扫整行。因为每行只有一个 1所以内层循环遍历所有节目列一旦发现votes[i][j] 1说明观众 i 投给了节目 j执行count[j]。这种写法逻辑直接适合初学。第二种是直接在输入时就累加。也就是输入一个观众投了哪个节目不存到二维数组里直接count[编号]。这种写法虽然更简单但没有用到二维数组和题目要求不太吻合。如果你在做作业建议还是先把二维数组建起来再统计这样才能练到二维数组的遍历。2.3 排序环节平行数组同步交换别把数据弄乱了统计完成后你手里有两个数组一个是prog[0..m-1]代表节目编号一个是count[0..m-1]代表对应票数。现在要按票数从高到低排序。这里最经典的坑就是只排了票数数组忘了同步调整节目编号数组。比如 3 号节目票数最高排序后count[0]变成了最高的票数但prog[0]还是原来的编号那输出就全对不上了。我推荐的做法是用选择排序因为它在教学阶段最好理解每一轮挑出剩余部分的最大值把它交换到最前面同时把对应的节目编号一起交换。每次交换都是“一组两个数据”同时动这样排序结束后两个数组的对应关系不会乱。如果你愿意也可以用结构体把节目编号和票数打包成一个整体排序时交换结构体变量。这个扩展方案我在后面会写效果是代码更清晰但要求你先掌握结构体。2.4 输出格式细节决定这道题拿满分还是被扣分最后一步输出看起来最简单实际上被扣分的人不少。题目一般要求输出节目编号和得票数例如1:25 3:18 2:12这里的“1”“3”“2”是节目编号“25”“18”“12”是票数。用 printf 输出时要注意节目编号是不是从 0 开始。如果你的程序内部用下标 0 表示 1 号节目那输出时就要printf(%d:%d\n, prog[i] 1, count[i])千万不能直接打印下标。还要注意格式对齐。票数可能是个位数也可能是两位数用%2d或%-3d之类的宽度控制可以让输出更整齐。千万别小看这个机器阅卷时格式错了可能就是零分人眼阅卷时格式好也能多给个印象分。3. 完整代码实现与逐段讲解3.1 基础版完整代码可直接运行下面这段代码是这道题最中规中矩的实现没有花哨技巧每一步都尽量好懂。我把它当作学习版本给出建议你先把这段代码吃透再考虑优化。#include stdio.h #define MAX_AUDIENCE 100 #define MAX_PROGRAM 10 int main() { int n, m; int votes[MAX_AUDIENCE][MAX_PROGRAM] {0}; int count[MAX_PROGRAM] {0}; int prog[MAX_PROGRAM]; // 节目编号 int i, j, temp, maxIndex; printf(请输入观众人数和节目数量用空格隔开\n); scanf(%d %d, n, m); // 初始化节目编号数组 for (i 0; i m; i) { prog[i] i 1; // 1号到m号节目 } // 输入投票情况 printf(请依次输入每个观众投票的节目编号1~%d\n, m); for (i 0; i n; i) { int choice; scanf(%d, choice); // 第 i1 位观众投给了 choice 号节目 if (choice 1 choice m) { votes[i][choice - 1] 1; // 列下标从0开始所以 choice-1 } else { printf(输入错误节目编号 %d 不存在本次投票作废\n, choice); } } // 统计每个节目的票数 for (i 0; i n; i) { for (j 0; j m; j) { if (votes[i][j] 1) { count[j]; } } } // 选择排序按票数从高到低节目编号同步移动 for (i 0; i m - 1; i) { maxIndex i; for (j i 1; j m; j) { if (count[j] count[maxIndex]) { maxIndex j; } } // 交换票数 temp count[i]; count[i] count[maxIndex]; count[maxIndex] temp; // 同步交换节目编号 temp prog[i]; prog[i] prog[maxIndex]; prog[maxIndex] temp; } // 输出结果 printf(\n节目受欢迎程度排序\n); for (i 0; i m; i) { printf(%d号节目%d票\n, prog[i], count[i]); } return 0; }3.2 实测运行效果一组输入一个输出把过程看明白光有代码还不够我们拿一组真实数据走一遍这样你能直观看到程序在干什么。假设输入5 3 2 1 2 3 2含义是 5 个观众3 个节目。投票情况观众1投2号观众2投1号观众3投2号观众4投3号观众5投2号。统计后第二步的count数组应该是1号节目1票2号节目3票3号节目1票。排序后输出应该是2号节目3票 1号节目1票 3号节目1票这里有个细节要提醒你1号节目和3号节目票数同为1票。我的代码里选择排序是“严格大于才更新 maxIndex”所以 1号节目会排在3号节目前面这种并列时的先后顺序不算错因为题目通常只要求按票数排序没要求二次排序。但你要是想让票数相同的节目按编号从小到大排就得把count[j] count[maxIndex]改成count[j] count[maxIndex] || (count[j] count[maxIndex] prog[j] prog[maxIndex])。这个细微差别面试和考试里都有人被问到。3.3 这段代码里最容易翻车的三个细节第一个细节是二维数组的初始化{0}。有些教材里只写了int votes[MAX_AUDIENCE][MAX_PROGRAM];不初始化。这在某些编译器里没问题因为局部变量可能默认是0但这不是标准行为。不初始化数组里就是随机垃圾值后面if (votes[i][j] 1)的判断就会出错。所以我的代码里写了 {0}这是必须养成的习惯。第二个细节是观众投票的编号和数组下标的转换。程序里观众输入的是2号节目数组下标却是1所以必须choice - 1。很多同学统计结果不对不是统计逻辑写错了而是这一步转换写错了把choice - 1写成了choice导致votes[i][choice]直接访问到下一列去甚至数组越界。第三个细节是排序时的“平行交换”容易漏掉一行temp。我见过很多次这样的代码交换了票数数组count却没交换prog数组结果节目编号和票数错位输出全乱。这一点在上一节已经强调过这里再用实际犯错场景提醒一次。你可以把两个数组的输出放在交换之后立刻打印一旦发现错位很快就能抓到。3.4 关于宏定义和动态数组的补充说明代码里用了#define MAX_AUDIENCE 100和#define MAX_PROGRAM 10这是在编译前就确定好数组大小的做法优点是简单直观、适合交作业。但它也有局限如果输入超过了100个观众数组会越界。你可能已经想到能不能用变量来定义数组大小比如int votes[n][m]。在C99标准里这叫变长数组VLA某些编译器支持。但在旧标准C89里不支持有些考试环境也不支持所以教材习题一般不会推荐你这么做。如果你想把程序写得“专业一点”可以用动态内存分配用malloc为二维数组分配空间这属于指针和动态内存的内容一般学到第七章可能还没讲。我这里先提一嘴你可以把它作为进阶目标等看完第4章的扩展方案再回头理解。现在这个宏定义版本足够应付大部分场景。4. 进阶扩展把一道作业题改造成一个小项目4.1 用结构体打包节目数据让代码更清晰基础版里用两个平行数组count和prog分别存票数和编号数据一多就容易乱。更好的做法是定义一个结构体把节目编号和票数绑在一起typedef struct { int id; int count; } Program;然后定义一个Program programs[MAX_PROGRAM]数组初始化时给每个id赋值为1,2,3...统计票数时直接programs[j].count排序时交换整个结构体变量。这有什么好处首先你不用再担心两个数组错位因为数据本来就在同一个“盒子”里。其次以后想加字段比如节目名称、主持人、播出时段直接在结构体里加一个成员就行不用再新增一个平行数组。第三C语言里用结构体管理一组关联数据是工程惯例越早适应越好。排序代码会变成这样for (i 0; i m - 1; i) { maxIndex i; for (j i 1; j m; j) { if (programs[j].count programs[maxIndex].count) { maxIndex j; } } if (maxIndex ! i) { Program temp programs[i]; programs[i] programs[maxIndex]; programs[maxIndex] temp; } }结构体变量的整体交换在C语言里是允许的一个临时结构体变量temp就能完成。这段代码看起来比平行数组版本更自然也更接近你以后在工作中写代码的方式。4.2 支持多轮投票、并列名次和百分比统计基础版里一个观众只能投一票。真实场景中一个观众往往可以给多个节目打分或者连续多轮投票。改造方法很简单把输入部分从“每个观众只能输入一个编号”改成“每个观众可以输入多个编号”用一个循环接收直到输入 0 结束。多轮投票统计的代码片段大致是这样的for (i 0; i n; i) { printf(请第%d位观众输入喜欢的节目编号输入0结束\n, i 1); scanf(%d, choice); while (choice ! 0) { if (choice 1 choice m) { programs[choice - 1].count; } scanf(%d, choice); } }这里while (choice ! 0)是一个很典型的输入循环模式重点是读一次、处理一次、再读一次而不是把scanf放在循环条件里写复杂表达式。对初学者来说先读再循环判断是最不容易出错的结构。百分比统计也不难。总票数 n如果是每人一票。每个节目的得票率 票数 / 总票数 × 100%。注意 C 语言里整数除法会直接丢弃小数所以要先转成浮点数再计算double percent (double)programs[i].count / n * 100; printf(%d号节目%d票占比%.2f%%\n, programs[i].id, programs[i].count, percent);至于并列名次如果前端显示不要求严格排名只按票数从高到低输出那就没问题。如果要求输出“第1名、第2名”且票数相同时名次相同则要额外判断当前节目票数和上一个节目票数是否相等相等就沿用上一个名次否则名次递增。这个逻辑可以在输出时用一个变量来控制。4.3 把数据存进文件让程序真正脱离“一次性”的尴尬控制台版本的缺陷很明显程序一关数据就没了。你想调查一百个观众每次都手动敲一百行数字手都酸了。这时候就该把输入输出改成文件读写这是搜索热词里“C语言文件读写操作代码”经常被搜的原因也是这道题最有实用价值的扩展方向。代码改起来不难思路是把原来的scanf改成fscanf(file, %d, choice)把原来的printf改成fprintf(outFile, %d号节目%d票\n, ...)。下面是一个简化版的文件输入方案文件名假设是vote.txt每行一个观众的投票#include stdio.h #define MAX_PROGRAM 20 int main() { FILE *fp fopen(vote.txt, r); if (fp NULL) { printf(无法打开文件 vote.txt\n); return 1; } int count[MAX_PROGRAM] {0}; int choice; while (fscanf(fp, %d, choice) ! EOF) { if (choice 1 choice MAX_PROGRAM) { count[choice - 1]; } } fclose(fp); // 排序代码省略和基础版一致 // 输出到文件 FILE *out fopen(result.txt, w); if (out NULL) { printf(无法创建结果文件\n); return 1; } for (int i 0; i MAX_PROGRAM; i) { if (count[i] 0) { fprintf(out, %d号节目%d票\n, i 1, count[i]); } } fclose(out); return 0; }这段代码里有个容易被忽略的点while (fscanf(fp, %d, choice) ! EOF)。fscanf的返回值是成功读取的数据个数读到文件末尾返回 EOF。如果文件里混入了非数字字符fscanf会返回 0不会推进文件指针就可能死循环。所以更严格的写法是while (fscanf(fp, %d, choice) 1) { // 处理 }这个细节在作业里可能不扣分但在实际处理脏数据时非常关键。搜索热词里有“C语言文件读写操作代码”说明很多人都在找这部分资料我建议你一定要试着把这个文件版跑通从手动输数据改成读文件这是从“做题”到“做工具”的分水岭。4.4 如果加上字符串从“1号节目”变成真实名称再往前走一步把节目编号换成节目名称你就必须用到字符串数组。C语言里没有原生的 string 类型字符串需要借助字符数组来存放。比如typedef struct { char name[50]; int id; int count; } Program;初始化时给每个节目填上名称比如“新闻联播”“综艺大观”“电视剧场”然后输入投票时不仅要输入编号还要根据编号找到对应结构体累加票数。输出时打印programs[i].name。这一步听起来简单但不少同学会卡在字符串的比较上。比如你想实现“根据用户输入的节目名称投票”那就不能用比较字符串要用strcmp函数。strcmp返回 0 表示两个字符串相同。这个点也是“C语言字符串”搜索热词频繁出现的原因之一。等你掌握了这一块这道题已经完全不是作业级别了它已经具备一个完整投票管理系统的雏形。5. 常见问题与排查技巧实录5.1 输出全是0八成是初始化或者统计逻辑出了错如果你运行程序后发现所有节目的票数都是0先别急着怀疑编译器。这种问题的原因主要有两个。第一二维数组没有被正确填充。比如你定义了int votes[MAX_AUDIENCE][MAX_PROGRAM] {0}结果输入时根本没写votes[i][choice - 1] 1那统计环节永远找不到1count自然全是0。请你在统计循环前后各加一行printf打印关键变量确认输入到底有没有进到数组里。第二统计循环写反了。有人会把两个循环写成for(i) for(j) if (votes[j][i] 1) count[i]这本来也没问题只要下标和循环变量对得上。但如果外层循环变量和内层循环变量混用了count累加的下标和数组访问的下标不一致结果就会乱套。我常用的调试方法是统计完之后先把 count 数组打印一遍。如果 count 数组正常、排序后不对那是排序的问题如果 count 数组就不正常那是输入或者统计的问题。这样一分为二定位速度非常快。5.2 段错误Segmentation fault从哪来数组越界的典型场景段错误几乎每个学C语言的人都会遇到这道题里最常见的触发点就是数组越界。比如观众输入了节目编号 5而你定义的数组只有MAX_PROGRAM 4列votes[i][4]就越界了。更隐蔽的是输入编号为 0 或负数虽然不越界但choice - 1得到 -1访问votes[i][-1]直接访问到数组前面的内存这也是非法访问。解决办法有两个层面。第一在输入时做范围判断也就是基础版代码里我写的那段if (choice 1 choice m)不合法就让用户重新输入。第二如果你怀疑程序访问越界用printf把每个访问数组下标的变量值打印出来或者用调试器设置断点查看数组下标。还有一种越界是在排序时发生的。选择排序的内层循环从i1到m-1如果写成j m最后一次count[m]就是越界访问程序可能直接崩溃。这种越界用眼睛很难看出来所以我的习惯是所有遍历数组的循环统一写成j m这种左闭右开的形式从源头上避免差一错误。5.3 scanf 的陷阱回车、空格和非法输入的处理scanf(%d, choice)读到空格、换行符时会自动跳过所以连续输入多个数字用空格或回车隔开都行。这个特性很方便但也带来一个问题如果用户输入的不是数字而是字母scanf会返回0变量保持原来的值输入流里的字母并没有被清掉下一次循环又会读到同一个字母形成死循环。判断方式很简单检查scanf的返回值。int ret scanf(%d, choice); if (ret ! 1) { // 输入非法清空输入缓冲区 while (getchar() ! \n); continue; }getchar() ! \n这个循环能把一行残留的无效字符全部吃掉是C语言处理输入混乱的经典手段。这道题虽然是教材作业输入大概率很规范但如果你把这个防御性写法练熟了以后写任何带交互的程序都能少踩很多坑。5.4 我的排查方法论从小数据、打印法和边界值入手最后分享一下我平时是怎么排查这类数组题目的不管你是作业调试还是以后工作中排查问题这套思路通用。第一用小数据测试。把 n 设为 3m 设为 2这个规模小到你可以手算预期结果。比如三个观众分别投给1、2、1号节目预期结果是1号节目2票、2号节目1票。小数据的好处是每一步都可以在纸上推演出错了能精确到具体是哪一行逻辑不对。第二用打印法。在输入后打印二维数组在统计后打印count数组在排序后打印排序结果。每个关键节点都打印一次程序跑到哪里开始不对一眼就能看出来。有人觉得加打印麻烦但比起一次次盲猜打印法十分钟内能解决80%的问题。第三测边界值。比如观众数是1节目数是1所有观众都投同一个节目所有节目票数都相同。这些极端情况最容易暴露数组越界和排序稳定性问题。很多人代码在正常数据下跑得好好的一上边界就崩就是因为没做过边界测试。最后再分享一点个人体会这道题我讲了很多遍每次讲都提醒学生别急着抄代码先自己想清楚“二维数组的每一行每一列代表什么”想清楚一次比抄十遍都管用。我个人在实际操作中的体会是这道题最难的从来不是写代码本身而是把题目里的现实场景翻译成数组下标的那一步。一旦你完成了这步翻译后面的统计、排序再复杂也只是方法问题不是思路问题。如果你手头用的是《C语言程序设计第四版何钦铭、颜晖》把第七章数组学扎实然后再把上面这几个扩展方向动手写一遍等你把这个程序做成一个能读文件、能统计百分比、能输出排名的小工具再回头看看当初对着题目发呆的自己你会明显感觉到C语言这扇门算是真的迈进去了。