C语言课程设计:教务信息管理系统十字交叉链表与文件存盘实现

发布时间:2026/10/28 4:53:45
C语言课程设计:教务信息管理系统十字交叉链表与文件存盘实现

简介:面向计算机相关专业C语言课程设计学习者,聚焦教务信息管理系统的完整实现思路与实验报告写作参考,适合需要完成课程设计、梳理链表综合应用或准备答辩的学生。压缩包内仅含1个doc文档,约361KB,以实验报告为主体,整合程序简介、功能说明、数据结构图解与源代码流程图,便于对照理解系统从需求到实现的整体结构。文档围绕班级、学生、课程、成绩四重十字交叉链表展开,说明新建、插入、删除、排序、查找、统计均分与不及格信息、模拟打印及多文件保存等模块的组织方式,并交代模块化编译和图形化中文菜单交互的设计取舍。已有1120人学习,可作为课程设计选题、报告撰写和链表项目复盘的参考资料。

1. 教务信息管理系统 C 语言课程设计:一套能编译、能存盘的十字交叉链表实现

教务信息管理系统这个题目年年出现在 C 语言课程设计的选题表上,但真正能编译、能存盘、能按「班级 → 学生 → 课程 → 成绩」四层查下来的实现并不多。这份资源给的就是一套完整跑通的版本:九个.c文件分模块,在 TC 里挂一个.prj工程就能生成jiaowu.exe,数据用十字交叉链表管理,保存时自动拆成四个纯文本文件,重新打开能原样重建整条链。它适合两类人:正在赶课程设计、需要一份能对着改的骨架的同学,以及想把指针、链表、文件读写重新过一遍的初级开发者。下面按「结构怎么设计 → 工程怎么编 → 文件怎么存 → 坑怎么排」的顺序推下去,代码可以直接抄。

2. 四层链表怎么搭:从班级到成绩的结构设计与内存布局

这套实现里最值得拆的不是菜单怎么画,而是数据怎么挂。四层链表的挂法决定了后面插入、删除、排序、统计四个模块好不好写。先用一节把选型讲透,再落到结构体字段和建链代码上,后面所有功能都是在这套骨架上长出来的。

2.1 为什么用链表而不用二维数组

最直接的诱惑是二维数组:一个班一张表,行是学生,列是课程,成绩往格子里一填就完事。真动手就发现三处硬伤。第一,学生数和课程数都写在输入里,不是常量,数组开小了溢出、开大了浪费;第二,TC 2.0 默认 small 内存模型,数据段只有 64KB,一个班五十人乘十门课再乘四个班级,光成绩矩阵就能把堆吃光;第三,二维数组表达不了「同一门课考两次」——重修是这份课设的明确需求,成绩链必须是变长的。

链表把这些都化解了:每个节点单独malloc,插入一个学生只在班级的学生链尾部接一个节点,删除一个班只改前驱的next1,重修再挂一个成绩节点就行。代价是没有随机访问,查一个人得从班级头一路走下来,所以后面才有了「断点查找」这种按学号前缀、姓名子串匹配的补丁式交互。

这里要澄清一个容易被名字带偏的地方:文档里叫「十字交叉链表」,但它不是教科书上那种行链表与列链表互相穿插的真正十字链表。它的实际结构是三级单向链表纵向嵌套——班级节点持有学生链头指针,学生节点持有课程链头指针,课程节点持有成绩链头指针——同时每个下层节点用fall1/fall2/fall3这几个字符数组冗余记录自己属于哪个班级、哪个学生、哪门课程。这种「链表 + 回指标记」的组合,好处是文件持久化时可以只存扁平记录、读回来再按标记重新挂链,坏处是标记一旦写错,整条链就挂歪,第 5 章会专门讲这个坑。

2.2 四个结构体的字段与定长数组的取舍

原文档给出的字段尺寸非常紧凑,student_no[13]刚好装下 12 位学号,class_no[5]刚好装下 4 位班号,一个字节余量都没留。这种写法在那个存储紧张的年代是本能反应,放到今天就是定时炸弹:学号只要多一位,strcpy就会把相邻字段的第一个字节冲掉,表现出来是「姓名莫名其妙变成乱码」。下面的定义在保留字段语义的前提下,把每个字符数组都放大到能容纳结尾的\0,这是我建议直接采用的版本。

/* jiaowu.h —— 四个链表节点,字段尺寸在原文档基础上放大到安全值 */ typedef struct scores { /* 成绩链:一门课的多次考试 */ char fall3[8]; /* 从属标记:fall3[0]班级 fall3[1]学生 fall3[2]课程 */ char number[4]; /* 第几次考试,1 表示首修,2 起为重修 */ char exam_score[8]; /* 卷面分 */ char usual_score[8]; /* 平时分 */ char coll_score[8]; /* 综合分,存字符串便于直接写文本 */ char date[16]; /* 考试学期,如 2005-2006 */ struct scores *next4; } SCORE; typedef struct subjects { /* 课程链 */ char fall2[8]; /* 从属:fall2[0]班级 fall2[1]学生 */ char course_no[12]; char course_name[20]; char credit[8]; /* 学分 */ struct subjects *next3; SCORE *chead; /* 成绩链头指针 */ } SUBJECT; typedef struct students { /* 学生链 */ char fall1[4]; /* 从属:fall1[0]班级 */ char student_no[14]; char name[20]; char sex[4]; /* 0 男 1 女 */ char age[4]; char phone[14]; /* 寝室电话 */ char com_phone[14]; /* 手机,0 表示没有 */ char jia_quan[10]; /* 加权分 */ char all_credit[8]; /* 已得总学分 */ struct students *next2; SUBJECT *suhead; /* 课程链头指针 */ } STUDENT; typedef struct classes { /* 班级链 */ char class_no[8]; struct classes *next1; STUDENT *sthead; /* 学生链头指针 */ } CLASS;

字段为什么全部用字符串而不是int/float,这是这套实现里最实用的一个决定。分数、学分、加权分如果定义成浮点,保存时要么用fwrite写二进制(文件里全是乱码,改都改不了),要么用fprintf("%f")写(精度和格式不可控,读回来还要处理多余空格)。全用字符串就简单了:fprintf(fp, "%s", s->jia_quan)原样写出去,fscanf(fp, "%s", buf)原样读回来,中间不做任何转换,文件用记事本打开就是一张能看懂的表格。代价是每次参与计算都要atof转一次,而且得自己保证写入的是合法数字串。

fall1/fall2/fall3这三个标记数组是最容易看漏的设计。它们不参与任何业务逻辑,唯一作用是在保存和打开之间做「扁平化 ↔ 树形」的映射。保存时把四层链分别导出成四个文件,每条记录带上自己的从属标记;打开时先读班级建班级链,再读学生、按fall1[0]找到对应班级挂上去,依此类推。没有这三个标记,四层链就无法从四个独立文件里还原出原来的父子关系。

2.3 按顺序建链:班级 → 学生 → 课程 → 成绩的四层嵌套

建链的驱动方式是这套程序里最有辨识度的交互约定:任何一层输入00就结束当前层、退回上一层。课程层输入00退回学生层,学生层输入00退回班级层,班级层输入00结束整个建链过程。这个约定必须写进每一层循环的判断里,漏掉一层就会出现「在课程层输 00 结果退到了主菜单」这类灵异现象。

四层嵌套最忌讳写成四个独立的平铺函数、最后再想办法关联,那样从属标记根本填不上。正确做法是让上层函数把下层的头指针直接接走,同时把当前层的编号透传下去。下面这段代码是学生层和课程层的骨架,班级层和成绩层同理。

/* in_out.c —— 建链核心:学生 -> 课程 -> 成绩 的嵌套输入 */ /* 成绩链:一门课可能有多次考试,及格即停,不及格询问是否录重修 */ SCORE *input_scores(char cls_idx, char stu_idx, char sub_idx) { SCORE *head = NULL, *tail = NULL, *node; float usual, exam, coll; int round = 1; char ch; while (1) { printf("第 %d 次考试 平时分 / 卷面分:", round); scanf("%f %f", &usual, &exam); coll = usual * 0.3f + exam * 0.7f; /* 综合分权重,按教学大纲改 */ node = (SCORE *)malloc(sizeof(SCORE)); if (node == NULL) { printf("内存不足\n"); break; } node->fall3[0] = cls_idx; /* 三个从属索引一次填满 */ node->fall3[1] = stu_idx; node->fall3[2] = sub_idx; sprintf(node->number, "%d", round); sprintf(node->usual_score, "%.1f", usual); sprintf(node->exam_score, "%.1f", exam); sprintf(node->coll_score, "%.1f", coll); printf("考试学期(如 2005-2006):"); scanf("%15s", node->date); node->next4 = NULL; if (head == NULL) head = node; else tail->next4 = node; tail = node; if (coll >= 60.0f) break; /* 及格直接返回,不录重修 */ printf("不及格,录入重修成绩?(y/n):"); ch = getche(); if (ch != 'y' && ch != 'Y') break; /* 用户喊停也退出 */ round++; } return head; } /* 课程链:输 00 结束,每建一门课顺手把它的成绩链接上 */ SUBJECT *input_subjects(char cls_idx, char stu_idx) { SUBJECT *head = NULL, *tail = NULL, *node; char buf[32]; char sub_idx = 0; while (1) { printf("课程号(输入 00 结束课程录入):"); scanf("%31s", buf); if (strcmp(buf, "00") == 0) break; node = (SUBJECT *)malloc(sizeof(SUBJECT)); if (node == NULL) break; sub_idx++; node->fall2[0] = cls_idx; node->fall2[1] = stu_idx; strncpy(node->course_no, buf, sizeof(node->course_no) - 1); node->course_no[sizeof(node->course_no) - 1] = '\0'; printf("课程名:"); scanf("%19s", node->course_name); printf("学分:"); scanf("%7s", node->credit); node->chead = input_scores(cls_idx, stu_idx, sub_idx); /* 递归挂成绩 */ node->next3 = NULL; if (head == NULL) head = node; else tail->next3 = node; tail = node; } return head; }

这段代码有两个地方值得停下来看。一是sub_idx的递增时机:它在malloc成功之后、建成绩链之前自增,保证同一学生的第一门课索引是 1 而不是 0,后面按索引回查时不会和「未初始化」混淆。二是input_scores的返回值直接赋给node->chead,而不是通过二级指针传参,这样课程节点和它的成绩链在同一次malloc生命周期里完成绑定,不会出现课程建好但成绩链悬空的中间态。

scanf("%15s", node->date)这类带宽度限制的写法在这份代码里要贯彻到底。原文档大量使用裸scanf("%s", ...),输入一个超长学期串就能把后面几个字段全冲掉,表现出来是「输完学期,程序打印的名字变成了一串数字」。宽度限制里的数字必须等于数组长度减一,写大了照样溢出。

三层循环的退出顺序也要在函数调用链上体现出来:input_scores里用户输 n 只是结束当前课程的成绩录入、回到input_subjects继续问下一门课;只有input_subjects里读到00才真正结束这个学生的课程链。这个层级关系如果搞反,用户会被迫用退出程序的方式来结束输入。

3. 在 TC 里把九个 .c 编成 jiaowu.exe:模块拆分与工程配置

单文件写到两三千行,编译器就开始罢工,这是当年绕不过去的一道坎,也是这份课设最有含金量的工程经验。这一章讲清楚模块按什么切、.prj工程文件长什么样、跨文件共享链头指针该怎么写。

3.1 九个源文件的职责边界与调用方向

按功能切分是最自然的切法,原文档给出的九个文件正好对应六类职责:main2.c放主函数和菜单调度,files.c管新建/打开/保存/退出,in_out.c管四层链表的录入,edit.c管插入/删除/修改/排序,search.c管班级、学生、课程三类查找,tongji.c管班级均分、学生均分、不及格统计,da_yin.c管模拟打印,help.c管帮助页,qita.c放公共小工具。

切分的关键原则是调用方向单向:main2.c只调用各模块的入口函数,各业务模块之间不互相调用,公共工具只被调用。违反这条就会出现循环依赖——比如edit.c想调用search.c的查找结果、search.c又想复用edit.c的打印函数,链接时就会报重复符号。绕开的办法是把共用的查找逻辑下沉到qita.c,两边都调它。

键盘输入这一块值得单独拎出来放进qita.c。DOS 下的方向键不是单个字符,getch()第一次返回 0 或 224 表示「扩展键前缀」,必须再读一次才能拿到真正的扫描码;不处理这个前缀,按一次上键会被当成两次输入,菜单直接跳两格。封装成下面这样,各模块统一调用就不会各写一套。

/* qita.c —— 统一键盘解码,屏蔽扩展键前缀 */ #include <conio.h> #define KEY_UP 1001 #define KEY_DOWN 1002 #define KEY_LEFT 1003 #define KEY_RIGHT 1004 #define KEY_ENTER 1005 #define KEY_ESC 1006 #define KEY_BACKSPACE 1007 int read_key(void) { int k = getch(); if (k == 0 || k == 224) { /* 扩展键:先吃掉前缀字节 */ switch (getch()) { case 72: return KEY_UP; case 80: return KEY_DOWN; case 75: return KEY_LEFT; case 77: return KEY_RIGHT; default: return 0; } } if (k == 13) return KEY_ENTER; if (k == 27) return KEY_ESC; /* Esc 统一作为「返回上层」 */ if (k == 8) return KEY_BACKSPACE; return k; /* 其余按原字符返回,供快捷键判断 */ }

返回自定义常量而不是直接返回扫描码,好处是菜单逻辑里写的case KEY_ESC:比case 27:好读,而且换编译器时只改这一个函数。Esc在所有子菜单里都被当作「返回上一层」,和文档里描述的交互完全一致,不需要在每个模块里重复判断。

3.2 建 .prj 工程并编译成 jiaowu.exe

TC 2.0 的工程文件就是一个纯文本清单,一行一个源文件名,扩展名必须是.prj。工程名可以随便取,但要在 TC 的 Project 菜单里选到同一个名字,否则编译的还是当前编辑窗口那一个文件。

main2.c files.c in_out.c edit.c search.c tongji.c da_yin.c help.c qita.c

清单里不写头文件,头文件靠各.c里的#include "jiaowu.h"引入。全部源文件和jiaowu.h必须放在同一个目录下,或者把该目录加进 TC 的 Include 路径,否则编译时会在第一行#include就报找不到文件。

操作顺序是固定的四步:先在 TC 里新建这个.prj文本并把上面九行写进去保存;再进 Project → Project name 选中它;然后 Compile → Build all,或者直接按 Ctrl+F9;最后得到与工程同名的jiaowu.exe,在 DOS 命令行里敲进去就能运行。如果只是 Compile 而不是 Build,TC 只会编译当前活动文件,链接阶段就会报一堆undefined symbol,这是新手最常翻的车。

.prj里的文件顺序不影响链接结果,但建议按调用层次从上往下排,出问题时一眼能看出是哪个模块没编进去。另外,改了任何一个.c之后都要重新 Build,不要只按运行键——TC 的运行键在某些配置下不会自动重编依赖文件,跑起来的是旧的可执行文件,改了半天没反应就是这个原因。

3.3 跨模块共享链表头指针:extern 的正确写法

九个文件都要访问同一个班级链头,最省事的做法是在头文件里直接CLASS *g_class;,结果每个包含它的.c都定义了一份,链接时报重复符号。正确做法是「头文件里声明、源文件里定义」,全工程只定义一次。

/* jiaowu.h 追加部分:只声明,不分配 */ #ifndef JIAOWU_H #define JIAOWU_H extern CLASS *g_class; /* 全局班级链头,定义在 main2.c */ extern char g_cur_base[64]; /* 当前文件名(不含后缀),用于保存提示 */ extern int g_dirty; /* 1 表示有未保存改动 */ #endif
/* main2.c —— 全工程唯一的一份定义 */ #include "jiaowu.h" CLASS *g_class = NULL; char g_cur_base[64] = ""; int g_dirty = 0;

g_dirty这个标志是「退出前是否提示保存」的实现核心。新建、插入、删除、修改、排序这五类操作成功后都置 1,保存成功后清 0,退出和新建文件前先看它。原文档描述的行为是「每次新建、打开或退出时自动判断是否有文件没保存」,背后的机制就是这个标志位加一个if。不加这个标志、改成每次退出都问一遍,用户会很烦;完全不加、直接退出,数据就丢了。

头文件里同时放结构体定义和 extern 声明,用#ifndef/#define包住防止重复包含。TC 2.0 的预处理器支持这套写法,不用担心兼容性。各.c文件开头统一#include "jiaowu.h",不要图省事在.c里重复写一遍结构体定义,两份定义一旦字段对不上,链接出来的程序行为会非常诡异,而且编译器完全不报错。

4. 四个 txt 的持久化:保存与打开的文件命名约定

文件模块是整个课设里最容易被低估的部分。链表建得再漂亮,存不下去或者读不回来,整个程序就是一次性的。这套实现采用的方案是「一个基名、四个后缀、纯文本、定长字段」,看起来土,但可调试性极强。

4.1 为什么拆成四个文件而不是一个

一个文件装下四层结构当然也能做,只要在每行前面加个类型标记。但这样一来文件里混着班级、学生、课程、成绩四类记录,人工打开检查时要在几千行里翻找,出问题根本定位不到是哪一层挂错了。拆成四个文件,一层一个,打开abc2.txt看到的就是一张干净的学生表,一眼能对上是哪一个字段错位。

命名规则是「用户只输基名,程序自动补后缀」:输入e:\abc,实际生成abc1.txt、abc2.txt、abc3.txt、abc4.txt,分别对应班级链、学生链、课程链、成绩链。打开时也只需要输e:\abc,程序用同样的规则拼出四个名字去读。这个约定必须写进帮助页,否则用户输e:\abc.txt就会得到abc.txt1.txt这种文件,然后来问为什么打不开。

文件内部全部是字符串,浮点也转成字符串写出去。好处是文件可以用记事本直接改——改分数调试的时候这太有用了,不用每次重跑一遍录入流程。代价是改的时候必须保持字段长度不变,把32.0改成100.0会让后面的字段整体右移,读回来就全错。这条限制原文档写得很清楚,实际使用中也是最高频的翻车点。

4.2 保存:路径拼接与定长写出

保存函数的核心不是写文件,而是拼文件名和判断写成功。拼接时要注意 DOS 路径总长上限是 63 个字符,超过就要提前报错而不是让fopen返回空指针再去解引用。

/* files.c —— 一个基名生成四个文件名,逐个写出 */ #include <stdio.h> #include <string.h> #include "jiaowu.h" static int make_name(const char *base, int idx, char *out) { if (strlen(base) + 6 > 63) return 0; /* 留出 "4.txt" 和结尾 \0 的余量 */ sprintf(out, "%s%d.txt", base, idx); return 1; } /* 学生链写出:一行一条,字段间用空格分隔,字段内容保证不含空格 */ static void write_students(FILE *fp) { CLASS *c; STUDENT *s; for (c = g_class; c != NULL; c = c->next1) for (s = c->sthead; s != NULL; s = s->next2) fprintf(fp, "%s %s %s %s %s %s %s %s %s\n", c->class_no, s->student_no, s->name, s->sex, s->age, s->phone, s->com_phone, s->jia_quan, s->all_credit); } int save_all(const char *base) { char name[64]; FILE *fp; int i; if (g_class == NULL) { /* 空链不允许保存 */ printf("请先新建链表\n"); return 0; } for (i = 1; i <= 4; i++) { if (!make_name(base, i, name)) { printf("文件名过长\n"); return 0; } fp = fopen(name, "w"); if (fp == NULL) { printf("无法写入 %s\n", name); return 0; } switch (i) { case 1: write_classes(fp); break; case 2: write_students(fp); break; case 3: write_subjects(fp); break; case 4: write_scores(fp); break; } fclose(fp); } strcpy(g_cur_base, base); g_dirty = 0; /* 保存成功才清脏标志 */ return 1; }

四个write_*函数都声明成static,作用域限制在files.c内部,别的模块调不到,避免误用。static函数必须写在调用它的函数之前,或者在使用前加一行原型声明,TC 2.0 对「隐式声明」只给警告不给错误,很容易埋雷。

fprintf里字段之间用单个空格分隔,前提是所有字段内容本身不含空格。姓名如果允许输入英文名带空格就会读串,稳妥的做法是改用固定宽度格式,比如%-13s之类,读的时候用fscanf(fp, "%13s", buf)按宽度截。这套实现选了前一种,够用但边界很明确。

g_dirty只在四个文件全部写完、fclose都成功之后才清零。中间任何一个fopen失败都直接返回 0 并保留脏标志,这样用户退出时还会被再问一次,不会出现「提示保存成功但其实只写了一半」的情况。

4.3 打开:重建顺序与失败处理

打开比保存难,因为四层结构的父子关系要从四个独立文件里还原。唯一可行的顺序是「按层级从外到内」:先读班级建班级链,再读学生、按记录里的班级号找父节点挂上去,再读课程、成绩同理。顺序一旦乱,就会出现学生找不到班级、被默默丢弃的情况。

/* files.c —— 打开:按 班级 -> 学生 -> 课程 -> 成绩 的层级顺序重建 */ static CLASS *find_class(const char *no) { CLASS *c; for (c = g_class; c != NULL; c = c->next1) if (strcmp(c->class_no, no) == 0) return c; return NULL; } int load_all(const char *base) { char name[64]; FILE *fp; char cls_no[8], stu_no[14], course_no[12]; char b1[16], b2[16], b3[16], b4[16], b5[16]; CLASS *c; STUDENT *s; if (!make_name(base, 1, name)) return 0; fp = fopen(name, "r"); if (fp == NULL) { printf("文件打不开,请检查路径和基名\n"); return 0; } while (fscanf(fp, "%7s", cls_no) == 1) { /* 一张表一列,好读好改 */ c = (CLASS *)malloc(sizeof(CLASS)); strcpy(c->class_no, cls_no); c->sthead = NULL; c->next1 = g_class; /* 头插,顺序与文件相反 */ g_class = c; } fclose(fp); if (!make_name(base, 2, name)) return 0; fp = fopen(name, "r"); if (fp == NULL) return 0; while (fscanf(fp, "%7s %13s %19s %3s %3s %13s %13s %9s %7s", cls_no, stu_no, b1, b2, b3, b4, b5, b5, b5) == 9) { c = find_class(cls_no); if (c == NULL) { printf("学生 %s 的班级不存在,已跳过\n", stu_no); continue; } s = (STUDENT *)malloc(sizeof(STUDENT)); strcpy(s->student_no, stu_no); s->suhead = NULL; s->next2 = c->sthead; /* 挂到对应班级下 */ c->sthead = s; } fclose(fp); /* 课程、成绩两层同理,成绩层要用 fall3 找到 班级->学生->课程 再挂 */ g_dirty = 0; strcpy(g_cur_base, base); return 1; }

fscanf的返回值是成功赋值的字段个数,判断它等于期望值是最省事的完整性检查。中途遇到格式不对的行会提前返回,后续字段全部读不到,表现是「文件明明有内容,打开后只有一半数据」。想定位得在循环里加一行打印当前行号,或者干脆改用fgets整行读进来再sscanf,出问题时能把原始行打出来。

find_class返回 NULL 时选择打印警告并跳过,而不是直接return 0终止整个打开过程。理由是一份被手工改过的文件里出现个别脏记录是常态,因为一条坏记录导致整份数据读不进来,用户会更难受。跳过时打印出错的学号或课程号,用户自己回去改那一行就行。

5. 常见问题排查:定长数组、编译器上限与三类数据错位

下面这五条都是这套实现里真实会撞上的问题,按「现象 → 原因 → 解决」写,遇到对应的症状直接对号入座。

5.1 现象:输入 12 位学号后,打印出来的姓名变成乱码

原因基本可以锁定在student_no[13]这一类「刚好够用」的数组上。12 位学号加结尾的\0需要 13 个字节,scanf("%s", s->student_no)写入时会在第 13 个字节放\0,而结构体里紧挨着的下一个字段正是name,它首字节就被覆盖,后面再用printf("%s", s->name)就一直读到下一个\0才停,看起来就是一串乱码。同理class_no[5]存 4 位班号也是零余量。

解决分两步:先把所有字符数组按「业务最大长度 + 1,再留 3 到 4 个字节缓冲」重新定义,学号给 14、班号给 8、姓名给 20;再把所有scanf("%s", ...)改成带宽度限制的写法,宽度值等于数组长度减一。改完之后旧数据文件仍然能读,因为按%13s读入 12 位字符不会越界。

5.2 现象:代码写到三四千行,TC 编译直接报内存不足或干脆卡死

这是当年最折磨人的一关,单文件写到两千行 TC

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