☰
C语言教务管理系统:顺序表、二叉排序树与快速排序的实战剖析
2026/10/17 12:59:08 网站建设 项目流程

简介:这份资源是华南理工大学数据结构大作业“教务管理系统”的完整项目包,面向正在完成同类课程设计的高校学生,可用于理解数组、链表、树、栈、队列及哈希表在真实业务场景中的组织方法。压缩包共含56个文件,整体仅1.92MB,其中23个C++源码与20个头文件构成教师端、学生端、教务员端三套程序主体;9个文本文件提供课程、学生、教师等模拟数据,3个exe可免环境直接运行,另附1份实验报告文档,方便对照分析系统架构。系统覆盖学籍存储、课程先修关系、操作撤销回溯、选课请求排队等典型功能,并利用散列查找提升检索效率,同时实现文件持久化读写,完整呈现了从数据结构选型到算法优化的实践路径。资源附带的测试数据与说明,能帮助快速验证各模块运行效果。目前已有699人学习/下载,适合作为大作业选题、架构设计或答辩准备的实用范本。

1. 从期末大作业说起:教务管理系统为什么不能只用数组糊弄

期末交课设时,很多同学拿到“教务管理系统”的第一反应是:不就是增删改查吗?但真正动手拆完需求才发觉,学生、课程、成绩三份数据怎么关联,按学号查学生用什么结构,按分数排名走什么算法,处处都在考数据结构。这套源码是某高校数据结构期末大作业的一个完整实现,C 语言写成,把顺序表、二叉排序树、成绩链表、快速排序和文件持久化全部揉进一个可交互的控制台系统。它能解决的现实问题是:几千个学生、上万条选课记录下,怎样保证学号查询不线性扫描,怎样保证退出程序后数据还能原样恢复。这篇解析围绕“结构怎么选、代码怎么组织、跑起来怎么验证、二次开发从哪下手”展开,适合直接下载源码跑一遍验证,也适合当课程设计的起点。

2. 架构设计:一张成绩单背后的三种数据结构

2.1 需求建模:先把大作业翻译成数据操作

大作业的功能清单看起来很多,实际归纳下来就四类:一是学生档案的维护,涉及录入、修改、按学号或姓名查询、按班级列出;二是课程信息的维护,涉及开课、删课、改学分;三是选课与成绩,涉及给某个学号录成绩、改分数、退选,以及按课程统计平均分和挂科名单;四是持久化,程序启动时把上次保存的数据读回内存,退出时把内存写回文件。数据规模按课程设计的常例来估算,学生几千名,选课记录上万条,单机运行,不需要并发。

这四类需求落到数据结构上,对应的其实是三种操作:按主键定位一条记录、按一对多关系遍历子表、按某字段重排输出。主键定位服务于“输入学号,立刻拿到这个学生的档案”;一对多关系服务于“查某门课有多少人选了、每个人考了多少分”;重排服务于“按总分排名、按单科排名、找出不及格名单”。只要这三个操作想清楚,后面选型就不会跑偏。

这里有个容易踩的思维误区:一上来就纠结“链表还是数组”,而不是先问“我的数据量是多少、操作频率最高的是哪种”。这门课设里最频繁的操作是按学号找人和遍历输出成绩单,而不是随机插入一堆记录,所以选型的天平从一开始就倾向连续存储。

2.2 数据结构选型:为什么顺序表打底、二叉排序树做索引

学生和课程这两类主数据我用的都是顺序表,成绩用的单链表,学号索引用的二叉排序树。有人会问,既然是数据结构大作业,为什么不全部用链表显得“更有技术含量”?我的习惯是反过来想:哪个结构对当前操作代价最低就用哪个。

学生档案的数量最多几千,操作里出现频率最高的是按学号查人和遍历输出。顺序表在这两个场景上都有天然优势:数组连续存储,遍历时 CPU 缓存命中率远高于链表的 node 跳转;按下标访问是 O(1),尾部追加也只需要在容量满时 realloc。代价是删除中间元素要搬数据,但本项目删除后会用二叉排序树重建索引,数组有序性根本不影响查询,所以删除可以直接用“尾部元素覆盖被删位置”的办法,把代价压成 O(1)。

成绩记录是一对多的子表,选课数无法提前预估,一个学生退学要撤掉他名下所有成绩。单链表在这里比顺序表好伺候:新成绩头插 O(1),按学生遍历自然走 next 指针,按学号删除只需要维护一个移动指针,这比在数组里反复 memmove 简单。学号索引选二叉排序树而不是哈希表,是因为大作业通常要演示“树”这个考点,同时 BST 支持顺序遍历,将来做“按学号范围输出学生名单”这类需求不需要额外排序,中序遍历一次就够。

2.3 代码目录与模块边界:头文件就是接口

整个工程按编译单元拆成六个方向:main.c 只做菜单循环和命令分发;data.h 放结构体定义和公共常量;list.c 管学生顺序表;bst.c 管学号索引;score.c 管成绩链表和统计;sort.c 管排名用的排序;file.c 管三个数据文件的读写。每个 .c 配一个同名 .h,头文件里只暴露对外的函数签名,结构体内部细节用 typedef 藏起来。

文件职责对外关键符号
data.h结构体、宏、返回码Student / Course / ScoreNode
list.c学生与课程的顺序表list_append / list_remove_by_index
bst.c学号索引bst_insert / bst_search / bst_rebuild
score.c成绩链表与统计score_add / score_remove_by_student / stat_course
sort.c快速排序与名次输出quick_sort_range
file.c二进制持久化save_all / load_all

这样分层的好处是:菜单回调里不会出现“遍历链表找节点再 malloc”这种细节,每个命令只调一个公开接口。二次开发想换存储格式,只动 file.c;想把学生索引从 BST 换成哈希,只动 bst.c 的接口实现,菜单一行不用改。data.h 里还会把所有接口的返回码定义成枚举,比如 0 表示成功、-1 表示参数非法、-2 表示内存不足,调用侧拿到返回码后统一提示,而不是各自打印一段含糊的字符串。

3. 核心模块实现:结构体、索引、排序与落盘的代码细节

3.1 数据模型:三个结构体如何互相关联

先看 data.h 里的三张主表:

#define MAX_NAME_LEN 32 #define MAX_CLASS_LEN 16 #define MAX_COURSE_LEN 32 typedef struct Student { long id; // 学号,主键 char name[MAX_NAME_LEN]; // 姓名 char class[MAX_CLASS_LEN]; // 班级 } Student; typedef struct Course { int course_id; // 课程编号,主键 char name[MAX_COURSE_LEN]; double credit; // 学分 } Course; typedef struct ScoreNode { long student_id; // 外键,指向 Student.id int course_id; // 外键,指向 Course.course_id double score; // 成绩 struct ScoreNode *next; // 下一门选课记录 } ScoreNode;

设计上的关键点有三处。第一,学号统一用 long 不用 int,课程设计里学号可能长达九到十位,int 在部分 32 位平台上一旦超过 21 亿就直接溢出成负数,用 long 把余量留足。第二,学生姓名用定长数组,虽然比 char* 多占一点内存,但换来的是结构体可以直接 fwrite 落盘,不用单独处理字符串的内存释放和深拷贝。第三,ScoreNode 用两个外键字段关联另外两张表,关联的是整数而不是字符串,就是为了避开“改名后成绩对不上号”的问题。

3.2 顺序表管理:学生档案的追加、删除与动态扩容

学生表封装成 StuList,底层是一个带容量和大小的动态数组:

typedef struct { Student *data; // 动态数组 int size; // 当前记录数 int capacity; // 当前容量 } StuList; void list_init(StuList *list, int cap); void list_destroy(StuList *list); int list_append(StuList *list, const Student *stu) { if (list == NULL || stu == NULL) return -1; if (list->size == list->capacity) { int new_cap = list->capacity * 2; // 倍增扩容 if (new_cap < 8) new_cap = 8; Student *tmp = (Student *)realloc(list->data, new_cap * sizeof(Student)); if (tmp == NULL) return -2; // 内存不足 list->data = tmp; list->capacity = new_cap; } list->data[list->size++] = *stu; return 0; }

扩容用倍增而不是每次加一,理由很现实:反复 realloc 会把内存搬来搬去,从几个学生增长到几千个学生时,倍增值可以让 realloc 的次数降到十几次,均摊到每次 append 上接近 O(1)。这里 realloc 的返回值没有直接覆盖原指针,而是先存进 tmp,只有成功后才更新 list->data,这个习惯能避免 realloc 失败时原数据指针被置空导致的内存丢失事故。删除操作选择“覆盖尾部”来保持 O(1):

int list_remove_by_index(StuList *list, int idx) { if (list == NULL || idx < 0 || idx >= list->size) return -1; list->data[idx] = list->data[list->size - 1]; list->size--; return 0; }

代价是学生顺序会乱,但前面说过本项目的查询全部走 BST 索引,删除后重建索引就能恢复一致,数组本身无需保持有序。如果将来要做“按班级连续输出学号段”,再改成前移式删除也不难,这就是“性能换语义”的取舍。

3.3 二叉排序树索引:pos 字段如何指挥顺序表

BST 节点里除了学号还要存一个 pos 字段,它指向这个学生在顺序表里的下标,相当于索引的“书签”:

typedef struct BSTNode { long id; // 学号 int pos; // 对应 StuList.data[] 的下标 struct BSTNode *left; struct BSTNode *right; } BSTNode; BSTNode* bst_insert(BSTNode *root, long id, int pos) { if (root == NULL) { BSTNode *node = (BSTNode *)malloc(sizeof(BSTNode)); node->id = id; node->pos = pos; node->left = node->right = NULL; return node; // 挂到父节点的左或右 } if (id < root->id) root->left = bst_insert(root->left, id, pos); else if (id > root->id) root->right = bst_insert(root->right, id, pos); else root->pos = pos; // 学号重复:更新位置 return root; } int bst_search(BSTNode *root, long id) { while (root) { if (id == root->id) return root->pos; if (id < root->id) root = root->left; else root = root->right; } return -1; // 查不到 }

bst_search 的返回值就是顺序表下标,调用方拿到下标后直接 list->data[pos] 就能读档案,复杂度从线性扫描的 O(n) 降到平均 O(log n)。这里有个大作业里常见的耦合问题:顺序表删除学生后,被删下标之后所有元素的位置都变了,BST 节点里的 pos 就成了过时数据。解决思路是在公共接口里约定“删除后统一重建索引”:

BSTNode* bst_rebuild(const StuList *list) { BSTNode *root = NULL; for (int i = 0; i < list->size; i++) root = bst_insert(root, list->data[i].id, i); return root; }

第一次写这套代码时我直接把新 root 覆盖旧 root,结果内存泄漏。这里要记得先遍历旧树 free 所有节点再重建,或者在调用侧把旧树句柄交给 bst_destroy。另外,如果想给索引增加“按学号段输出学生”的能力,可以加一个中序遍历接口,BST 天然有序,中序遍历输出出来的就是升序学号序列。

3.4 成绩链表与统计排序:一对多关系下的遍历与排名

成绩记录全部挂在一条单链表上,新增记录用头插:

void score_add(ScoreNode **head, long sid, int cid, double sc) { ScoreNode *node = (ScoreNode *)malloc(sizeof(ScoreNode)); node->student_id = sid; node->course_id = cid; node->score = sc; node->next = *head; // 新节点成为链表头 *head = node; }

按学号删除成绩是常见的级联操作,删除学生时必须同时清掉他名下的所有选课记录:

void score_remove_by_student(ScoreNode **head, long sid) { ScoreNode *prev = NULL, *cur = *head; while (cur) { if (cur->student_id == sid) { ScoreNode *dead = cur; if (prev) prev->next = cur->next; else *head = cur->next; cur = cur->next; free(dead); } else { prev = cur; cur = cur->next; } } }

这段代码有两个细节值得抠。一是入参用二级指针 ScoreNode **head,因为删除头节点时函数内部要改写链表头本身,只传一级指针改不了调用方的 head;二是删除节点后 cur 要继续往后走,但 prev 要保持不变,因为同一个学生可能有多门课,不能漏删,我在初版里把 cur 提前 next 了,导致挂在后面的同学生成绩记录全部悬空,后来加测试数据才抓出来。

成绩统计需要把某个课程的学生成绩单独抽出来排序,排序目标用轻量结构体数组装:

typedef struct { long student_id; double score; } ScoreItem; void quick_sort_range(ScoreItem *arr, int left, int right, int asc) { if (left >= right) return; int i = left, j = right; double pivot = arr[(left + right) / 2].score; while (i <= j) { while (asc ? arr[i].score < pivot : arr[i].score > pivot) i++; while (asc ? arr[j].score > pivot : arr[j].score < pivot) j--; if (i <= j) { ScoreItem t = arr[i]; arr[i] = arr[j]; arr[j] = t; i++; j--; } } quick_sort_range(arr, left, j, asc); quick_sort_range(arr, i, right, asc); }

这个写法是取区间中点作为 pivot 的快速排序,原地交换,空间上只有递归栈。asc 参数控制升序降序:asc=1 时找分界用的是小于号和大于号,成绩从低到高排;asc=0 时比较方向反过来,从高到低排。对几百条成绩来说排序时间在毫秒级,即使上万条也扛得住;要是换成选择排序,排名操作在数据量上来后会明显卡顿。需要注意快速排序是不稳定排序,成绩并列时名次先后不保证,如果课设要求“相同分数按学号升序”,排序前要把学号作为次要条件,比较函数里写成先比 score 再比 student_id。

3.5 文件持久化:断电后如何把数据原样找回来

学生和课程是顺序表,直接“先写数量、再写数组”就行:

int save_students(const StuList *list, const char *path) { FILE *fp = fopen(path, "wb"); if (fp == NULL) return -1; fwrite(&list->size, sizeof(int), 1, fp); // 先写记录数 fwrite(list->data, sizeof(Student), list->size, fp); // 再写数组 fclose(fp); return 0; } int load_students(StuList *list, const char *path) { FILE *fp = fopen(path, "rb"); int n = 0; if (fp == NULL) return -1; fread(&n, sizeof(int), 1, fp); if (n <= 0 || n > 100000) { fclose(fp); return -2; } // 脏数据保护 list->capacity = n * 2; list->data = (Student *)malloc(sizeof(Student) * list->capacity); fread(list->data, sizeof(Student), n, fp); list->size = n; fclose(fp); return n; }

读入时的 n 必须做合法性检查,否则一个损坏文件可能让 malloc 申请几个 G 内存然后直接崩溃。成绩链表不能直接 fwrite 整条链表,因为 ScoreNode 里的 next 是指针,写进文件的是内存地址,下次进程启动读回来就是悬空指针。常见做法是像下面这样先铺平成数组再落盘,加载时逐条头插重建:

int save_scores(const ScoreNode *head, const char *path) { FILE *fp = fopen(path, "wb"); int cnt = 0; for (const ScoreNode *p = head; p; p = p->next) cnt++; fwrite(&cnt, sizeof(int), 1, fp); for (const ScoreNode *p = head; p; p = p->next) { long sid = p->student_id; int cid = p->course_id; double sc = p->score; fwrite(&sid, sizeof(long), 1, fp); fwrite(&cid, sizeof(int), 1, fp); fwrite(&sc, sizeof(double), 1, fp); } fclose(fp); return 0; }

字段逐个写而不是写整个结构体,就是为了把 next 指针隔离在文件之外。三条数据文件都建议放在 data/ 目录下,路径在 data.h 里用宏定义,这样二次开发换目录只需要改一处。

4. 避坑记录:文件持久化与索引维护的五个教训

4.1 数据落盘的两处翻车现场

现象一:程序退出时明明调了保存函数,成绩文件也确实生成了,第二天双击运行想继续录成绩,程序一启动就段错误。原因:初版 save_scores 直接把 ScoreNode 链表从头 fwrite 到文件,把 next 指针的内存地址也写进去了,加载时反序列化出来的 next 指向一个根本不存在的地址。解决:改成上文那种“不写指针、逐字段铺平”的格式,加载时从文件读出 sid/cid/score 三个值后重新 malloc 并头插。

现象二:学号录入后反复换环境编译,换了个编译器结果控制台显示学号变成负数。原因:学号在 data.h 里一度定义成 int,九位数的学号在部分平台 int 直接溢出;或者 printf 用了 %d 去打印 long 型。解决:统一用 long 存学号,所有格式化输出用 %ld,且在 load 端检查 n 必须大于 0 且小于 100000,防止脏文件把大小读成一个天文数字导致崩溃。

4.2 索引与删除的关联问题

现象三:删除一个学生后,按学号查询偶尔还能查出已删除的档案,或者查出来的学生名字跟学号对不上。原因:删学生只动了顺序表,没动 BST,索引里残留的 pos 指向了删除后被覆盖的新学生。解决:删除接口签名设计成返回新的 BST 根节点,在删除函数末尾调用一次 bst_rebuild,代价是几千条记录的全量遍历,对课设数据量来说可以忽略。

现象四:统计某课程成绩时,人数永远比选课名单多出几条,多出来的是已退学学生的残留记录。原因:remove_student 只删了学生表,成绩链表里该学号对应的节点还在。解决:把成绩清理动作放进删除学生的业务流程里,先调 score_remove_by_student 再调 list_remove_by_index,顺序不能反,因为成绩链表长度一变,学生下标才能重新确定。这个场景大作业验收时经常被当场抓包,属于最容易翻车的业务逻辑。

4.3 运行环境的坑:中文乱码与路径问题

现象五:源码文件保存成 UTF-8,在 Windows 控制台编译运行,所有中文菜单全变乱码,但换到 Linux 终端又正常。原因:Windows 控制台默认代码页是 GBK,而 GCC/MinGW 默认把源码字符串按 UTF-8 编码编译,两边编码不一致。解决:在 main 函数开头调用 SetConsoleOutputCP(65001) 切到 UTF-8 输出;或者源码另存为 GBK 编码。前者更适合保持工程文件统一 UTF-8,后者只影响当前机器,我一般推荐前者。

还有一个容易被忽略的是路径:Windows 下如果 data 目录不存在,fopen 的路径中含 data/ 时会直接失败,保存时不会立刻报错,但加载时 file==NULL 就被当成“首次运行”,用户以为数据丢了。解决:main 里先调用一次 EnsureDataDir,用 mkdir(data) 建目录再进菜单循环。

5. 运行与验证:从源码到可以演示的完整流程

5.1 编译环境与构建命令

这套代码只依赖 C 标准库,没有第三方包。Linux/macOS 下直接用 gcc 或 clang;Windows 下装 MinGW-w64 后也能编过。下载工程后,在源码目录直接执行:

gcc -std=c11 -Wall -Wextra -o edu main.c list.c bst.c score.c sort.c file.c

-Wall -Wextra 打开之后,初版代码会暴露几个“变量声明未使用”和“符号隐式声明”的警告,建议一条都别放过。如果目录下已经放了 Makefile,直接 make && ./edu 即可;没有 Makefile 就手动敲上面的 gcc 命令,注意顺序,目标文件 edu 放在前面,后面按依赖关系列出全部 .c 文件。

注意:Windows 下如果控制台中文乱码,先按第 4 章第 3 条的方法在 main 里设置代码页,再重新编译。

编译成功后,启动进程会先由 load_all 尝试从 data/ 下读取三个 .dat 文件,读不到就按空数据初始化,不阻塞用户录入。

5.2 菜单主循环与命令分发

main.c 不做任何业务,只负责把命令号翻译成各个模块的调用:

int main(void) { StuList students; ScoreNode *scores = NULL; list_init(&students, 8); load_all(&students, &scores); // 恢复上次会话 BSTNode *idx = bst_rebuild(&students); // 重建索引 int running = 1, cmd = 0; while (running) { print_menu(); printf("请选择(1-9,0 退出): "); if (scanf("%d", &cmd) != 1) break; // 非法输入直接退出 switch (cmd) { case 1: add_student(&students, &idx); break; case 2: remove_student(&students, &scores, &idx); break; case 3: query_student(&students, idx); break; case 4: add_score(&scores); break; case 5: stat_course(&scores); break; case 6: rank_students(&students, &scores); break; case 0: save_all(&students, &scores); running = 0; break; } } bst_destroy(idx); list_destroy(&students); score_destroy(scores); return 0; }

注意看两个设计点。第一,remove_student 的入参同时带了 &students、&scores 和 &idx,因为删除操作涉及顺序表、成绩链表、索引三处联动,函数内部自己保证一致性;第二,保存动作只挂在 0 号退出命令上,用户如果强行关终端,数据确实会丢到上一轮保存点,这是控制台课设最常见的行为,演示时一定要提醒自己走菜单退出。

5.3 整链验证:从录入到重启

拿到代码后不要急着通读全部源码,先用一组可控数据跑通主链路,验证点用表格列清楚:

步骤操作预期结果
1添加学生,学号输入 20240001菜单切回主界面
2添加课程,课程号 101课程列表出现该课程
3为 20240001 录入课程 101 成绩 88成绩链表新增一条记录
4按学号查询 20240001显示姓名、班级、课程成绩 88
5按课程 101 统计输出平均分 88,人数 1
6选 0 退出,再 ./edu 启动直接按学号查,能查到同一份档案和成绩

第六步是这条链路里最关键的一步,它验证的是文件持久化的完整性:学生表走的是数组整体读写,成绩表走的是逐字段铺平重建,两者加载路径不同,只要启动后能查到昨天录入的数据,就说明两份文件的序列化都没写错。接着再做删除测试:删除 20240001,再查成绩统计,人数应该是 0,这一步能验证级联删除是否生效。最后可以往学生表里塞 2000 条学号连续递增的记录,手动感受一下 BST 在数据几乎有序时的性能退化,这就是紧接着要聊的改造点。想快速造批量数据的话,写个 for 循环调 list_append 就好,不必真的在菜单里按两千次回车。

6. 二次开发:把大作业改造成可维护项目的三条路线

6.1 索引改造:学号索引从 BST 换成哈希表

如果查询性能是首要目标,把 bst.c 的接口实现整体替换成哈希表即可。学号用 long 做 key,哈希函数用除留余数法(id % table_size),冲突用链表法解决。接口函数保持 bst_insert / bst_search / bst_rebuild 三个签名不动,菜单和调用侧一行不改。table_size 取大于学生容量两倍的质数,装填因子维持在 0.5 上下,平均查找 O(1)。改造后的对比实验很好写:同一批 5000 条学号,BST 退化时可能要两千次比较,哈希表只会触发零星几次冲突。

6.2 存储升级:从自定义二进制文件到 SQLite

自定义二进制格式最怕两个问题:结构体布局依赖编译器的字节对齐,换编译器可能读不了旧文件;成绩、选课等关联数据一旦遗漏级联删除,整个库的引用完整性就崩了。升级路径是引入 SQLite,把三张表建成真正的数据库表,外键约束交给数据库层。迁移思路是先用现成的 save_all 导出一份 CSV,再写个小脚本把 CSV 灌进 SQLite 新表。代价是工程里多了一个 sqlite3 的库文件,构建命令要补 -lsqlite3,但对后续扩展相当值得。

6.3 分层重构:把菜单回调里的业务规则搬进模块接口

最后一条路线针对的是代码可维护性。初版的菜单回调里有不少“边读链表边打印”的代码,统计平均分、判断是否及格这类业务规则散落在 main.c 里。重构时把规则收回到 score.c 的公开接口,例如 stat_course 直接返回平均分和挂科名单,rank_students 直接输出完整排名表,菜单函数只管调用。再把每个接口的返回码整理成 data.h 里的常量枚举,调用侧统一做错误提示。做完这层改造,就可以开始给 stat_course 这类纯函数补单元测试,输入一组成绩断言平均值,大作业阶段就算迈入工程化门槛了。

这套代码我前后拆过两遍,第一遍只顾着跑通菜单,第二遍才意识到“删除学生必须连带清成绩和索引”这种一致性问题的分量。从那以后每次拿到课设源码,我都会先做一遍“录入-重启-再查询”的持久化闭环测试,再动手改任何一行。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询