☰
C语言课程设计:十字交叉链表四重链实现教务管理系统与持久化
2026/10/28 13:44:22 网站建设 项目流程

简介:教务信息管理系统 C语言课程设计文档面向计算机专业课程设计学习者,以华中科技大学《C语言程序设计》课程设计为背景,围绕年级成绩表格管理场景,帮助读者完成需求分析、数据结构设计、模块化实现与实验报告撰写的完整训练。资源包共1个doc文件,约361KB,正文包含程序简介、十字交叉链表结构说明、功能与操作介绍,并配有源代码和流程图,便于对照理解班级链、学生链、课程链与成绩链的四重组织方式。系统采用中文图形菜单,支持文件新建、打开、保存,以及信息修改、插入、删除、排序、模拟打印、班级/学生/课程查找、均分统计和不及格信息统计等功能;工程按模块编译,保存时按四类链表分别写入txt文件。已有1120人学习,适合需要参考完整课程设计思路、代码结构与文档写法的高校学生。

1. 十字交叉链表:一份年级成绩表为什么值得做成四重链

很多人做「C语言课程设计 教务管理系统」时,第一反应是开一个大数组:班级、学生、课程、成绩全铺平,按下标取。但真正把「教务」两个字做进去,问题立刻变成动态的——班级会增减、学生可能转走、某门课只开一个学期、一门课还可能重修两次。数组一旦定长,加一层就要整体搬一次。

这套课设选的解法是十字交叉链表:班级链表往下挂学生、学生往下挂课程、课程往下挂成绩,四条链四个方向交叉,靠指针把这些关系焊死。它看着像为了炫技而链来链去,实际上是把关系表里的外键关系硬塞进了指针里。搞懂它,你就搞懂了「没有数据库、没有 STL 的年代,结构化数据到底怎么落地」这件事。

2. 四层结构体:班级、学生、课程、成绩的指针是怎么一级级挂上去的

2.1 从 CLASS 到 scores,每一层头指针的职责边界

这套结构只有一条规则:父节点持有子链表的头指针,子节点不持有父节点的指针。方向是单向的,从班级往下,一路走到单条成绩记录。四层结构依次长这样:

/* 第一层:班级。只有班号 + 下一个班 + 本班学生链头 */ typedef struct classes { char class_no[5]; /* 4 位班号 + '\0',如 "0510" */ struct classes *next1; /* 兄弟:下一个班级 */ struct students *sthead; /* 子:本班学生链表的头指针 */ } CLASS; /* 第二层:学生。挂基本信息 + 课程链头 */ struct students { char fall1[2]; /* fall1[0] = 所属班级序号 */ char student_no[13]; /* 12 位学号 + '\0' */ char name[17]; char sex[2]; char age[3]; char phone[12]; char com_phone[12]; char jia_quan[7]; /* 加权分,存成字符串如 "95.50" */ char all_credit[4]; /* 已得总学分 */ struct students *next2; /* 兄弟:本班下一个学生 */ struct subjects *suhead; /* 子:该生课程链表的头指针 */ }; /* 第三层:课程。挂课程信息 + 成绩链头 */ struct subjects { char fall2[3]; /* fall2[0]=班级序号 fall2[1]=学生序号 */ char course_no[11]; char course_name[17]; char credit[4]; struct subjects *next3; /* 兄弟:该生下一门课 */ struct scores *chead; /* 子:该门课的成绩链表头 */ }; /* 第四层:成绩。同门课的多次考试/重修依次串起来 */ struct scores { char fall3[4]; /* fall3[0]=班 fall3[1]=生 fall3[2]=课 */ char number[2]; /* 第几次考试 */ char exam_score[4]; /* 卷面分 */ char usual_score[4]; /* 平时分 */ char coll_score[4]; /* 综合分 */ char date[11]; /* 考试学期,如 "2005-2006" */ struct scores *next4; };

走一条完整路径要转三次指针:cls->sthead拿学生,stu->suhead拿课程,sub->chead拿成绩。这个设计的关键在于——父指针只有一个方向。你从上往下走畅通无阻,但站在一条成绩记录上,你没法反查它是谁的。所以每条成绩、每门课、每个学生身上都硬塞了fall字段当「反向索引」,这是下一节要说的。

2.2 全 char 定长数组:在没有堆的年代怎么存字符串

注意到没有,四个结构体里没有一个char *,全是定长数组。这不是偷懒,是被内存模型逼出来的。

那个年代的编译器跑在 16 位 DOS 上,small 内存模型下数据段只有 64KB,栈和堆挤在同一块空间里,实际能拿到的堆往往只有几 KB。你要是给每个学生的姓名malloc一段,几百个学生下来堆就碎了,而且碎片一旦产生,后面无论怎么释放都拼不回大块。定长数组把内存全部压到数据段(全局/静态区)或栈上,不碰堆,也就不存在碎片和泄漏。

代价是:每个字段都自带一个硬性长度上限。name[17]意味着姓名最多 16 字节,中文按 GBK 两字节算就是 8 个汉字,超了就得截断。credit[4]意味着学分只能是三位字符,"5.5"放得下,"12.5"就只剩 3 个字符位,勉强够,"100.5"直接溢出。这种「先定死再写逻辑」的思路在今天看着很反人类,但在当时是最优解。

数值也是字符串。jia_quan[7]存"95.50",exam_score[4]存"53"。计算的时候必须atof()转出来,算完再sprintf()写回去。这套来回转换的开销不小,但它带来一个直接的好处:写进文件的时候不用做任何格式化,fputs直接扔进去就是人能读的文本。

2.3 fall1/fall2/fall3:冗余的从属字段到底是不是浪费

按数据库的第三范式,fall字段是彻底的冗余——成绩记录的位置已经由它在链表中的挂载点确定了,再存一份「我属于哪个班哪个生哪门课」纯属重复。

但这套结构没有反向指针,也没有全局索引表。想在不带上下文的情况下定位一条记录、或者想在读文件重建时把孤立的记录挂回树上,你必须能从记录自身读出归属。这就是fall的真正用途:它是给「从文件读回来的裸记录」准备的路标。文件里四张表是分开存的,成绩表读进来的时候是个一条条独立的记录,程序得靠fall3[0]找到班级、fall3[1]找到学生、fall3[2]找到课程,才能把它挂到正确的chead后面。

代价也很明显:一个字节只能表示 10 个值('0'~'9')。也就是说这套系统最多支持 10 个班、每班 10 个学生、每人 10 门课。这个上限藏得很深,正常课设演示三五条数据根本碰不到,一旦真拿一个年级的数据跑,第 11 个班加进去就直接错乱。这是这份设计里最典型的「为省一个字节埋一个坑」。

2.4 建链顺序与「00」哨兵:为什么输入退出码要分三层

创建过程是严格自顶向下的:先建班级链,再在指定班级里建学生链,再在指定学生里建课程链,最后在某门课下建成绩链。每一层都用同一个哨兵值"00"表示「本层输入结束,返回上一层」。

/* 建链的骨架,注意每层的退出条件互不干扰 */ CLASS *build_classes(void) { CLASS *head = NULL, *tail = NULL, *node; char buf[8]; while (1) { printf("班号(00 结束):"); get_str(buf, sizeof(buf)); if (strcmp(buf, "00") == 0) break; /* 退出本层,回到 main */ node = new_class(buf); if (head == NULL) head = node; /* 第一个节点当表头 */ else tail->next1 = node; /* 尾插,保持输入顺序 */ tail = node; node->sthead = build_students(node); /* 递归下一层,收子链头 */ } return head; }

分层的意义在于:"00"在班级层表示「班级输完了」,在学生层表示「这个班的学生输完了,继续下一个班」。同一个字面量在不同层级含义不同,靠的是各层函数各自消化自己的哨兵,绝不向上冒泡。如果只用一层循环加全局标志位,一个"00"就会被上层误读成整体结束,整个录入直接断掉。

尾插而不是头插,是为了让链表顺序和输入顺序一致——后面的「按学号排序」和「按加权排名」都建立在这个假设上:没排过序的情况下,链表顺序就是录入顺序,也是文件里的顺序。

3. 排序与统计:链表上没有索引,均分和排名怎么算出来

3.1 加权分的口径:为什么排名不看综合分平均值

一门课的综合分是平时分和卷面分按比例折算出来的,但学生的排名绝不能用各科综合分简单求平均。因为学分不一样,2.5 学分的课和 5.5 学分的课,权重差一倍多。

正确的口径是加权平均:

加权分 = Σ(单科综合分 × 该科学分) / Σ(该科学分)

对应到结构体上,jia_quan[7]存的是这个结果,all_credit[4]存的是已得总学分(通常只累计及格课程的学分)。计算发生在建链完成、或者成绩被修改之后:

/* 遍历一个学生的课程链,累加 (综合分 * 学分) 和学分 */ void calc_weight(struct students *stu) { struct subjects *sub; double sum_cs = 0.0, sum_c = 0.0; for (sub = stu->suhead; sub != NULL; sub = sub->next3) { struct scores *sc = sub->chead; /* 默认取最近一次考试 */ if (sc == NULL) continue; double c = atof(sub->credit); /* 学分 */ double s = atof(sc->coll_score); /* 综合分 */ sum_cs += c * s; sum_c += c; } sprintf(stu->jia_quan, "%.2f", sum_c > 0 ? sum_cs / sum_c : 0.0); sprintf(stu->all_credit, "%.1f", sum_c); /* 简化:累计总学分 */ }

这里有两个容易踩的点。第一,sc取的是chead而不是遍历整条成绩链——一个学生某门课可能有多次考试记录(重修),加权分只认最新一次,也就是链表头(因为按尾插,最后录入的在最前面还是最后面取决于插入策略,通常取chead对应最近一次)。第二,学分为 0 或没有课程时要挡一下除零,不然后面所有基于加权分的排序会拿到nan,atof解析回来变成 0,排名直接乱掉。

3.2 班级内部排序:只排一层,是因为只有一层有序需求

排序的粒度是「班级内部」,不跨班。理由很直白:排名是班内排名,教务处看的是「这个班谁第一」,没有「全年级混排」的需求。跨班排序要么再建一条全局链表,要么每次排序时把所有人的指针收集到数组里,两种做法都比班内冒泡贵得多。

链表上的冒泡不能像数组那样swap(arr[i], arr[j]),因为节点的next2决定了它在链上的位置,而动next2要处理前驱、后继、表头三个边界。最常见的实现思路是交换数据、保留链接,把除next2之外的所有字段整体对调:

/* 交换两个学生节点除 next2 外的全部内容 */ void swap_student(struct students *a, struct students *b) { char t_no[13], t_name[17], t_sex[2], t_age[3]; char t_ph[12], t_cp[12], t_jq[7], t_ac[4]; char t_fall[2]; struct subjects *t_sub; /* 逐字段搬运,字段多但安全:链接关系完全不动 */ STR_SWAP(a->student_no, b->student_no, t_no, 13); STR_SWAP(a->name, b->name, t_name, 17); STR_SWAP(a->sex, b->sex, t_sex, 2); STR_SWAP(a->age, b->age, t_age, 3); STR_SWAP(a->phone, b->phone, t_ph, 12); STR_SWAP(a->com_phone, b->com_phone, t_cp, 12); STR_SWAP(a->jia_quan, b->jia_quan, t_jq, 7); STR_SWAP(a->all_credit, b->all_credit, t_ac, 4); STR_SWAP(a->fall1, b->fall1, t_fall, 2); /* 关键一行:课程链头必须跟着走,否则成绩会挂错人 */ t_sub = a->suhead; a->suhead = b->suhead; b->suhead = t_sub; } void sort_by_weight(CLASS *cls) { struct students *p; int swapped = 1; if (cls == NULL || cls->sthead == NULL) return; while (swapped) { swapped = 0; for (p = cls->sthead; p->next2 != NULL; p = p->next2) { if (atof(p->jia_quan) < atof(p->next2->jia_quan)) { swap_student(p, p->next2); /* 降序:加权高的在前 */ swapped = 1; } } } }

注意suhead那一行:如果只换基本信息、不换课程链头,那排序完成后「张三的学号」配的是「李四的课程和成绩」,这是这类课设里最阴的一类 bug——表面上排名对了,点进去每个人的成绩全是别人的。交换数据法虽然要搬十几个字段,但避开了链表重连的所有边界情况,在几百个学生的规模下性能完全够。

按学号排序同理,只是比较键换成strcmp(p->student_no, p->next2->student_no)。学号是定长左对齐的,字典序等于数值序,不需要转数字。

3.3 不及格与重修:成绩链为什么允许同门课挂多条记录

这是四重链里唯一「一父多子且数量不定」的地方。一个学生一门课,正常情况下只有一条成绩记录,但如果不格,就得挂第二条重修成绩,再不及格再挂,直到及格或者用户主动放弃。

链表的形态天然支持这个:chead往后可以接任意多条scores,每条带自己的date区分学期,number区分次数。录入逻辑是尾插加重试循环:

/* 录入一门课的成绩,不及格则追问是否继续录入重修 */ void input_scores(struct subjects *sub) { char buf[8]; while (1) { struct scores *sc = new_score(); /* 分配并清零 */ printf("学期(如 2005-2006):"); get_str(sc->date, sizeof(sc->date)); printf("平时成绩:"); get_str(sc->usual_score, 4); printf("考试成绩:"); get_str(sc->exam_score, 4); /* 综合分按 30% / 70% 折算,结果仍写成字符串 */ sprintf(sc->coll_score, "%.0f", atof(sc->usual_score) * 0.3 + atof(sc->exam_score) * 0.7); append_score(sub, sc); /* 尾插到 chead 链 */ if (atof(sc->coll_score) >= 60.0) break; /* 及格就收工 */ printf("不及格,录入重修?(y/n):"); get_str(buf, sizeof(buf)); if (buf[0] != 'y' && buf[0] != 'Y') break; } }

这套设计的取舍在于:「最新成绩」和「是否及格」没有单独字段标记,只能靠遍历判断。统计不及格学生时,程序得把每个人的每门课的每条成绩都过一遍,只要有一条coll_score < 60就判定该生有挂科。重修及格后,之前那条不及格记录仍然留在链上——统计时要不要把它算进「曾不及格」,取决于遍历时取的是第一条还是最后一条。这个歧义没在结构里解决,只能靠统计函数的实现约定,是一个真实存在的模糊地带。

3.4 统计的遍历路径:三重循环是怎么写出四重效果

统计班级均分,要走到第四层才能拿到分数。路径是:外层遍历班级链,中层遍历该班学生链,内层遍历该生课程链,最内层遍历该课成绩链。四层嵌套,但因为中间两层是「每个父节点各自一条子链」,写起来是三层for加一次内层循环:

/* 统计某班某门课的平均综合分 */ double avg_of_course(CLASS *cls, const char *course_no) { struct students *stu; struct subjects *sub; struct scores *sc; double sum = 0.0; int n = 0; for (stu = cls->sthead; stu != NULL; stu = stu->next2) { for (sub = stu->suhead; sub != NULL; sub = sub->next3) { if (strcmp(sub->course_no, course_no) != 0) continue; /* 只算指定课 */ for (sc = sub->chead; sc != NULL; sc = sc->next4) { sum += atof(sc->coll_score); /* 重修多条会重复计入 */ n++; } } } return n > 0 ? sum / n : 0.0; }

这段代码暴露了一个结构性问题:跨学生找同一门课,靠的是字符串比较course_no,而不是共享的课程对象。因为每个学生各自持有一条独立的subjects节点,同一门「高等数学」在 50 个学生身上就是 50 个节点,改一次学分要改 50 处。这是树形结构的必然代价——共享就得引入引用计数和去重表,那又是另一套复杂度了。在课设规模下,用字符串匹配换实现简单,是划算的。

4. 持久化:一次保存为什么要拆成四个 txt 文件

4.1 一个基名生成四个文件名的规则

保存的时候用户只输入一个路径,比如e:\abc,程序自动派生出abc1.txt到abc4.txt四个文件,分别装班级、学生、课程、成绩四条链。这个约定的好处是用户心智负担极小——记住一个名字就行,四个文件的对应关系由序号固定。

/* 由基名派生出第 idx 个文件名,idx 取 1..4 */ void make_fname(char *out, const char *base, int idx) { /* base 形如 "e:\\abc",不带后缀;拼出来是 "e:\\abc1.txt" */ sprintf(out, "%s%d.txt", base, idx); } int save_all(const char *base) { char fn[64]; make_fname(fn, base, 1); if (!dump_classes(fn)) return 0; /* 班级 */ make_fname(fn, base, 2); if (!dump_students(fn)) return 0; /* 学生 */ make_fname(fn, base, 3); if (!dump_subjects(fn)) return 0; /* 课程 */ make_fname(fn, base, 4); if (!dump_scores(fn)) return 0; /* 成绩 */ return 1; }

这里有个必须守住的约定:打开时的基名必须和保存时完全一致。因为打开不做任何探测,直接按同样的规则拼四个文件名去fopen,少一个就整体失败。文件名里带不带后缀、用相对路径还是绝对路径、盘符大小写,任何一项对不上就是「文件打不开」。这是用户层面最容易翻车的地方,而程序给不出任何有用的错误提示,只能回一句「文件打不开」。

4.2 全部转成字符串落盘:为什么不是二进制

四个文件全是纯文本,浮点也转成字符串,原因有两个层面。

实现层面:结构体里有指针(next1~next4、sthead、suhead、chead),二进制直接fwrite整个结构体,写进去的是当时那次运行的内存地址。下次跑起来地址全变,读回来就是一串野指针,一解引用就崩。想用二进制就得手工序列化,把指针全部剔除,那和写文本的工作量差不多。

使用层面:文本文件可以直接用记事本打开看,改数据、查错、交作业时给老师演示,都比二进制友好得多。代价是长度约定极其脆弱——每个字段定长,写的时候按%s加固定宽度,读的时候必须按同样的宽度切。用户要是在记事本里手工改数据,多敲一个字符,整行后面全部错位,读回来就是一堆乱码挂到错误的字段上。

常见的实现思路是每条记录占固定的一行,字段之间用制表符或空格分隔,读取时按分隔符切分而不是按偏移量切,这样对「长度变化」的容忍度高一些。但原设计选择的是严格定长,因为它同时也把「字符串转数字」这步省了——读出来的就是写进去的字符,不用解析。

4.3 打开时的重建顺序:为什么必须自顶向下

读文件建链的顺序不能乱:先读班级建班级链,再读学生,读到哪个学生就靠fall1[0]找到对应班级挂上去,再读课程靠fall2找到对应学生,最后读成绩靠fall3找到对应课程。

/* 打开:四张表依次读入,后读的挂到先读的上面 */ int load_all(const char *base) { char fn[64]; CLASS *cls_head; make_fname(fn, base, 1); cls_head = load_classes(fn); /* 第一张表:只有班号,无依赖 */ if (cls_head == NULL) return 0; make_fname(fn, base, 2); load_students(fn, cls_head); /* 依赖班级链已建好 */ make_fname(fn, base, 3); load_subjects(fn, cls_head); /* 依赖学生链已建好 */ make_fname(fn, base, 4); load_scores(fn, cls_head); /* 依赖课程链已建好 */ return 1; }

这个顺序是由fall决定的:子记录只知道自己父节点的序号,父节点必须先存在。如果哪个文件的字段顺序被手工调换过,比如成绩表里fall3[2]的课程序号指向一个不存在的课程,挂载就会失败——这时候要么丢弃这条记录,要么挂到一个「孤儿链」上最后统一提示。原设计没有做这层容错,直接静默丢弃,表现出来就是「打开后少了几条数据」,而且没有任何提示。

4.4 打印与预览共用一套输出函数

「模拟打印」这个功能的设计很有意思:它不真的调打印机,而是把报表同时输出到屏幕和student.txt。屏幕那份是给人预览的,文件那份是给人拿去打印的。

实现上,输出函数不直接printf,而是写到一个抽象的输出目标上——要么是屏幕,要么是文件句柄。这样一套格式化逻辑服务两个出口,报表格式永远一致,不会出现「屏幕上对齐、文件里错位」的情况。这也是那个年代没有打印机时的标准做法:先保证数据能落到文件,格式对不对、打出来好不好看,等有打印机再说。

屏幕上那份还要支持上下左右滚动,因为一屏放不下一个班的所有成绩。这需要在内存里维护一个「当前视口偏移」的变量,按方向键改偏移量再重绘。这部分和链表结构无关,但它是这套课设里代码量最大、最容易调崩的部分之一。

5. 排查:这套四重链最容易在哪几个地方翻车

5.1 编译到几千行就报错:不是语法问题,是内存模型问题

现象:代码写到两三千行,编译能过,一运行就异常退出或者死循环;换一个编译器,能撑到四千行,再往上又不行了。

原因:不是语法错误,是 16 位编译器 small 内存模型下数据段只有 64KB。全局数组、字符串常量、静态变量全挤在这 64KB 里,代码量一大,光常量池就把空间吃完了,堆和栈被压到几乎为零,稍微一递归或者一分配就溢出。

解决:模块化编译。把main、统计、打印、文件、编辑、帮助、查找、其他拆成独立的.c文件,每个单独编译成.obj,再通过工程文件链接成一个.exe。单个编译单元变小,编译器对每个单元的内存分配压力就下来了,链接阶段再合并。这是那个年代做大程序的通用手法,也是这套课设里最有价值的一条工程经验——拆分不是为了好看,是为了让编译器吃得下。

5.2 删了班级,学生指针还在:悬空链的清理顺序

现象:删除一个班级之后再执行统计,程序直接崩,或者统计结果里凭空多出一堆不认识的学生。

原因:删除时只把班级节点从next1链上摘下来并free掉,没有先把sthead指向的整棵学生子树释放。摘链之后,那串学生节点变成了没有任何引用的孤儿,内存泄漏;如果删除时释放了班级但别处还留着指向它的指针,就是标准的悬空指针,下次访问直接读到已释放的内存。

解决:删除必须自底向上。先遍历该班的学生链,对每个学生先释放课程链,对每门课先释放成绩链,一层层往下free,全部释放完了再free班级节点本身,最后才动next1的链接关系。顺序反了就是野指针。

5.3 定长数组越界:多敲一个字符就把下一层指针写坏

现象:输入一个长一点的姓名之后,程序行为开始变得莫名其妙——某个学生的课程链突然指向一串乱码,或者打印出来的班级号是一堆方块。

原因:name[17]只留了 16 个字符位,get_str如果不做长度截断,多敲几个字符就直接覆写掉结构体里紧挨着的字段。在四层结构体里,被覆写的很可能是suhead这种指针——内存里相邻的成员一旦被字符串淹没,指针就变成了一个非法地址。

解决:所有字符串输入必须走统一的带长度参数的读取函数,读满size - 1就停止并丢弃剩余输入,末尾强制补'\0'。绝不能图省事用gets——它连长度参数都没有,是这类课设里最经典的翻车源头。

5.4 单字符序号:第 11 个班就崩

现象:演示时三五个班一切正常,真拿一个年级的数据灌进去,第 11 个班之后所有数据全挂错地方。

原因:fall1[2]、fall2[3]、fall3[4]里的序号是单个字符,用'0' + index编码,能表示的只有 0~9 十个值。第 11 个班序号算出来是'0' + 10,也就是':',读回来再减'0'得到 10,但比较逻辑里如果写的是fall1[0] == '0' + i,越界后匹配就会错位到第一个班。

解决:要么把fall字段扩成多字节(比如fall1[3]存两位序号),要么彻底改成通过指针直接挂载、只在落盘时临时生成序号。前者改动小但治标,后者才是根治——序号本质上是「没有反向指针」的补丁,补丁本身不该有容量上限。

5.5 保存后改文件:长度一变,读回来就整体错位

现象:在记事本里手工改了一下abc2.txt里的某个姓名,改短了两个字符,重新打开程序,从这一条开始后面所有学生的信息全部串位。

原因:落盘用的是严格定长格式,读取时按偏移量切字段。任何一个字段的实际长度和约定不符,后面的字段边界全部平移,姓名可能被读成学号,学号被读成电话。

解决:别手改,或者在读取时改用分隔符切分而不是固定偏移。更稳的做法是每条记录写成key=value形式,读取时按=拆键值,字段顺序和长度都不再敏感。代价是文件变大、解析变慢,但换来的是对人工编辑的容错——这在课设场景下其实更实用,因为演示时经常需要现场编数据。

6. 设计取舍:把关系塞进指针,代价落在哪几个地方

回头看这套结构,它解决的核心问题和关系数据库完全一样:一对多关系怎么存。数据库用外键加索引,它用子链头指针加fall序号。两者都在做同一件事,只是实现的层不同。理解这一点,比背下四个结构体的字段重要得多。

代价集中在三处。查询是 O(n) 的:想找一个学号,必须从班级链头开始逐层往下扫,没有任何跳表或哈希。在几十人的规模下无所谓,上千人就是明显的卡顿。冗余字段会漂移:fall序号、jia_quan、all_credit都是派生数据,只要有一处修改忘了同步,全库就不一致,而且没有任何校验机制能发现它。结构表达能力有限:默认所有学生课程相同,直接排除了选修课;一旦要支持「张三只修三门、李四修五门不同课程」,共享课程对象和引用计数就绕不过去。

维度十字交叉链表关系表现代等价做法
一对多子链头指针外键邻接表 / 嵌套对象
反向查找fall序号冗余索引加父指针或建索引表
存储四个定长文本单库多表JSON / SQLite
增删成本O(1) 挂链,需手工释放子树级联约束事务 + 外键
排序链上冒泡,交换数据ORDER BY收集到数组排

如果今天要复刻同样的东西,我会这么改:结构体里保留父子指针,但去掉所有fall序号,落盘时改成一行一条 JSON,读回来先建对象再按 id 二次挂载;排序不碰链表,把指针收进数组排完再重建链接;查询加一张哈希表,键是学号,值是节点指针。这样既保留了链表增删 O(1) 的优势,又把查询和持久化的坑填掉了。

我当年写类似的东西时,最惨的一次是改完排序函数忘了同步suhead,跑出来的排名表每个人分数都正常,点进去看成绩全是隔壁宿舍的。查了整整两天才发现问题出在三行交换代码上。从那以后我每次动链表节点的字段,都强制走一遍「列出所有指向子结构的指针,逐个确认有没有跟着搬」的清单,哪怕只有两个字段要换。这套笨办法救过我很多次。

这份课设的价值不在功能多全,而在于它逼你把「数据之间的关系」和「数据本身」分开想清楚。搞清楚这个,再看今天的 ORM、图数据库、嵌套文档模型,你会发现它们解的都是同一道题,只是把指针换成了别的东西。希望帮到你。

需要这份完整的课程设计资源(含四重链源码、模块化工程文件、实验报告与操作截图)的话,可以顺着这条思路去找同名的工程包,把结构体和保存逻辑对照着读一遍,比单看代码收获大得多。

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

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

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

立即咨询