☰
C语言课程设计:用AOV网与拓扑排序实现教学计划编制
2026/10/18 15:24:26 网站建设 项目流程

简介:面向C语言/C++课程设计的“教学计划编制问题”完整项目包,适配高校计算机专业实践环节。资源围绕课程设计核心任务,提供可运行的工程源码与配套文档,帮助学习者理解教学计划编排的逻辑设计与代码实现。压缩包内含7个文件,包括3个cpp源文件、2个头文件及2个doc文档,其中源程序覆盖接口封装、主控流程与核心算法,doc文档则提供课程设计报告和说明书,便于对照撰写与答辩准备。包体约893KB,结构紧凑,适合初学者快速查阅。目前已有1471人学习浏览,累计参考价值较高。通过该资源,读者可掌握C++类设计、函数模块划分、文件组织方式,并学习如何将教学计划编制问题转化为可运行的解决方案,对完成同类课程设计具有直接借鉴意义。

1. 教学计划编制问题:一份能直接跑通的C语言课程设计

期末课程设计题目发下来,要求写一个“教学计划编制系统”:给定若干门课程、每门课的学分和先修关系,自动排出每一学期该上哪些课。很多人第一反应是去做界面、做菜单、做文件存取,但拿到这份资源打开源码就会发现,核心根本不在这层——真正的工程量在“先修约束怎么建模、怎么按学期摊开、怎么检测数据有环”。这份C语言课程设计包用的是最经典的AOV网+拓扑排序方案,适合把离散数学里学的图论真正落进代码的人。你要做的不是背题,而是理解三个东西:建图、排序、按学期切分。下面按我实际拆包的顺序讲。

2. 先修关系建图:邻接表结构、入度计算与课程序列化的核心

2.1 课程与先修关系的图论建模:为什么是AOV网而不是别的

教学计划编制问题的本质是“有向无环图上的线性化”。每门课是一个顶点,先修关系是边:如果A是B的先修课,就有一条从A指向B的边。所谓“能够顺利毕业”,就是存在一个序列,保证每个顶点出现时,它的所有前驱都已经出现过——这就是拓扑排序的定义。

我在拆这个项目时第一个确认的就是:数据组织用的是“顶点的入度”而不是“出度”。入度表示“修这门课之前必须修完多少门课”,入度为0的课程就是当前没有先修负担、可以立即开课的门课。AOV网(Activity On Vertex)是这种场景的标准建模方式,活动在顶点上,边只表达先后依赖,不表达持续时间——这恰好是教学计划的需求,课程本身不占图论的边权重,所以用AOV网而不是AOE网。

这里有个常见的选型误用:有人把课程当成边,把学期当成顶点,建出来的模型处理不了“一门课可以跨多个学期先修”的情况。用AOV网则天然支持多对多的先修关系,课程数到几千门也不影响算法结构。

2.2 数据结构定义:从文件到邻接表的完整存储设计

这个资源里对“课程”的抽象是紧凑的,没有用花哨的高级容器,完全符合C语言课程设计的教学预期。结构体定义长这样:

#define MAX_COURSES 200 #define MAX_NAME_LEN 50 typedef struct ArcNode { int adjvex; /* 后继课程的数组下标 */ struct ArcNode *next; /* 指向下一条后继弧 */ } ArcNode; typedef struct { char id[12]; /* 课程编号,如 CS101 */ char name[MAX_NAME_LEN]; /* 课程名称 */ int credit; /* 学分 */ int indegree; /* 先修课程数量,即入度 */ ArcNode *firstarc; /* 后继课程链表的头指针 */ } Course; Course courses[MAX_COURSES];

为什么用邻接表而不是邻接矩阵?因为教学计划场景下边数远小于 n²,邻接矩阵会浪费大量空间,而且拓扑排序中要频繁遍历每条边的后继,邻接表的遍历开销是 O(边数),矩阵要 O(n)。课程设计报告里如果能写清楚这一层的空间时间权衡,答辩会顺利很多。

每个顶点的 firstarc 存的是“以这门课为先修课的那些后继课程”,这个方向不能搞反。入度 indegree 在读取文件时统计:每读到一条先修关系 src 指向 dst,就把 dst 的 indegree 加 1,同时把 dst 挂到 src 的非后继链表中。

2.3 读文件与入度统计的代码骨架

data.txt 是这份资源的样例数据文件,格式是每两行一组:

MAC101 高等数学 4 0 CS101 程序设计基础 3 0 CS102 数据结构 4 CS101 CS201 算法设计 3 CS102 MAC101 CS301 编译原理 3 CS102 CS302 操作系统 3 CS102 END

第一行是“课程编号 课程名称 学分”,第二行是它的先修课程编号列表,多个先修用空格分隔,没有先修就写 0。文件以 END 结尾。读文件的核心逻辑:

#include <stdio.h> #include <stdlib.h> #include <string.h> int find_course(Course *courses, int n, const char *id) { for (int i = 0; i < n; i++) { if (strcmp(courses[i].id, id) == 0) return i; } return -1; } void add_arc(Course *courses, int src_idx, int dst_idx) { ArcNode *node = (ArcNode *)malloc(sizeof(ArcNode)); node->adjvex = dst_idx; node->next = courses[src_idx].firstarc; courses[src_idx].firstarc = node; } void read_data(const char *filename) { FILE *fp = fopen(filename, "r"); if (fp == NULL) { printf("无法打开文件 %s\n", filename); exit(1); } char line[256]; int n = 0; while (fgets(line, sizeof(line), fp)) { if (strncmp(line, "END", 3) == 0) break; sscanf(line, "%s %s %d", courses[n].id, courses[n].name, &courses[n].credit); courses[n].indegree = 0; courses[n].firstarc = NULL; /* 读取下一行的先修课程列表 */ fgets(line, sizeof(line), fp); char *token = strtok(line, " \n"); if (token == NULL || strcmp(token, "0") == 0) { n++; continue; } while (token != NULL) { int src_idx = find_course(courses, n, token); if (src_idx >= 0) { add_arc(courses, src_idx, n); courses[n].indegree++; } else { printf("警告:课程 %s 引用了不存在的先修课 %s\n", courses[n].id, token); } token = strtok(NULL, " \n"); } n++; } fclose(fp); }

逻辑说明:整个读取是一次“边读边建图”的过程。第一行解析出课程信息后,课程的数组下标就是它自己;第二行里每出现一个先修课程编号,就通过 find_course 找到该先修课的下标,把当前课程挂到它的后继链表上,同时当前课程的入度加 1。这样一轮循环后,每个顶点的 indegree 和 firstarc 就全部就位。

参数说明:MAX_COURSES 设为 200,对大多数专业的教学计划足够了;如果课程数更多,可以改成动态 realloc,但在课程设计场景下静态数组更利于阅读和调试。注意 data.txt 里的课程名不能包含空格,否则 sscanf 的 %s 会把课程名拆成两段。如果一定要支持带空格的课程名,就得改成“按位置取子串”的解析方式,我在后面避坑章节会细说。

3. 用拓扑排序生成教学计划:队列实现、学期切分与输出

3.1 拓扑排序主循环:入度归零的课程怎么进入队列

拓扑排序的思路是:先把所有入度为 0 的课程放入队列,然后循环:从队列取出一个课程输出,遍历它的后继链表,把每个后继课程的入度减 1,如果减到 0 说明它的所有先修课都已修完,就把它也加入队列。这个过程重复到队列为空。资源包里的核心实现如下:

void topological_sort(int n, int *topo_order) { int queue[MAX_COURSES]; int front = 0, rear = 0; int count = 0; /* 把入度为 0 的课程全部入队 */ for (int i = 0; i < n; i++) { if (courses[i].indegree == 0) { queue[rear++] = i; } } while (front < rear) { int v = queue[front++]; topo_order[count++] = v; /* 遍历 v 的所有后继课程,入度减 1 */ ArcNode *p = courses[v].firstarc; while (p != NULL) { int adj = p->adjvex; courses[adj].indegree--; if (courses[adj].indegree == 0) { queue[rear++] = adj; } p = p->next; } } if (count < n) { printf("错误:课程先修关系存在环,无法编制教学计划。\n"); printf("已输出的课程数: %d, 总课程数: %d\n", count, n); exit(1); } }

这里用数组模拟队列,而不是直接用 STL 的 std::queue,是故意为之。课程设计通常要求展示数据结构基本功,数组队列能看到 front/rear 指针的运动过程,报告里画流程图也直观。rear 的最大值是课程总数 n,所以数组容量开 MAX_COURSES 就不会溢出。

有个细节必须提醒:入度数组在拓扑排序过程中会被修改。如果你在排序之后还要继续用入度信息,得先拷贝一份。这份资源在排序前保存了 indegree 的副本,否则学期划分阶段会拿不到原始入度数据。

3.2 按学期分配课程:固定课数的贪心策略与参数调整

纯拓扑排序只能输出一个线性序列,但教学计划要的是“每一学期上什么”。资源里的做法是“逐学期摘洋葱”:每一轮循环扫描所有未分配且当前入度为 0 的课程,按学分降序取前 N 门作为本学期课程,然后统一把它们的后继课程入度减 1。这个“统一释放”的顺序非常关键。

void schedule_by_semester(int n) { int scheduled[MAX_COURSES] = {0}; int semester_groups[MAX_SEMESTERS][MAX_COURSES_PER_TERM]; int semester_size[MAX_SEMESTERS] = {0}; int semester_credits[MAX_SEMESTERS] = {0}; int scheduled_count = 0; int sem = 1; while (scheduled_count < n) { int available[MAX_COURSES]; int avail_count = 0; /* 收集当前可选课程:未分配且入度为 0 */ for (int i = 0; i < n; i++) { if (!scheduled[i] && courses[i].indegree == 0) { available[avail_count++] = i; } } if (avail_count == 0) { printf("错误:剩余课程无法满足先修条件\n"); exit(1); } /* 按学分降序排序,优先排高学分课 */ for (int i = 0; i < avail_count - 1; i++) { for (int j = i + 1; j < avail_count; j++) { if (courses[available[j]].credit > courses[available[i]].credit) { int tmp = available[i]; available[i] = available[j]; available[j] = tmp; } } } int take = avail_count < MAX_COURSES_PER_TERM ? avail_count : MAX_COURSES_PER_TERM; /* 记录本学期课程,但先不释放后继 */ for (int i = 0; i < take; i++) { int idx = available[i]; scheduled[idx] = 1; semester_groups[sem][semester_size[sem]] = idx; semester_credits[sem] += courses[idx].credit; semester_size[sem]++; } /* 统一释放:学期选完后,把后继课程入度减 1 */ for (int i = 0; i < take; i++) { int idx = available[i]; ArcNode *p = courses[idx].firstarc; while (p) { courses[p->adjvex].indegree--; p = p->next; } } scheduled_count += take; sem++; } }

逻辑说明:available 数组收集的是“本轮可以先修的全部课程”,它们之间没有先后依赖,可以放到同一学期。选课的顺序是“学分降序”,这样前面学期学分不会过低。选择完成后才统一释放后继课程,保证同一批次被选中的课程不会因为互相驱动而提前进入本学期——这是整段算法里最容易写错的地方。如果把释放并入选课循环,会出现第一学期就排入十几门课的情况。

参数说明:MAX_COURSES_PER_TERM 是每学期最多课程数,资源里默认 5。实际使用时可以根据培养方案调整,比如某些学期允许 6 门,就可以改成 6 或 7。但要注意,这个值设得越大,单学期学分越高;设得越小,总学期数会拉长。一般高校本科四年制 8 个学期,专业课大概 30-40 门,每学期 4-6 门是合理区间。

3.3 输出格式设计:让课程方案直接放进课程表

资源输出到控制台的格式是有讲究的,不是简单打印课程编号,而是把“学期”“课程编号”“课程名称”“学分”“本学期学分合计”一次性列出来,这样实验结果可以直接截图贴进课设报告:

void print_schedule(int sem_count) { for (int i = 1; i <= sem_count; i++) { printf("========== 第 %d 学期 ==========\n", i); for (int j = 0; j < semester_size[i]; j++) { int idx = semester_groups[i][j]; printf("%s %-20s 学分:%d\n", courses[idx].id, courses[idx].name, courses[idx].credit); } printf("本学期学分合计: %d\n", semester_credits[i]); } }

这个输出格式也方便后续做学分均衡的验证,可以直接把这部分内容重定向到文件里保存。我在验证的时候习惯把输出重定向到 plan.txt:

./course_plan < data.txt > plan.txt

或者改一行代码,把输出写到文件。重定向的好处是可以把结果交给自己写的校验脚本去自动化检查,避免肉眼漏读。

4. 课程设计避坑指南:编码、输入顺序与学分均衡的四个高频翻车点

4.1 中文课程名乱码:UTF-8与GBK的错位

现象:在 Windows 上打开程序,课程名显示成一堆乱码,或者奇怪的汉字位被吞掉。

原因:源码文件或 data.txt 是 UTF-8 编码,而 Windows 控制台默认代码页是 GBK。二者字符集不匹配时,printf 输出的中文就崩了。这不是代码逻辑问题,纯纯是环境问题。

解决:在 Dev-C++ 或 Visual Studio 中,把源文件和 data.txt 都另存为“ANSI”或“GB2312”编码。source 文件用 UTF-8 但编译器默认按本地代码页读取,结果一样会乱。最省事的方法是:先打开资源包里的 data.txt,确认编码,再决定是否在 main 开头加一句 SetConsoleOutputCP(65001)——如果文件是 UTF-8,就加这句把控制台切到 UTF-8;如果文件是 GBK,这句就是多余的。

4.2 文件路径失效:运行时目录和绝对路径的选择

现象:双击 exe 运行时提示“无法打开文件 data.txt”,但在 IDE 里点运行却一切正常。

原因:IDE 运行时当前工作目录一般是项目目录或 Debug 目录,而 exe 文件在别的子目录。直接写相对路径 data.txt 只能命中“当前工作目录”,双击运行时工作目录是 exe 所在目录,于是找不到文件。

解决:不要光靠相对路径。我一般会在 main 里做一步判断,如果默认文件名打不开,就让用户传参数:

int main(int argc, char *argv[]) { const char *data_file = (argc > 1) ? argv[1] : "data.txt"; read_data(data_file); /* ... */ }

这样在 IDE 里可以直接跑,命令行也能跑,双击时用命令行传文件路径即可。调试时真的打不开,先在代码里临时打印 getcwd() 看看实际目录在哪。

4.3 有环数据导致死循环:拓扑排序的“卡死”与检测

现象:程序输出了一部分课程后停住不动,或者打印出“剩余课程无法满足先修条件”,或者陷入无限循环持续不退出。

原因:先修关系里存在环,比如“数据结构”要求先修“算法设计”,“算法设计”又要求先修“数据结构”。这两门课的入度永远不可能减到 0,拓扑排序无法处理它们,队列提前为空。如果代码里 while 循环没有退出条件,就直接死循环。

解决:拓扑排序主循环里,用 count 记录实际输出的顶点数。循环结束后比较 count 和课程总数,小于就说明有环。同时要在学期分配阶段兜底,available_count 为 0 且还有课程未分配时,明确报错退出,而不是继续无限循环。这一点在 3.2 节的代码里已经体现。你也可以写一个独立的“环路检测”函数,用 DFS 标记三色状态,在数据载入后就提前发现环。

4.4 只看课程数不看学分:均衡方案的逻辑缺口

现象:按照每学期固定 5 门课切分后,第一学期全是 4-5 学分的高阶课,总学分飙到 18-20;最后一学期只剩几门 2 学分的选修课,总学分只有 6-8。看起来“每学期课数均匀”,但实际课业负担严重失衡。

原因:课程学分差异很大,仅按课程数量切分没有考虑学分分布。高学分的公共课通常先修条件少,会被拓扑排序放在序列前面,导致全部挤进前面学期。

解决:资源里选课的排序是“学分降序优先”,但这只能保证高学分课不会排后,并不能保证每学期学分总和均衡。更稳的做法是按“学期学分目标”来取课:先算出总学分除以估算学期数得到每学期参考学分,然后在可选课程里每次挑“补上后离参考学分最近的课”。这个启发式比单纯按学分降序好很多,在 5.1 节我再给出验证脚本。

5. 让排课结果更有说服力:学分均衡校验、手工调整与扩展思路

5.1 学分均方差检查脚本:验证结果是否真的均衡

一份排课方案交到导师手里,第一眼被问的往往是“学分合理吗”。手工算太慢。我建议在资源包里额外配一个校验脚本——资源里自带的核查工具可以快速打印每个学期的学分分布,同时验证先修约束有没有被破坏:

python check_plan.py plan.txt data.txt

校验脚本核心逻辑如下:

import sys def parse_courses(path): courses = {} prereqs = {} with open(path, encoding='gbk') as f: lines = [line.strip() for line in f if line.strip()] i = 0 while i < len(lines) and lines[i] != 'END': cid, name, credit = lines[i].split() courses[cid] = (name, int(credit)) pre_list = lines[i + 1].split() prereqs[cid] = [] if pre_list == ['0'] else pre_list i += 2 return courses, prereqs def parse_plan(path): plan = {} with open(path, encoding='gbk') as f: for line in f: parts = line.split() if len(parts) >= 3 and parts[0].isdigit(): sem = int(parts[0]) plan.setdefault(sem, []).append((parts[1], int(parts[2]))) return plan courses, prereqs = parse_courses('data.txt') plan = parse_plan('plan.txt') # 校验先修约束 violations = [] for sem, items in plan.items(): taken = set() for cid, _ in items: for pre in prereqs.get(cid, []): if pre not in taken: violations.append((cid, pre)) taken.add(cid) # 打印每学期学分 total_credit = sum(courses[cid][1] for cid in courses) sem_credits = [] for sem in sorted(plan.keys()): credit_sum = sum(c for _, c in plan[sem]) sem_credits.append(credit_sum) print(f"第{sem}学期: {len(plan[sem])}门课, 学分合计 {credit_sum}") avg = total_credit / len(plan) var = sum((x - avg) ** 2 for x in sem_credits) / len(sem_credits) print(f"总学分 {total_credit}, 学期平均学分 {avg:.1f}, 方差 {var:.2f}") if violations: print("先修约束违反:", violations) else: print("所有先修约束满足")

脚本分三段职责:解析课程文件里的先修关系、解析排课结果、校验并统计。输出信息中“方差”是最直观的均衡指标,方差越小说明各学期学分越接近。用这个脚本,你可以在答辩时直接展示“传统按课数切分方案的方差是 X,当前方案降到 Y”,这就是一份有数据支撑的改进说明。

5.2 从“能跑”到“能答辩”:手工微调、边界说明与报告配套

自动排课不可能完美,总会有一些实际原因需要手工微调,比如某门课的老师只在单学期开课。微调时不能硬改输出,我习惯写一个“可移动性检查”的小逻辑:判断一门课能否从 A 学期挪到 B 学期,本质是查它在 B 学期的所有目标课程里是否作为先修课出现。如果该课的“后继课程”都在 B 学期以后,就可以移动。

int can_move_to(int course_idx, int target_sem) { for (int j = 0; j < semester_size[target_sem]; j++) { int target = semester_groups[target_sem][j]; ArcNode *p = courses[course_idx].firstarc; while (p) { if (p->adjvex == target) return 0; p = p->next; } } return 1; }

代码里的边界说明也要对应写清楚:比如“本程序假设所有课程可以在任意学期开设”“不区分秋学期和春学期”“每学期课数上限可变但需要手工调整参数”。这种在报告里主动说明限制的写法,比等导师发现问题再解释要好得多。

课程设计报告的结构我建议四部分:问题描述与输入格式、AOV 网的建模与数据结构选择、拓扑排序加学期划分的算法流程、实验结果与均衡性对比分析。这份资源里的源码和样例数据正好完整覆盖这四个部分,直接照着跑一遍收集数据,再结合你自己的课程列表替换 data.txt 里的内容,就能产出一份有数据的报告。从那以后,我每次做完这种带约束的课设,都强制走一遍校验脚本再交,而不是凭肉眼瞄一眼排课结果就觉得通了——有东西检查数据总比答辩被问住强。希望这套思路在你排自己的课表时也用得上。

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

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

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

立即咨询