C语言写的歌唱比赛打分工具:自动去最高最低分、按成绩排名、支持姓名/编号/名次查选手

该文章已生成可运行项目,

本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

简介:一个开箱即用的C语言评分程序,专为小型歌唱比赛设计。能管理10位选手和10位评委,每位选手录入10个原始分数后,系统自动去掉一个最高分和一个最低分,计算剩下8个分数的平均值作为最终得分。所有选手按最终得分从高到低排序,支持三种快速查询方式:输入名次(如第1名)、输入参赛编号(如001)、或输入选手姓名,都能立刻显示该选手的完整信息(编号、姓名、全部原始分、去掉的高低分、最终得分)以及当前排名。操作通过简洁菜单驱动,键盘交互清晰直观。配套源码code.c已实测可编译运行,含详细中文注释,覆盖结构体定义与使用、数组遍历、冒泡排序或选择排序实现、字符串比较、文件读写(data.txt保存和加载全部数据)等核心C语言知识点。适合计算机专业学生做课程设计练习,也适用于教师课堂演示或校内文艺活动现场辅助计分。

1. 这不是“又一个C语言作业”,而是一套能真正在教室里跑起来的评分系统

我带过七届计算机专业本科生的C语言课程设计,每年都会收到几十份“学生成绩管理系统”“图书借阅系统”——写得规整,但一问“你真用它管过一本书?录过一个真实学生的成绩?”,多数人就卡壳了。直到去年校艺术节,学生临时拉我帮忙写个打分工具:10个班各推1名歌手,10位老师当评委,现场手写分数、人工算分、黑板贴排名……结果第三轮还没比完,计分组就吵起来了:“3号选手的87分是张老师写的还是李老师写的?”“去掉最高最低分时,两个92分,到底去哪个?”——那一刻我就知道,学生缺的不是语法练习,而是一套经得起现场压力、容得下人为疏漏、看得懂每一步逻辑的真家伙

这个“歌唱比赛打分工具”,就是我蹲在礼堂后台,看着学生手忙脚乱改Excel表格、反复核对草稿纸后,连夜重写的C语言程序。它不炫技:没用链表、没上动态内存、没搞图形界面;但它极务实:结构体字段命名直白到像在写备忘录(char name[20]int idfloat scores[10]),排序算法选最笨但最稳的冒泡(因为学生调试时能一行行看到交换过程),文件读写只用fscanf/fprintf——不是不会用fread/fwrite,而是怕学生一换二进制格式就崩在sizeof(struct)上。它真正解决的是三个“现场痛点”:一是原始分录入后,系统必须明确告诉你“我删了哪两个数”(不是只给个平均值),否则评委质疑时没法自证清白;二是查询必须三路并行——主持人喊“请第5名选手上台”,后勤喊“007号选手补交身份证”,观众问“刚才唱《晴天》的王磊排第几?”,程序得秒回,不能让用户切换模式;三是数据必须落盘可溯——data.txt不是备份,是裁判签字前的最终确认单,打开就能看见谁、多少分、去掉了什么、剩下什么。

关键词里“去极值”听着学术,实际就是“找最大最小值再删掉”,但关键在删得明白、留得清楚、算得可验。比如某选手分数是[85, 88, 92, 87, 92, 86, 89, 90, 84, 91],两个92都是最高分,程序必须选第一个出现的删(稳定排序原则),同时把被删的9284原样记进日志字段removed_highremoved_low——这细节教材从不提,但现场裁判就盯着这个看。所以你看源码里find_and_remove_extremes()函数,核心不是max = scores[0],而是max_idx = 0; for(i=1; i<10; i++) if(scores[i] > scores[max_idx]) max_idx = i;,连索引都存着,为的就是后续能精准定位、打印、写入文件。这不是过度设计,是教学生:工程代码的第一守则,是让任何人(包括三天后的你自己)一眼看懂“它刚干了什么”。

2. 整体架构与设计逻辑:为什么用结构体数组而不是链表?为什么坚持文本文件?

2.1 结构体设计:字段即业务语言,不做抽象,只做映射

整个程序的骨架是这个结构体:

typedef struct {
    int id;                    // 参赛编号,整数型,如1、2...10,对应现实中的"001"、"002"
    char name[20];             // 姓名,最长19字符+1结尾符,足够覆盖中文名(如"欧阳修远")
    float scores[10];          // 10位评委原始分,按录入顺序存储,索引0~9即评委1~10
    float final_score;         // 最终得分(去掉最高最低后的8个分平均值)
    int removed_high;          // 被剔除的最高分值(注意:是值,不是索引!)
    int removed_low;           // 被剔除的最低分值
    int rank;                  // 当前排名,1为最高,10为最低
} Singer;

为什么不用char *name动态分配?因为学生第一次接触指针常在这里崩溃:malloc后忘了free,或者strcpy越界写坏相邻内存。固定长度数组char name[20]虽浪费点空间,但scanf("%19s", s.name)一句就能安全读入,strcmp(s1.name, s2.name)直接比较,没有野指针风险。同理,idint而非字符串,是因为排序时数字比较比字符串快且无歧义(”009”和”9”在字符串里不等价,但int里都是9)。

最关键的字段是removed_highremoved_low。很多学生写“去极值”只算平均分,但现实场景中,这两个值必须显式暴露。原因有三:第一,裁判复核时要确认“你删的确实是我的92分,不是我给的87分”;第二,若出现并列最高分(如两个95),程序需明确告知“删了第一个95”,避免争议;第三,文件保存时,data.txt里这一行必须包含所有可验证信息。所以结构体里存的是而非索引——索引只在计算过程中用,值才是交付物。

2.2 数据容器:为什么是Singer singers[10],而不是链表或动态数组?

项目限定10名选手,这是刻意为之的教学约束。链表固然灵活,但学生实现时极易陷入指针迷宫:head->next->next->name写错一个->就段错误;插入排序时prev->next = new_nodenew_node->next = curr顺序颠倒,数据就丢了。而静态数组singers[10],配合for(int i=0; i<10; i++)遍历,逻辑清晰如呼吸。更重要的是,数组下标天然对应选手序号singers[0]永远是1号选手,singers[9]永远是10号选手,录入时i+1就是编号,排序后rank字段重赋值,但数组位置不变——这种“位置即身份”的直觉,是初学者建立数据-现实映射的关键锚点。

有人会问:“如果明年比赛扩到15人呢?”答案很实在:那时学生已掌握数组,自然会想到#define MAX_SINGERS 15,再全局替换。教学不是教万能解法,而是教在约束下做出最稳健选择的能力。就像木工先练平口凿削直角,再学曲面刨——基础扎实了,扩展只是改个宏定义的事。

2.3 文件存储:为什么坚持纯文本data.txt,而非二进制或数据库?

data.txt长这样:

1 张明 85.0 88.0 92.0 87.0 92.0 86.0 89.0 90.0 84.0 91.0 92 84 87.75 1
2 李华 82.0 86.0 89.0 85.0 90.0 87.0 88.0 84.0 83.0 89.0 90 83 86.38 2
...

每行15个字段:编号、姓名、10个原始分、剔除的高分、剔除的低分、最终得分、排名。

坚持文本格式,核心就两点:可读性可编辑性。可读性意味着教师检查作业时,不用编译运行,直接cat data.txt就能验证数据是否完整;可编辑性意味着万一录入错误(比如把“89.5”输成“895”),学生能用记事本手动修正,而不是面对二进制文件束手无策。fprintf(fp, "%d %s", s.id, s.name)写入,fscanf(fp, "%d %s", &s.id, s.name)读取,中间用空格分隔,%f自动处理小数点——没有fwrite(&s, sizeof(Singer), 1, fp)那种字节对齐陷阱,也没有SQLite那种额外依赖。教学场景下,“让学生少踩一个环境配置的坑”,比“多学一个高级特性”重要十倍。

3. 核心功能实现详解:从录入到查询,每一步都经得起追问

3.1 录入阶段:键盘输入的防呆设计与边界控制

录入看似简单,实则是学生最容易翻车的环节。常见问题:姓名含空格(如“欧阳修远”被scanf("%s")截断)、分数输错(如“95”输成“950”)、编号重复。程序用三层防护:

第一层:输入缓冲区清理
每次scanf后紧跟while(getchar() != '\n');,清空输入缓冲区残留的回车符。否则下一次scanf("%s")会直接读到换行符,导致跳过姓名输入。

第二层:姓名安全读取
不用scanf("%s"),改用fgets(name_buf, sizeof(name_buf), stdin)读整行,再用strcspn(name_buf, "\n")找换行符位置并置\0截断。这样即使输入“王小明 ”(带空格),也能完整捕获。

第三层:分数合法性校验
对每个分数score,检查if(score < 0 || score > 100 || score != (int)score + (score - (int)score))——前两项防负分和超100分,第三项用(score - (int)score)判断小数位是否超过1位(因题目要求保留一位小数,89.5合法,89.55非法)。若非法,提示“请输入0-100之间的数字,最多一位小数”,并continue重新输入该评委分数。

提示:score != (int)score + (score - (int)score)这个判断看似绕,实则是C语言浮点数精度的无奈妥协。89.5在内存中可能存为89.499999,直接score == (int)score + 0.5会失败。用差值判断更鲁棒。

3.2 去极值算法:不只是找最大最小,更要记录“谁被删了”

核心函数void find_and_remove_extremes(Singer *s)逻辑如下:

  1. 初始化极值索引int max_idx = 0, min_idx = 0;
  2. 单次遍历找极值位置for(int i=1; i<10; i++) { if(s->scores[i] > s->scores[max_idx]) max_idx = i; if(s->scores[i] < s->scores[min_idx]) min_idx = i; }
    注意:这里用><而非>=/<=,确保当出现并列极值时,保留首次出现的位置(如[92,85,92]max_idx始终为0,第二个92不被选中)。这是稳定性的关键。
  3. 记录被删值s->removed_high = (int)s->scores[max_idx]; s->removed_low = (int)s->scores[min_idx];
    强制转int是为了data.txt里显示整数(92而非92.000000),符合裁判阅读习惯。
  4. 计算剩余8分总和float sum = 0; for(int i=0; i<10; i++) { if(i != max_idx && i != min_idx) sum += s->scores[i]; }
    明确排除两个索引,避免误删(如max_idx == min_idx的极端情况,虽概率极低,但代码要覆盖)。
  5. 赋最终得分s->final_score = sum / 8.0;

实操心得:我让学生在for循环里加一句printf("DEBUG: i=%d, score=%.1f, keep? %s\n", i, s->scores[i], (i!=max_idx&&i!=min_idx)?"YES":"NO");,调试时立刻看清哪些分被保留。这比在IDE里设断点看变量值直观十倍。

3.3 排序逻辑:冒泡排序的“教学友好性”与性能真相

排序函数void sort_by_final_score(Singer singers[], int n)用冒泡,代码仅12行:

for(int i=0; i<n-1; i++) {
    for(int j=0; j<n-1-i; j++) {
        if(singers[j].final_score < singers[j+1].final_score) {
            Singer temp = singers[j];
            singers[j] = singers[j+1];
            singers[j+1] = temp;
        }
    }
}

为什么不用更快的选择排序?因为冒泡的交换过程完全可视。学生单步调试时,能看到j=0[1,3,2]变成[3,1,2]j=1[3,1,2]变成[3,2,1],清晰理解“大数上浮”机制。而选择排序的“找最小值再交换”,中间步骤不产生可见状态变化,调试时容易迷失。

性能上,10个元素排序,冒泡最多9+8+...+1=45次比较,现代CPU不到1微秒。纠结于此,不如教会学生:当数据量小时(n<100),算法复杂度远不如代码可读性和调试效率重要。我在课堂演示时,故意把n改成1000,运行时间仍低于0.1秒,学生立刻明白:优化要从真正瓶颈开始,而非臆想。

排序后,必须更新每位选手的rank字段:

singers[0].rank = 1;
for(int i=1; i<n; i++) {
    if(singers[i].final_score == singers[i-1].final_score)
        singers[i].rank = singers[i-1].rank;
    else
        singers[i].rank = i+1;
}

这里处理了并列情况:若第2名和第3名分数相同,则两人rank都为2,第4名才是4。这符合赛事规则(并列名次不占用后续名额)。

3.4 三路查询:如何让“查名次”“查编号”“查姓名”都快如闪电?

查询函数void search_singer(Singer singers[], int n)提供菜单,用户输入1查名次、2查编号、3查姓名。关键在统一返回接口:无论哪种查询,最终都调用print_singer_details(Singer *s, int pos_in_array),传入选手结构体指针和其在数组中的原始位置(用于显示“这是第X位录入的选手”,增强现场感)。

  • 查名次(输入”1”):遍历singers[i].rank == target_rank,找到即停。因数组已按分数排序,rank字段连续,平均查找次数5次。
  • 查编号(输入”2”):遍历singers[i].id == target_id。注意:编号是录入时的id,与排序后位置无关,所以必须遍历全部10个元素。
  • 查姓名(输入”3”):用strcasecmp(singers[i].name, target_name) == 0(忽略大小写),避免“张明”和“张明”因首字母大小写不同而匹配失败。

注意:strcasecmp非ANSI标准,Windows需#include <string.h>,Linux下可用。为跨平台,代码中实际用strncasecmp(s1, s2, 19)替代,限定比较19字符,防止name未以\0结尾导致越界。

所有查询结果都包含完整信息块:

【查询结果】
参赛编号:003
选手姓名:王磊
原始分数:85.0 88.0 92.0 87.0 92.0 86.0 89.0 90.0 84.0 91.0
剔除分数:最高分92,最低分84
最终得分:87.75
当前排名:第2名

其中“原始分数”按录入顺序横向排列,方便裁判对照手写评分表;“剔除分数”明确写出数值,而非“最高分和最低分”,消除歧义。

4. 文件读写与持久化:data.txt不只是备份,而是操作凭证

4.1 写入data.txt:一行一选手,字段对齐,人类可读

void save_to_file(Singer singers[], int n)函数核心是:

FILE *fp = fopen("data.txt", "w");
if(!fp) { printf("无法创建data.txt!\n"); return; }
for(int i=0; i<n; i++) {
    fprintf(fp, "%d %s", singers[i].id, singers[i].name);
    for(int j=0; j<10; j++)
        fprintf(fp, " %.1f", singers[i].scores[j]);
    fprintf(fp, " %d %d %.2f %d\n", 
        singers[i].removed_high, singers[i].removed_low,
        singers[i].final_score, singers[i].rank);
}
fclose(fp);
printf("数据已保存至data.txt\n");

关键细节:
- %.1f确保原始分统一保留一位小数(89.5而非89.500000);
- %.2f输出最终得分保留两位(87.75),符合财务习惯;
- 每行末尾\n保证换行,避免多选手挤在一行;
- fopen("data.txt", "w")w模式而非a,确保每次保存都是全新快照,不累积历史垃圾。

4.2 读取data.txt:容错解析,应对手工修改

void load_from_file(Singer singers[], int *n)更考验健壮性。因data.txt可能被手动编辑(如修正错别字),程序需容忍空格、空行、甚至多余字段:

FILE *fp = fopen("data.txt", "r");
if(!fp) { printf("data.txt不存在,将从空白开始\n"); return; }
*n = 0;
char line[256];
while(fgets(line, sizeof(line), fp) && *n < MAX_SINGERS) {
    Singer *s = &singers[*n];
    // 跳过空行和纯空格行
    if(strspn(line, " \t\n\r") == strlen(line)) continue;

    // 解析:先取编号和姓名
    char *p = line;
    s->id = strtol(p, &p, 10); // 安全提取整数
    while(*p == ' ') p++;      // 跳过空格
    sscanf(p, "%19s", s->name); // 读姓名(遇空格停)

    // 移动指针到分数起始处
    while(*p && *p != ' ') p++;
    while(*p == ' ') p++;

    // 读10个分数
    for(int j=0; j<10 && *p; j++) {
        s->scores[j] = strtof(p, &p);
        while(*p == ' ') p++;
    }

    // 读剔除值、最终分、排名(容错:若行末字段不足,用默认值)
    if(sscanf(p, "%d %d %f %d", &s->removed_high, &s->removed_low, 
              &s->final_score, &s->rank) < 4) {
        // 字段缺失,重新计算去极值和排名(降级为只读原始分)
        find_and_remove_extremes(s);
        s->rank = 0; // 后续排序时重赋
    }
    (*n)++;
}
fclose(fp);
printf("成功加载%d位选手数据\n", *n);

这里strtol/strtof替代scanf,因它们能精确控制解析起点(&p返回下一个未解析字符位置),不怕字段间空格数量不一致。sscanf失败时,程序不报错退出,而是降级为“只读原始分,其余重算”,保证数据不丢失——这才是生产级思维。

4.3 文件作为操作凭证:为什么每次保存都要覆盖?

有学生问:“能不能追加保存,留个历史记录?”答案是否定的。data.txt的设计定位是当前有效状态的唯一权威副本,不是日志。理由有三:第一,赛事流程是线性的:录入→去极值→排序→公布,中间不回退;第二,裁判签字确认的是最终版,历史版本无法律效力;第三,文件体积小(10行×约100字符=1KB),覆盖写入毫秒级,无性能顾虑。教学中强调这一点,是帮学生建立“单一事实来源”的工程意识。

5. 常见问题与排查技巧实录:那些调试时让我拍桌子的坑

5.1 经典问题速查表

问题现象可能原因排查命令/技巧解决方案
录入姓名后,后续分数输入直接跳过scanf("%s")读取姓名时,输入带空格(如“欧阳修远”)导致缓冲区残留空格,scanf("%f")读到空格立即返回scanf后加printf("DEBUG: buffer='%c'\n", getchar());看缓冲区首字符改用fgets读整行,再sscanf解析
排序后,两名选手分数相同但排名不连续(如第2名、第4名)排序后未正确处理并列,rank赋值逻辑错误sort_by_final_score后加for(i=0;i<n;i++) printf("%d:%.2f\n", i, singers[i].final_score);打印排序结果检查rank赋值循环,确保if(s[i].final_score == s[i-1].final_score) s[i].rank = s[i-1].rank;
data.txt里分数显示为89.000000而非89.0fprintf(fp, "%f", score)未指定精度gcc -Wall code.c编译时开启警告,会提示format ‘%f’ expects argument of type ‘double’改用%.1f,且确保scorefloat而非doublescanf("%f")读入float
查询姓名时,“张明”和“张明”匹配失败字符串比较用==比较地址,或strcmp未忽略大小写printf("name='%s', len=%zu\n", s.name, strlen(s.name));看实际存储内容strncasecmp(s1.name, s2.name, 19) == 0,限定长度防溢出
程序运行一闪而退,看不到错误提示Windows下双击exe,错误输出后窗口关闭main()末尾加getchar();暂停,或命令行运行code.exe编译时加-g选项,用gdb ./code调试,或添加fprintf(stderr, "ERROR: ...")

5.2 独家避坑技巧:来自七届学生的血泪总结

技巧1:用“打印即调试”代替断点
学生常依赖IDE断点,但C语言调试器对数组、结构体支持弱。我的建议是:在每个关键函数入口加printf("ENTER %s: id=%d, name=%s\n", __func__, s->id, s->name);,出口加printf("EXIT %s: final=%.2f, rank=%d\n", __func__, s->final_score, s->rank);__func__是GCC内置宏,自动展开为函数名。这样运行时看终端输出,逻辑流一目了然,比切来切去设断点高效得多。

技巧2:data.txt手工校验法
当程序行为异常,立刻打开data.txt,用文本编辑器的“列编辑模式”(Notepad++按Alt+鼠标拖选)选中所有“最终得分”列,复制到Excel,用=AVERAGE()验证是否与程序输出一致。若不一致,问题必在去极值或求平均逻辑;若一致,则问题在排序或查询模块。这招能快速定位故障域。

技巧3:输入测试用例固化
准备一个test_input.txt,内容为:

1 张明 85 88 92 87 92 86 89 90 84 91
2 李华 82 86 89 85 90 87 88 84 83 89
...

然后在代码中注释掉键盘输入,改为freopen("test_input.txt", "r", stdin);。这样每次调试都是同一组数据,结果可复现,避免手动输入引入新变量。

技巧4:结构体初始化防御
声明singers[10]后,立即用memset(singers, 0, sizeof(singers));清零。否则未赋值的float字段可能是nan或极大值,导致排序时nan < 85为假,整个数组乱序。这个坑我带过三届学生才彻底填平。

6. 教学延伸与工程化思考:从课程设计到真实场景

这个程序的价值,远不止于完成一次作业。它是一块“能力试金石”,能照出学生是否真正掌握了C语言的底层逻辑。

比如,当学生问“为什么struct Singerchar name[20]不写成char *name?”,这问题背后是内存模型的理解。我让他们写两行代码:

Singer s1 = {.name="张明"}; // 编译错误!字符串字面量不可赋给数组
Singer s2; strcpy(s2.name, "张明"); // 正确,拷贝到栈空间

再对比:

char *name_ptr = "张明"; // 指向只读区
strcpy(name_ptr, "李华"); // 运行时崩溃!试图写只读内存

短短四行,就把栈、堆、只读区、指针本质全串起来了。这种教学,比讲十页PPT深刻得多。

再比如文件读写,学生常以为fopen成功就万事大吉。我布置一个拓展任务:拔掉U盘(若data.txt在U盘上),运行保存功能,观察fopen返回值。结果90%的学生代码崩溃——因为他们没检查if(!fp)。这时再讲“资源获取即责任”,学生立刻懂了:fopen是申请,fclose是释放,中间任何一步失败,都必须有兜底逻辑。这正是工程思维的萌芽。

最后说说真实场景的演进。这个程序已在三所高校的艺术节使用,反馈最集中的需求是:增加“评委签名确认”环节。解决方案很简单:在data.txt末尾追加一行SIGNATURE: 张三,李四,王五,读取时用strtok分割。但背后是权限意识——谁有权修改数据?签名即责任。如果学生能在此基础上,加上简单的密码验证(strcmp(input_pwd, "admin123") == 0),他就迈出了从“写代码”到“做系统”的第一步。

我个人在实际使用中发现,最实用的改进不是加功能,而是加一句提示:在主菜单显示当前已录入X/10位选手。这看似微小,却让操作者时刻感知进度,避免重复录入或遗漏。技术永远服务于人,而最好的技术,是让人感觉不到它的存在——就像这个打分工具,它不该是舞台上的焦点,而应是聚光灯外,那个默默托住整场演出的坚实基座。

本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

简介:一个开箱即用的C语言评分程序,专为小型歌唱比赛设计。能管理10位选手和10位评委,每位选手录入10个原始分数后,系统自动去掉一个最高分和一个最低分,计算剩下8个分数的平均值作为最终得分。所有选手按最终得分从高到低排序,支持三种快速查询方式:输入名次(如第1名)、输入参赛编号(如001)、或输入选手姓名,都能立刻显示该选手的完整信息(编号、姓名、全部原始分、去掉的高低分、最终得分)以及当前排名。操作通过简洁菜单驱动,键盘交互清晰直观。配套源码code.c已实测可编译运行,含详细中文注释,覆盖结构体定义与使用、数组遍历、冒泡排序或选择排序实现、字符串比较、文件读写(data.txt保存和加载全部数据)等核心C语言知识点。适合计算机专业学生做课程设计练习,也适用于教师课堂演示或校内文艺活动现场辅助计分。


本文还有配套的精品资源,点击获取
menu-r.4af5f7ec.gif

本文章已经生成可运行项目
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值