简介:围绕教学计划编制问题整理的论文与源程序文档,面向数据结构课程设计、图论应用学习及需要理解拓扑排序实现的学生与开发者。内容以课程先修关系为切入点,将课程体系抽象为有向无环图(AOV网),讨论深度优先与广度优先拓扑排序的差异,并结合高等数学、C语言程序设计、数据结构等31门课程说明依赖建模、入度计算、邻接表组织及队列辅助遍历的实现思路。压缩包共1个doc文件,约190KB,集中收录问题描述、设计思路、AOV网与邻接表示例、功能函数设计及完整源代码,便于对照论文理解initGraph、拓扑排序、入队出队等模块的编码细节。已有297人学习,适合用作课程设计参考、实验报告素材与图论算法入门案例,也能帮助读者掌握从实际问题抽象到程序实现的完整路径。
1. 从一张排课表说起:教学计划编制问题到底难在哪
每学期排课最怕的不是课多,而是先修关系被排反:数值分析排在高等数学前,数据结构排在C语言前,学生选课系统直接卡住。教学计划编制问题论文与源程序要解决的就是这件事:把31门计算机类专业课程抽象成AOV网,用广度优先拓扑排序排出满足先修约束的课程顺序,再把线性序列切成学期课表,并给没有先修关系的公共通识课留出插入位置。这套方案适合正在做数据结构课设、图论毕设,或者要给教务系统写排课原型的人。下面按需求、设计、实现、避坑、验收一路拆开,能照着改。
2. 需求拆解与总体设计:31门课程如何落成一张AOV网
教学计划编制不是简单地把课程按学期平均分配。真正要处理的是课程之间的先后约束:一门课必须在若干门先修课学完之后才能开,所有课程又必须全部排进培养方案。把课程看成顶点,先修关系看成有向边,整张培养方案就是一张有向无环图。题目给出的31门课程里,大多数课程都能挂到这张图上,少量公共通识课与专业课没有边相连,属于孤立点,先不参与拓扑排序,等专业课程顺序确定后再按学期容量插入。这个拆法决定了后面所有数据结构和算法选择。
2.1 先修关系抽象:顶点、边与入度的业务含义
顶点编号从1到31,每个顶点存课程名、入度和边表头指针。边表里存的是“学完当前课程之后可以学的后续课程”编号。比如1号高等数学的边表里有15数值分析和28计算机图形学,表示高数必须先于这两门课;4号C语言程序设计的边表里有8面向对象、9数据结构、10汇编语言、24 Matlab编程技术、28计算机图形学和30嵌入式系统,表示C语言是这六门课的直接先修课。入度在排序前不是手工写死的,而是扫一遍所有边表统计出来:每出现一条u指向v的边,v的入度加一。
题目给出的边表按源程序调用序列还原后,关键依赖如下表。课程之间的先后关系不必深究现实教学计划是否完全合理,重点是把图建对、把拓扑排序跑通。
| 先修课编号 | 先修课名称 | 直接后续课(编号+名称) |
|---|---|---|
| 1 | 高等数学 | 15数值分析、28计算机图形学 |
| 2 | 概率论与数理统计 | 15数值分析、28计算机图形学 |
| 3 | 线性代数 | 15数值分析、28计算机图形学 |
| 4 | C语言程序设计 | 8面向对象编程技术、9数据结构、10汇编语言、24 Matlab编程技术、28计算机图形学、30嵌入式系统 |
| 5 | 电路理论 | 6模拟电子技术 |
| 6 | 模拟电子技术 | 7数字逻辑 |
| 7 | 数字逻辑 | 11计算机组成原理 |
| 8 | 面向对象编程技术 | 14数据库概论、17 JAVA程序设计、19现代通信技术、21软件工程 |
| 9 | 数据结构 | 14数据库概论、21软件工程、22算法设计与分析 |
| 10 | 汇编语言 | 11计算机组成原理 |
| 11 | 计算机组成原理 | 12微机原理与接口技术 |
| 12 | 微机原理与接口技术 | 13操作系统、16计算机控制基础、18编译原理、30嵌入式系统 |
| 13 | 操作系统 | 26分布式数据库 |
| 15 | 数值分析 | 23人工智能 |
| 18 | 编译原理 | 20计算机网络 |
| 19 | 现代通信技术 | 25电子商务、31多媒体技术 |
| 21 | 软件工程 | 23人工智能 |
| 25 | 电子商务 | 27网络数据库编程 |
| 27 | 网络数据库编程 | 29图像处理 |
这张表里最容易被忽略的是入度。高等数学、概率论、线性代数、C语言、电路理论的入度都是0,它们可以第一批开课;数值分析的入度是3,必须等1、2、3都学完;计算机图形学的入度是4,必须等1、2、3、4都学完;人工智能的入度是2,来自15数值分析和21软件工程。入度不是静态属性,拓扑排序每弹出一条边,目标顶点的入度就要减一,减到0才能进队列。这个动态过程是整段程序的核心,后面所有代码都围绕它转。
2.2 数据结构选型:邻接表、链式队列与全局结果数组
31个顶点、三十多条边,用邻接矩阵会浪费大量空间,而且每次找邻接点都要扫一行。邻接表更贴合稀疏图:每个顶点只挂自己真实存在的后续课,统计入度和更新入度都只访问有效边。队列用链式结构,入队出队都是O(1),不需要预先估计队列长度。结果数组pTopo单独放在全局区,存拓扑排序输出的课程编号,主函数最后按数组下标打印课程名。全局数组在这里不是偷懒,而是避开局部数组返回后生命周期结束的问题,后面避坑章节会展开。
结构体定义可以按下面这样写,课程编号统一用1到31,数组下标统一用0到30,边表节点里的index存目标课程编号,不存下标。
#include <stdio.h> #include <stdlib.h> #define MAX_COURSE 31 int pTopo[MAX_COURSE]; // 拓扑排序结果,存1-based课程编号 typedef struct edgeNode { int index; // 目标课程编号,1..31 struct edgeNode *next; } EdgeNode, *EdgePtr; typedef struct vertexNode { int vertexInfo; // 课程编号,1..31 int inDegree; // 当前入度 EdgePtr next; // 边表头指针 } VertexNode, *VertexPtr; typedef struct graphList { int n; // 顶点数 VertexPtr vertex[MAX_COURSE]; } GraphList, *GraphPtr; typedef struct qNode { int info; // 入队课程编号 struct qNode *link; } QNode, *QPtr; typedef struct linkQueue { QPtr head; QPtr rear; } LinkQueue, *QueuePtr;这段定义里,vertex数组的每个元素单独malloc,图结构本身不存顶点对象,只存指针。边表节点只挂目标编号,不挂权重,因为教学计划只关心“有没有先修关系”,不关心“先修课占多少学分”。队列节点存课程编号,出队后直接拿编号去vertex数组里找对应顶点。pTopo的长度固定31,因为课程总数固定,换课程数量时改宏定义和循环上限即可。
这里有一个设计取舍:顶点结构体里既有vertexInfo又有数组下标。如果约定vertex[i]->vertexInfo == i + 1,其实可以省掉vertexInfo,但保留它能让打印和调试时少一次换算。代价是初始化时必须把1到31写进去,后面所有按下标访问的地方仍然要用课程编号减一。这个“双轨编号”是后面翻车的高发区,设计阶段就要在注释里写清楚。
2.3 模块划分与函数职责
整份程序拆成图初始化、边表插入、入度统计、拓扑排序、链式队列五组函数。主函数只负责建图、按边表调用插入函数、调用拓扑排序、打印结果。这样拆的好处是每个函数只做一件事,调试时可以单独打断点看入度数组和队列状态,不会在一大段main里迷路。
| 函数名 | 输入 | 职责 | 输出 |
|---|---|---|---|
| initGraph | 无 | 分配31个顶点,入度清零,边表置空,课程编号1..31 | 图指针 |
| initEdgeList | 图指针、目标课程编号、起点下标 | 在起点边表尾部插入一条边 | 图指针 |
| findIndegree | 图指针 | 扫所有边表,统计每个顶点入度 | 无 |
| topoSort | 图指针 | 广度优先拓扑排序,结果写入pTopo | 无 |
| creatEmptyQueue | 无 | 创建空链式队列 | 队列指针 |
| isEmptyQueue | 队列指针 | 判断队列是否为空 | 1为空,0非空 |
| enQueue | 队列指针、课程编号 | 入队 | 无 |
| deQueue | 队列指针 | 出队并释放节点 | 无 |
| getQueueHead | 队列指针 | 取队头课程编号,不出队 | 课程编号 |
主函数骨架可以写成下面这样。这里只列几条边做示例,完整边表按2.1节的依赖表逐条补齐。注意initEdgeList的第二个参数是目标课程编号,第三个参数是起点在vertex数组里的下标,起点下标从0开始。
int main(void) { GraphPtr g = initGraph(); // 1 高等数学 -> 15 数值分析、28 计算机图形学 g = initEdgeList(g, 15, 0); g = initEdgeList(g, 28, 0); // 4 C语言程序设计 -> 8 面向对象、9 数据结构、10 汇编语言 g = initEdgeList(g, 8, 3); g = initEdgeList(g, 9, 3); g = initEdgeList(g, 10, 3); // 9 数据结构 -> 22 算法设计与分析 g = initEdgeList(g, 22, 8); // 其余边按依赖表继续补齐 topoSort(g); for (int i = 0; i < g->n; i++) { printf("%d ", pTopo[i]); } printf("\n"); return 0; }这段骨架的关键是“起点下标”和“目标编号”的对应关系。比如4号C语言在数组里是vertex[3],所以第三个参数传3;它的后续课数据结构编号是9,所以第二个参数传9。写边表调用时只要记住“目标写编号、起点写下标”,大部分错位都能避免。主函数最后只打印编号,课程名可以另外用字符串数组按编号映射,打印时用array[pTopo[i] - 1]。
3. 核心算法落地:广度优先拓扑排序的完整实现
拓扑排序有两种常见写法:深度优先和广度优先。深度优先容易一条路走到黑,把本来可以并行的课程排成前后串行。比如高等数学和C语言程序设计都是入度为0的基础课,DFS可能先沿着C语言走到数据结构、算法设计与分析,再回头排高等数学,出来的顺序虽然满足先修关系,但不符合“基础课先开、后续课跟进”的教学节奏。广度优先用队列把当前所有入度为0的课程先处理完,再处理下一层,正好对应“先学完一批基础课,再学后续课”的排课习惯。这一章把入度统计、队列驱动排序、边表插入三段代码拆开讲。
3.1 入度统计:findIndegree 的遍历顺序与下标换算
findIndegree不依赖队列,它只做一件事:把每个顶点的入度清零,然后扫所有边表,每遇到一条u指向v的边,就把v的入度加一。外层循环遍历起点,内层循环遍历起点的边表。边表节点里的index是目标课程编号,访问vertex数组时要减一。这个减一是整段程序最容易漏的地方,漏了就会把入度加到别的课程头上,排序结果看起来有课,但顺序不对。
void findIndegree(GraphPtr g) { int i; EdgePtr p; // 入度先全部清零,避免上一次统计的残留 for (i = 0; i < g->n; i++) { g->vertex[i]->inDegree = 0; } // 扫所有起点的边表,目标课程入度加一 for (i = 0; i < g->n; i++) { p = g->vertex[i]->next; while (p != NULL) { int target = p->index; // 目标课程编号,1..31 g->vertex[target - 1]->inDegree++; p = p->next; } } }这段代码的时间复杂度是O(n+e),n是顶点数,e是边数。外层跑31次,内层总共跑边表长度次。入度统计必须放在拓扑排序开头,不能放在建图过程中边插边加,因为建图时如果重复调用initEdgeList,入度会重复累加,后面很难排查。统一在topoSort里先调findIndegree,建图阶段只负责挂边,职责清楚。
参数上唯一要盯的是target - 1。target来自边表index,是1-based课程编号;vertex数组是0-based。如果某条边写成了initEdgeList(g, 9, 3),表示4号C语言指向9号数据结构,findIndegree会把vertex[8]的入度加一,而不是vertex[9]。调试时可以在while循环里打印target和g->vertex[target - 1]->vertexInfo,确认两者一致。
3.2 队列驱动的BFS拓扑排序:topoSort 逐行拆解
topoSort先把入度为0的顶点全部入队,然后进入循环:取队头课程编号,出队,把编号写进pTopo,再扫它的边表,把每个后续课的入度减一。后续课入度减到0就入队。循环结束时如果写进pTopo的课程数小于总课程数,说明图里有环,排序不完整。整个过程靠队列的先进先出保证广度优先:第一批基础课先出队,它们带来的后续课排在后面;同一层的课程按入队顺序输出,不会一条路走到黑。
void topoSort(GraphPtr g) { int i, count = 0; QueuePtr q; EdgePtr p; findIndegree(g); q = creatEmptyQueue(); // 第一批:所有入度为0的课程入队 for (i = 0; i < g->n; i++) { if (g->vertex[i]->inDegree == 0) { enQueue(q, g->vertex[i]->vertexInfo); } } while (!isEmptyQueue(q)) { int u = getQueueHead(q); // 当前可以开课的课程编号 deQueue(q); pTopo[count++] = u; // 记录到结果数组 // 扫u的边表,后续课入度减一 p = g->vertex[u - 1]->next; while (p != NULL) { int v = p->index; // 后续课编号,1..31 g->vertex[v - 1]->inDegree--; if (g->vertex[v - 1]->inDegree == 0) { enQueue(q, g->vertex[v - 1]->vertexInfo); } p = p->next; } } if (count < g->n) { printf("AOV网存在环,无法排出完整教学计划\n"); } }这段代码里,u - 1和v - 1是两个必须盯住的下标换算。u来自队列,存的是课程编号,所以访问u的边表要用vertex[u - 1];v来自边表index,也是课程编号,所以更新入度要用vertex[v - 1]。如果某门课一直不入队,先看它的入度有没有减到0,再看边表里是不是有重复边把入度加多了。count的作用是判环:正常无环图里31个顶点都会进pTopo一次,count等于31;有环时环上的顶点入度永远减不到0,count小于31。
时间复杂度还是O(n+e)。每个顶点入队一次、出队一次,每条边被扫一次,入度减一次。空间上队列最多存n个顶点,pTopo固定31个int,邻接表存e个边节点。对于31门课这个规模,跑起来是瞬间的事,但这个复杂度分析要写进论文里,答辩时老师很爱问“为什么不是O(n²)”。
3.3 边表构建:initEdgeList 的尾插法与主函数调用
initEdgeList负责在起点边表尾部插入一个目标节点。尾插的好处是边表顺序和主函数调用顺序一致,调试时打印边表能直接对照依赖表;头插虽然代码短,但边表顺序反了,入度统计不受影响,可读性差很多。插入时先malloc一个边节点,把目标编号写进index,next置空,然后从起点边表头开始找到最后一个节点,把新节点挂上去。如果起点边表为空,直接挂到头指针。
GraphPtr initEdgeList(GraphPtr g, int to, int fromIndex) { EdgePtr e = (EdgePtr)malloc(sizeof(EdgeNode)); if (e == NULL) { printf("Out of space!\n"); return g; } e->index = to; // 目标课程编号,1..31 e->next = NULL; VertexPtr v = g->vertex[fromIndex]; // 起点,0-based下标 if (v->next == NULL) { v->next = e; } else { EdgePtr p = v->next; while (p->next != NULL) { p = p->next; } p->next = e; } return g; }参数to是目标课程编号,fromIndex是起点在vertex数组里的下标。主函数调用时,每写一条边都要核对这两个值。比如“4号C语言 -> 9号数据结构”写成initEdgeList(g, 9, 3);“9号数据结构 -> 22号算法设计与分析”写成initEdgeList(g, 22, 8)。如果写成initEdgeList(g, 9, 4),就变成了5号电路理论指向9号数据结构,图的结构错了,排序结果自然不对。
尾插还有一个副作用:如果同一条边被重复调用两次,边表里会出现两个相同目标节点,findIndegree会把目标入度加两次,后续课可能永远等不到入度变0。建图阶段最好在插入前做一次查重,或者干脆保证主函数调用表里每条边只出现一次。后面避坑章节会把这个问题展开。
4. 从拓扑序列到学期课表:课程分层与公共课插入
拓扑排序输出的是一个线性序列,但教学计划要的是“每学期开哪些课”。线性序列只能保证先修关系不被违反,不能直接当课表用,因为同一学期可以并行学多门没有依赖的课程。把线性序列转成学期课表,通常的做法是给每个顶点算一个层级:入度为0的课程放第1层,后续课的层级等于所有先修课层级加一的最大值。这个层级就是最早可开课学期,同一层的课程可以放在同一学期。公共通识课不在这张AOV网里,等专业课程层级排好后再按学期学分余量插入。
4.1 拓扑序列不是最终课表:按学期容量做分层
在topoSort里加一个level数组,初始全部置1。每当从u指向v,更新level[v - 1] = max(level[v - 1], level[u - 1] + 1)。这个更新要放在入度减一之前或之后都行,只要在遍历边表时做。level算的是最长前驱路径,能保证v不会排在任何一门先修课之前。排完以后按level分组,第1层放第1学期,第2层放第2学期,依此类推。如果某一层课程太多,再按学分拆分到相邻学期,但拆分不能把后续课提前。
int level[MAX_COURSE]; void topoSortWithLevel(GraphPtr g) { int i, count = 0; QueuePtr q = creatEmptyQueue(); EdgePtr p; findIndegree(g); for (i = 0; i < g->n; i++) { level[i] = 1; if (g->vertex[i]->inDegree == 0) { enQueue(q, g->vertex[i]->vertexInfo); } } while (!isEmptyQueue(q)) { int u = getQueueHead(q); deQueue(q); pTopo[count++] = u; p = g->vertex[u - 1]->next; while (p != NULL) { int v = p->index; if (level[u - 1] + 1 > level[v - 1]) { level[v - 1] = level[u - 1] + 1; } g->vertex[v - 1]->inDegree--; if (g->vertex[v - 1]->inDegree == 0) { enQueue(q, g->vertex[v - 1]->vertexInfo); } p = p->next; } } }按这套层级粗排,第1学期可以放1到5号基础课;第2学期放6模拟电子、8面向对象、9数据结构、10汇编语言、15数值分析、24 Matlab、28计算机图形学;第3学期放7数字逻辑、14数据库概论、17 JAVA、19现代通信、21软件工程、22算法设计与分析;第4学期放11计算机组成原理、23人工智能、25电子商务、31多媒体技术;第5学期放12微机原理、27网络数据库编程;第6学期放13操作系统、16计算机控制基础、18编译原理、29图像处理、30嵌入式系统;第7学期放20计算机网络、26分布式数据库。这个分层只是示例,实际排课还要看每学期学分上限和课程学时,但层级关系不能乱。
层级表的价值在于把“拓扑序列”翻译成教务能看懂的学期语言。答辩时老师如果问“你这31门课怎么分学期”,直接拿level表讲:第1层是没有先修课的基础课,第2层是学完基础课才能开的课,依此类推。比背一串线性序列清楚得多。
4.2 公共通识课的插入策略:孤立点与学分约束
公共通识课在AOV网里是孤立点,入度和出度都为0,拓扑排序不会把它们排进去。处理办法是先把专业课程按层级排好,算出每学期已用学分,再拿公共课的学分去填余量。填的时候从第1学期往后扫,找到第一个“已用学分 + 本课学分 <= 学期上限”的学期就放进去。这样公共课不会挤爆某一学期,也不会破坏专业课程的先修关系。学期上限可以按学校实际培养方案设,比如前几学期高一点,后面低一点。
int termCap[8] = {26, 26, 24, 22, 20, 18, 16, 12}; // 每学期学分上限示例 int termUsed[8] = {0}; // 已用学分 void insertPublicCourse(int courseId, int credit) { for (int t = 0; t < 8; t++) { if (termUsed[t] + credit <= termCap[t]) { assignCourseToTerm(courseId, t + 1); // 放到第t+1学期 termUsed[t] += credit; return; } } printf("公共课%d没有学期容量可插入\n", courseId); }这段逻辑里,termCap和termUsed是插入公共课的依据。专业课程先占学分,公共课再填缝。如果某门公共课学分较大,前面学期都放不下,就往后找,实在找不到再提示调整学期上限。公共课不参与拓扑排序,所以它们的插入顺序不影响先修关系,只影响每学期负担是否均衡。把公共课随机撒进去也能跑,但按学分余量填更接近真实排课,论文里也更好写。
4.3 结果校验:人工拓扑与程序输出对拍
程序跑出序列后,不能只看“课都出来了”就收工。写一个小的校验脚本,把边表和输出序列读进去,逐条检查每条边的起点是否排在终点前面。这个脚本不依赖C程序内部状态,纯做结果对拍,答辩前跑一遍能挡住大部分低级错误。下面用Python写一个最小校验器,edges里放边表,seq里放程序输出或手工排的序列。
edges = [ (1, 15), (1, 28), (2, 15), (2, 28), (3, 15), (3, 28), (4, 8), (4, 9), (4, 10), (4, 24), (4, 28), (4, 30), (5, 6), (6, 7), (7, 11), (8, 14), (8, 17), (8, 19), (8, 21), (9, 14), (9, 21), (9, 22), (10, 11), (11, 12), (12, 13), (12, 16), (12, 18), (12, 30), (13, 26), (15, 23), (18, 20), (19, 25), (19, 31), (21, 23), (25, 27), (27, 29) ] seq = [1, 2, 3, 4, 5, 6, 8, 9, 10, 15, 24, 28, 7, 14, 17, 19, 21, 22, 11, 23, 25, 31, 12, 27, 13, 16, 18, 29, 30, 20, 26] pos = {course: i for i, course in enumerate(seq)} for u, v in edges: assert pos[u] < pos[v], f"先修关系被破坏: {u} -> {v}" print("拓扑序列合法,共", len(seq), "门课")脚本先建立课程编号到序列位置的映射,再逐条边断言pos[u] < pos[v]。断言失败会直接打印哪条边被违反。这个校验器还可以反过来用:手工排一个教学计划,跑一遍脚本,如果全部通过,说明人工排序没有漏掉先修约束。程序输出和人工结果对拍一致,论文里的“运行与测试”部分就有据可写。
5. 避坑与常见问题:拓扑排序课设里最容易翻车的五处
拓扑排序代码不长,但课设里翻车往往不在算法思想,而在编号、数组生命周期、队列判空这些细节。下面五处是这类教学计划编制项目里最常见的问题,每一处都按“现象、原因、解决”写清楚,调试时可以直接对号入座。
5.1 课程编号从1开始,数组下标从0开始
现象是程序能跑,但打印出来的课程名和预期对不上,第一门课变成第二门,后面偶尔少一门。原因是对外课程编号用1到31,vertex数组下标用0到30,边表index又存1-based编号,代码里有的地方减一、有的地方没减。比如findIndegree里写g->vertex[p->index]->inDegree++,就比正确写法多偏了一位,入度加到了下一门课上。解决方法是统一约定:所有对外编号保持1..31,所有数组访问一律vertex[id - 1],并在代码里写一个小函数courseIndex(int id)做换算。建图完成后先打印每个顶点的vertexInfo和入度,确认1号高数入度是0、15号数值分析入度是3,再往下跑排序。
5.2 全局结果数组与局部数组返回
现象是拓扑排序在某个编译器里能显示前二十几个结果,换一个编译器只显示前两个,后面的数据像被吞了。原因是为了图省事,在topoSort里定义了一个局部数组int result[31],排序完把数组名返回给主函数。函数返回后局部数组所在的栈帧已经失效,主函数拿到的指针指向一块随时会被覆盖的内存,编译器优化级别不同,表现就不一样。解决方法是把pTopo定义成全局数组,或者由调用方传入缓冲区指针和长度,函数只往里写,不返回局部数组。这个坑在旧编译器上尤其隐蔽,因为有时候能“碰巧”打印出正确结果。
5.3 深度优先还是广度优先:并行课程被排成串行
现象是排序结果满足先修关系,但教学节奏不对:C语言程序设计、数据结构、算法设计与分析排在一起,高等数学、电子电路、模拟电子电路排到后面。原因是用了深度优先遍历,从C语言出发一条路
本文还有配套的精品资源,点击获取