30、综合实战项目:学生成绩管理系统(链表+文件+排序+查找的综合应用)

终于到了这门课的压轴项目。

说实话,每次我带新人或者给学生讲到这里,都会多说几句。因为前面学的那些零散知识点——链表、文件操作、排序、查找——就像一堆散落的零件。而这个项目,就是让你亲手把它们组装成一台能跑起来的机器。

我个人习惯把这种综合项目叫做「毕业设计」。不是学校那种毕业设计,而是你作为C语言学习者的一次「技术成年礼」。做完它,你才算真正把C语言用起来了。

项目需求:我们要做什么?

先别急着写代码。我见过太多人上来就敲键盘,结果写到一半发现结构乱了,又删掉重来。嗯,咱们先花5分钟把需求理清楚。

这个学生成绩管理系统,说白了就是管理一堆学生的成绩数据。它需要支持以下功能:

  • 添加学生记录:输入学号、姓名、各科成绩
  • 删除学生记录:按学号删除
  • 修改学生记录:按学号修改成绩
  • 查询学生记录:按学号或姓名查找
  • 显示所有记录:以表格形式输出
  • 排序:按总分或单科成绩排序
  • 文件持久化:程序退出时保存,启动时加载

你想想看,这些功能是不是把咱们前面学的知识点全串起来了?链表负责动态存储,文件负责数据不丢失,排序和查找负责让数据好用。

系统架构设计

动手之前,我建议你先画一张图。哪怕画在草稿纸上都行。下面这张SVG图是我自己常用的结构,你可以参考:

学生成绩管理系统架构图 用户交互层(菜单驱动) 添加/删除/修改 查询(学号/姓名) 排序(总分/单科) 显示全部 核心数据结构:单向链表 struct Student { int id; char name[20]; float scores[3]; float total; struct Student *next; } 文件持久化(二进制/文本) 程序启动 → 从文件加载链表 | 程序退出 → 链表写入文件

看到没?整个系统分四层。最上层是菜单,中间是功能模块,核心是链表,底层是文件。每一层只跟相邻层打交道,这就是模块化设计的思想。

数据结构定义

咱们先定义学生结构体。这里我有个小建议:把成绩设计成数组,而不是单独定义语文、数学、英语三个变量。为什么?因为数组配合循环,代码会简洁很多。

#define MAX_NAME 20
#define SUBJECT_NUM 3

typedef struct Student {
    int id;                 // 学号
    char name[MAX_NAME];    // 姓名
    float scores[SUBJECT_NUM]; // 三门课成绩
    float total;            // 总分
    float average;          // 平均分
    struct Student *next;   // 指向下一个节点
} Student;
我的经验:total和average其实可以通过scores实时计算。但我习惯把它们存起来,因为排序和显示时不用每次都算一遍。这叫「空间换时间」,嵌入式里经常这么干。

核心功能实现

1. 链表的创建与插入

链表操作是基本功。我见过很多新手在插入节点时忘记更新指针,结果链表断了。记住一个原则:先接后断

Student* insertStudent(Student *head, Student *newNode) {
    if (head == NULL) {
        newNode->next = NULL;
        return newNode;
    }
    // 按学号升序插入
    Student *prev = NULL, *curr = head;
    while (curr != NULL && curr->id < newNode->id) {
        prev = curr;
        curr = curr->next;
    }
    newNode->next = curr;
    if (prev == NULL) {
        head = newNode;  // 插在头部
    } else {
        prev->next = newNode;  // 插在中间或尾部
    }
    return head;
}
避坑指南:我曾经在项目里忘记处理head为NULL的情况,结果程序一启动就崩溃。后来我养成了一个习惯:任何链表操作函数,第一行先检查head是否为NULL

2. 文件读写

文件操作是让数据「活」起来的关键。我个人偏爱二进制文件,因为读写快,而且不会出现文本编码问题。

// 保存链表到文件
int saveToFile(Student *head, const char *filename) {
    FILE *fp = fopen(filename, "wb");
    if (fp == NULL) return -1;
    
    Student *curr = head;
    while (curr != NULL) {
        fwrite(curr, sizeof(Student), 1, fp);
        curr = curr->next;
    }
    fclose(fp);
    return 0;
}

// 从文件加载链表
Student* loadFromFile(const char *filename) {
    FILE *fp = fopen(filename, "rb");
    if (fp == NULL) return NULL;
    
    Student *head = NULL;
    Student temp;
    while (fread(&temp, sizeof(Student), 1, fp) == 1) {
        Student *node = (Student*)malloc(sizeof(Student));
        memcpy(node, &temp, sizeof(Student));
        node->next = NULL;
        head = insertStudent(head, node);
    }
    fclose(fp);
    return head;
}
注意:二进制文件里存的是结构体的内存映像。如果你修改了结构体定义,旧文件就读不出来了。所以正式项目中,我一般会在文件头部加一个版本号字段。

3. 排序功能

排序我推荐用冒泡排序,虽然效率不是最高,但胜在简单、稳定、好理解。对于学生成绩这种数据量(一般几百条),冒泡完全够用。

void sortByTotal(Student *head) {
    if (head == NULL || head->next == NULL) return;
    
    int swapped;
    Student *ptr;
    Student *last = NULL;
    
    do {
        swapped = 0;
        ptr = head;
        while (ptr->next != last) {
            if (ptr->total < ptr->next->total) {
                // 交换数据,不交换节点
                Student temp = *ptr;
                *ptr = *(ptr->next);
                *(ptr->next) = temp;
                // 修复指针
                Student *tmpNext = ptr->next;
                ptr->next = tmpNext->next;
                tmpNext->next = ptr;
                if (ptr == head) head = tmpNext;
                swapped = 1;
            }
            ptr = ptr->next;
        }
        last = ptr;
    } while (swapped);
}
一个小技巧:交换节点数据比交换节点本身简单得多。除非你有特殊要求(比如保持节点地址不变),否则直接交换数据域就行。

4. 查找功能

查找分两种:按学号(精确查找)和按姓名(模糊查找)。学号是唯一的,所以找到第一个就返回。姓名可能有重名,所以需要返回所有匹配项。

// 按学号查找(精确)
Student* findById(Student *head, int id) {
    Student *curr = head;
    while (curr != NULL) {
        if (curr->id == id) return curr;
        curr = curr->next;
    }
    return NULL;
}

// 按姓名查找(模糊)
void findByName(Student *head, const char *name) {
    Student *curr = head;
    int found = 0;
    while (curr != NULL) {
        if (strstr(curr->name, name) != NULL) {
            printStudent(curr);
            found = 1;
        }
        curr = curr->next;
    }
    if (!found) printf("未找到匹配的学生\n");
}

菜单与主循环

最后是用户交互部分。我习惯用do-while循环,因为菜单至少显示一次。

void showMenu() {
    printf("\n===== 学生成绩管理系统 =====\n");
    printf("1. 添加学生\n");
    printf("2. 删除学生\n");
    printf("3. 修改成绩\n");
    printf("4. 查找学生\n");
    printf("5. 显示全部\n");
    printf("6. 排序(按总分)\n");
    printf("7. 保存并退出\n");
    printf("请选择: ");
}

int main() {
    Student *head = loadFromFile("students.dat");
    int choice;
    
    do {
        showMenu();
        scanf("%d", &choice);
        
        switch(choice) {
            case 1: head = addStudent(head); break;
            case 2: head = deleteStudent(head); break;
            case 3: head = modifyStudent(head); break;
            case 4: searchStudent(head); break;
            case 5: displayAll(head); break;
            case 6: sortByTotal(head); displayAll(head); break;
            case 7: saveToFile(head, "students.dat"); break;
            default: printf("无效选项\n");
        }
    } while (choice != 7);
    
    // 释放链表
    Student *curr = head;
    while (curr != NULL) {
        Student *temp = curr;
        curr = curr->next;
        free(temp);
    }
    return 0;
}
我曾经犯过的错:程序退出时忘记释放链表内存。虽然操作系统会回收,但在嵌入式设备上,长期运行的程序如果内存泄漏,后果很严重。所以养成好习惯:谁分配,谁释放

项目总结

这个项目看起来代码量不小,但拆开来看,每个模块其实都不难。你想想看:

  • 链表操作:就是增删改查,跟咱们前面练的一样
  • 文件操作:就是fread/fwrite,没什么新花样
  • 排序:冒泡排序,老朋友了
  • 查找:顺序查找,简单直接

说白了,这个项目就是把以前学过的零件拼在一起。真正考验你的不是某个知识点有多难,而是你能不能把它们组织成一个完整的系统。

我个人建议你亲手敲一遍代码,不要复制粘贴。哪怕照着上面的代码一个字一个字打,收获都比看十遍大。遇到bug也别慌——我写这个项目的时候也调了整整一个下午。调试的过程,才是真正长本事的过程。

好了,动手试试吧。


公众号:蓝海资料掘金营,微信deep3321