简介:一套面向高校教务场景的毕业设计排课系统源码包,适合计算机相关专业学生用于课程设计、毕业设计或SpringBoot开发练习。系统围绕排课核心业务,覆盖管理员、教师、学生、课程、教室、教室资源等管理模块,重点演示多条件下课表编排与教学资源分配的实现思路。压缩包共235个文件,以82个HTML页面、56个Java源码、55个class文件、31个XML配置为主,同时包含SpringBoot的YML配置、SQL数据库脚本和启动脚本等,包体仅617KB,结构完整且便于导入开发。项目内含控制器、实体类、业务逻辑及前端页面,从数据库建表到页面交互均有完整对应,目录结构清晰,便于二次开发和答辩讲解;SQL脚本可直接建库,导入开发环境后即可运行排课管理、课表展示等主要功能。资源中附有需求分析与排课流程说明,能帮助理解从业务梳理到项目落地的完整过程,已有1133人学习下载。
1. 高校排课系统:一个让教务老师少加班 20 天的毕业设计选题
每年大四下学期,总有同学抱着“管理系统”类的题目来找我,其中高校排课系统是出现频率最高的之一。原因很直接:它不像电商系统那样业务套路固定,也不像推荐系统那样对数学要求高,它的复杂度集中在「课程、教师、教室、时间」四个维度的冲突约束上,既有数据库建模的功夫,又有算法优化的空间,特别好出工作量,也很好答辩。但说实话,我见过太多人把排课系统做成「课程信息增删改查」,排课功能全靠手动 drag and drop,这样答辩时很容易被问住:你的系统到底解决了什么?所以这篇笔记,我按自己带毕设的惯用路径把整套方案拆开讲——从数据库 SQL 脚本设计,到自动排课核心算法,再到前后端联调,全程落到可复现的代码和参数上。无论你是想直接拿去改,还是想搞清楚里面每个坑,这篇文章都按能跑通的标准写。
2. 先把数据地基打对:排课系统的核心表设计与 SQL 脚本写法
排课系统的表结构,第一原则是「约束前置」。什么叫约束前置?就是你建表的时候,就把课程、教师、教室、时间之间的绑定关系考虑进去,而不是等写业务代码时再各种 if 判重。很多毕设翻车,翻在后期加字段、改外键、数据对不上,根源就是表设计阶段少了几张关联表和几个唯一约束。
2.1 五张核心表:从基础数据到排课结果的数据流向
我一般会先画一张数据流向图,字段跟着流程走。你需要的最小表集合是:学生表、教师表、教室表、课程表、排课结果表。听起来很常规,但关键在每一张表的字段取舍。
学生表不用放班级之外太多冗余信息,student_id 为主键,class_id 关联班级表即可。教师表要有 teacher_id、name、department,以及一个很关键但常被忽略的字段 max_hours_per_week——周最大课时数,这是后面排课算法里硬约束的参数来源。教室表需要 room_id、capacity、building、is_lab(是否为实验室,因为实验课和理论课的排课规则不一样)。课程表是重头戏,除了 course_id、name、credit、hours,还要有 course_type 字段区分理论课/实验课/体育课,以及 week_frequency(每周几次)和 preferred_time_slots(教师偏好的时间段,比如上午第一节不想排),这两个字段直接影响排课算法。
排课结果表是整个系统的核心业务表,字段至少要有:schedule_id 主键、course_id、teacher_id、room_id、weekday、start_section、end_section、week_list(第几周到第几周),再加一个 status 字段(已锁定/可调整)。这里必须设计一个联合唯一索引,否则同一间教室在同一个时间段会被排进两门课,数据层面就乱套了。索引这么建:
CREATE UNIQUE INDEX idx_room_time ON schedule_result (room_id, weekday, start_section, week_list); CREATE UNIQUE INDEX idx_teacher_time ON schedule_result (teacher_id, weekday, start_section, week_list); CREATE UNIQUE INDEX idx_class_time ON schedule_result (class_id, weekday, start_section, week_list);三个唯一索引分别锁死教室冲突、教师冲突、班级冲突,这是排课系统数据库层的硬约束,也是后面算法做冲突检测的底牌。
2.2 SQL 脚本怎么组织:从建库到测试数据的完整顺序
别把 SQL 脚本写成一大坨,然后让客户自己一行行跑。合理的脚本顺序是:建库建表 → 插入基础字典数据(学期、节次、星期)→ 插入教师/教室/课程模拟数据 → 插入排课约束数据 → 最后是查询视图。每段之间用注释分隔,并且全部加上DROP TABLE IF EXISTS前缀,方便反复执行。下面这段是节次字典表的脚本,很多排课系统表设计里漏掉这张表,结果前端显示只能硬编码,后期改上下课时间就麻烦了。
-- 节次字典表:定义一天的教学节次区间 DROP TABLE IF EXISTS t_section; CREATE TABLE t_section ( section_id INT PRIMARY KEY AUTO_INCREMENT COMMENT '节次ID', section_no INT NOT NULL COMMENT '第几节次,如1表示第1-2节', start_time TIME NOT NULL COMMENT '上课时间', end_time TIME NOT NULL COMMENT '下课时间', is_morning TINYINT DEFAULT 1 COMMENT '1上午 0下午晚上', sort_order INT DEFAULT 0 COMMENT '排序,用于前端展示' ) COMMENT='节次时间字典表'; INSERT INTO t_section (section_no, start_time, end_time, is_morning, sort_order) VALUES (1, '08:00', '09:40', 1, 1), -- 第1-2节 (2, '10:00', '11:40', 1, 2), -- 第3-4节 (3, '14:00', '15:40', 0, 3), -- 第5-6节 (4, '16:00', '17:40', 0, 4), -- 第7-8节 (5, '19:00', '20:40', 0, 5); -- 晚自习/选修课这里的逻辑是:排课结果表里只存 section 编号,不存具体时间,这样如果学校调整作息,只需要改表 t_section,历史排课数据完全不用动。另外注意is_morning字段,排课算法里可以设一个软约束:上午尽量排理论课,下午排实验课,这个字段就是给软约束用的。
2.3 测试数据量级:排课算法验证必须用真实规模的数据
写排课系统毕设,最怕拿 10 个老师 5 间教室来验证算法,然后答辩时说“效果很好”。真实高校的排课规模一般是:每学期 400~800 门课程、150~250 位教师、60~120 间教室、300~500 个班级。哪怕你只做单校区,你的测试数据也得往这个量级靠,否则算法里的性能问题根本暴露不出来。
我建议你的 SQL 脚本里写一个存储过程或者直接用INSERT ... SELECT去批量生成模拟数据。比如生成课程数据,用存储过程循环插入,每门课随机指定教师、学分、周学时,这样数据量可控,且每次跑脚本生成的测试数据集都不一样,便于测试算法鲁棒性。需要生成的模拟数据至少包括:20 个院系的 200 位教师、分布在 6 栋教学楼的 80 间教室、500 门课程、覆盖 4 个年级 120 个班级,以及每门课的周次分布(比如第 1~8 周、第 9~16 周这种分段设置)。
3. 排课算法的选择与实现:从贪心到遗传,一步到位不返工
表结构定完,就到了整个系统最核心的部分——自动排课。很多毕设会在这里陷入一个误区:上来直接上遗传算法,然后花一半时间调参,最后结果还不如手动排得快。我的建议是两段式实现:先做基于优先级的贪心排课,把基本盘稳住,保证无冲突;再视系统完整性接入遗传算法做优化,用「冲突率 + 均衡度」做适应度函数。这样答辩时既能讲清楚基础逻辑,又展示了你对启发式算法的理解。
3.1 贪心排课:用优先级排序解决 80% 的排课需求
贪心算法的核心是排序规则。我常用的优先级权重从高到低是:周课时数多的课程优先(因为可安排的时间窗口短)、有特殊时段要求的课程次之(比如某教师只在周一周三有课)、合班课程再次(因为需要容量足够的大教室,可选教室少)、最后才是普通课程。每次排课时,先查该课程的可选时间集合,再查可选教室集合,取笛卡尔积并过滤冲突,命中即分配。
// 贪心排课核心逻辑:按优先级取课程,找第一个可用时间与教室 public Schedule assignCourse(Course course, List<TimeSlot> timeSlots, List<Classroom> rooms, ScheduleContext context) { // 优先处理该课程的教师时间偏好 List<TimeSlot> preferred = course.getPreferredSlots(); List<TimeSlot> candidates = preferred.isEmpty() ? timeSlots : preferred; for (TimeSlot slot : candidates) { if (!context.isTeacherFree(course.getTeacherId(), slot)) continue; if (!context.isClassFree(course.getClassIds(), slot)) continue; // 按容量从大到小尝试,避免小课占大教室 List<Classroom> sortedRooms = rooms.stream() .filter(r -> r.getCapacity() >= course.getStudentCount()) .sorted(Comparator.comparingInt(Classroom::getCapacity)) .collect(Collectors.toList()); for (Classroom room : sortedRooms) { if (context.isRoomFree(room.getId(), slot)) { Schedule schedule = new Schedule(course, room, slot); context.lock(schedule); // 锁定时间+教室+教师+班级 return schedule; } } } return null; // 返回 null 说明该课程需要人工处理 }这段代码最容易出性能问题的地方在isTeacherFree和isRoomFree。如果你用List去线性遍历已经排好的 Schedule 记录来判断冲突,500 门课排下来时间复杂度直接爆炸。常见做法是用三个Map<时间维度, Set<资源ID>>维护当前占用表,判断冲突是 O(1) 操作。例如Map<String, Set<Long>> teacherBusyMap,key 是「星期几 + 节次编号」,value 是该时段已占用的教师 ID 集合,判断时直接查 set 是否存在。这个细节,能让你排 500 门课的时间从分钟级压到秒级。
3.2 遗传算法优化:把排课问题建模成编码与适应度函数的调优
贪心能保证无冲突,但排出来的课表可能集中在上午,下午大量空闲,或者同一班级一天排满六节课。这时再上遗传算法做二次优化。编码方式用整数串即可:每个基因位表示一门课,基因值表示排课方案 ID(时间+教室组合的索引),染色体长度就是课程总数。适应度函数我是这么设计的:
适应度值 = 权重1 × 教室利用率 + 权重2 × 时间均衡度 - 权重3 × 冲突惩罚
其中时间均衡度可以量化成:同一班级每天的排课节数方差。方差越小,课表越均匀。教室利用率则是看所选教室容量和课程人数之间的匹配度,差值越少利用率越高。初始种群用贪心结果作为一条染色体,其余染色体随机生成,这样保证种群里有优质种子,收敛会快很多。交叉算子用单点交叉,变异算子随机替换某个基因的时间段。迭代 200 代左右基本就能看到适应度曲线收敛。这里给一组我在项目里调得相对稳定的参数:种群规模 100、交叉概率 0.8、变异概率 0.1、锦标赛选择 k=5。注意如果课程规模超过 800,种群规模和迭代次数要相应减半,否则运行时间会让演示环节很难看。
3.3 为什么优先用贪心而不是上来就遗传:算法的边界与说服力
先说结论:贪心算法解决的是「能不能排出来」,遗传算法解决的是「排得好不好」。毕设答辩时,评委大概率会问一个问题:“你这个算法和别人的纯贪心算法相比,优势在哪里?”如果你只做了贪心,回答会很单薄;如果你只做了遗传,评委又会质疑收敛性和初始解。两段式的设计正好形成递进逻辑:先用贪心输出可行解,再用遗传在这个可行解基础上优化均衡度,优化前后的课程表对比截图,就是你最好的答辩素材。另外,贪心还有一个额外好处:当遗传算法变异出一个不可行解时(比如一个教室时间被占用了),你的冲突检测代码可以从贪心部分直接复用,做修复比重新随机生成更高效。
4. 跑通一个能演示的后端:Spring Boot + MyBatis 的模块划分与配置要点
排课系统这类毕设,后端技术栈最常见的组合是 Spring Boot + MyBatis + MySQL。我做毕设指导时通常推荐这个组合,不是因为它是「标准答案」,而是因为它的资料最多、出问题好搜、答辩时也不会被挑技术选型的毛病。后端模块按业务域拆包:controller、service、mapper、entity 是常规四层,另外单独拆一个 algorithm 包放排课算法,避免算法代码和业务代码混在一起。
4.1 核心接口设计:排课执行接口和冲突查询接口
排课执行接口是系统的门面。我会把它设计成同步接口加返回统计信息,这样前端调用一次就能拿到排课成功率和冲突列表。接口参数接收排课学期和批次 ID,返回结果里包含成功排课课程数、失败课程数、冲突详情列表。失败课程需要落到一张「人工处理表」里,前端单独渲染一个列表,让教务老师去手动分配。这个设计解决的是排课系统里最现实的问题:算法不能覆盖所有边界场景,必须留人工兜底的口子。
@PostMapping("/api/schedule/execute") public Result<ScheduleReport> executeSchedule(@RequestBody ScheduleRequest request) { // request 携带 semesterId(学期ID)和 batchId(批次ID) ScheduleContext context = scheduleContextBuilder.build(request); // 第一遍:贪心排课,保证大多数课程有解 List<CourseSchedule> unsolved = greedyScheduler.schedule(context); // 第二遍:遗传算法优化已排课程的均衡度 geneticOptimizer.optimize(context.getScheduledList()); // 生成排课报告:冲突率、教室利用率、时间均衡度统计 ScheduleReport report = reportGenerator.generate(context, unsolved); return Result.success(report); }这段代码的逻辑说明:scheduleContextBuilder.build()负责加载课程、教师、教室、已排课程数据到内存缓存,这一步要抽查 SQL 效率,如果这里查库查成 N+1,500 门课会卡哭你。greedyScheduler和geneticOptimizer是两个独立类,分别对应第 3 章的两个算法模块,接口设计上二者不互相依赖,方便后面替换算法实现。返回的ScheduleReport里包含三项核心指标,这三项指标会在前端用图表展示,也是你答辩时数据支撑的来源。
4.2 MyBatis 关联查询与 SQL 脚本联调:批量插入性能优化
排课系统执行排课动作后,要把排课结果批量写入表 schedule_result。这时候最容易出现的性能坑就是一条条 insert,500 门课要执行 500 次插入,外加每次插入还要更新教师占用集合,体验很差。正确做法是用 MyBatis 的批量插入,在 mapper XML 里写 foreach 动态 SQL。要注意 MySQL 对单条 SQL 的长度是有限制的,max_allowed_packet默认 4MB,批量插入数据量过大时要分批,比如每 100 条提交一次。我通常在 service 层做一个partitionList工具方法,把排课结果列表切分成多段,逐段调用批量插入。
<insert id="batchInsertSchedule" parameterType="list"> INSERT INTO schedule_result (course_id, teacher_id, room_id, class_id, weekday, start_section, week_list) VALUES <foreach collection="list" item="item" separator=","> (#{item.courseId}, #{item.teacherId}, #{item.roomId}, #{item.classId}, #{item.weekday}, #{item.startSection}, #{item.weekList}) </foreach> </insert>这里有个隐形坑:week_list字段存储的是周次字符串,比如 "1,2,3,4,5,6,7,8"。有些同学会在 SQL 里用FIND_IN_SET或者模糊查询去判断某一周是否有课,这样做不仅慢,而且无法利用索引。正确做法是在 Java 层把字符串切分后计算,或者在表里额外冗余一个中间表来存「排课结果 × 周次」的多对多关系。毕设阶段建议保留冗余字符串,但在论文里要提一句「生产环境应拆分为 schedule_week 关联表」,这句话成本很低但在答辩时很加印象分。
4.3 前端课表展示:按月视图与周视图切换的设计细节
课表展示是排课系统里最容易做丑也最容易做错的部分。很多毕设前端用 Element UI 的 el-table 直接渲染 5×10 的格子,然后往里面塞课程信息,这样会出现课程文字重叠、单元格高度不一致、跨周课程无法展示的问题。我的做法是用 Vue 封装一个课表组件,数据结构按照「星期几 → 节次 → 课程数组」的嵌套映射来组织,这样做的好处是前端渲染时不必做复杂转换,直接按索引取数据。另外,需要支持视图切换:周视图只展示当前周的排课,月视图展示整个学期的课程分布。在两个视图切换时,要重新向后端请求对应时间段的数据,而不是在前端一次性加载全部数据再切割——后者在数据量大的时候首屏渲染时间会很长。
5. 部署与排错避坑:5 个让人熬夜的常见问题及排查方案
排课系统这类项目,功能写完只算走完一半,另一半是在联调和部署过程中踩坑踩出来的。这里挑五个我见过最多的问题,按现象、原因、解决三步写清楚,方便你以后排错时快速对照。我建议跑通这个项目的标准流程是:MySQL 建库 → 执行完整 SQL 脚本 → 本地启动后端 → 启动前端 → 调用排课接口 → 检查课表渲染,每一步都有明确的验证标志。
5.1 数据库连不上的低级坑:MySQL 8 的认证插件与配置项
现象是后端启动时报Unable to connect to MySQL,但用 Navicat 却能正常连接。原因是 MySQL 8 默认用了caching_sha2_password认证插件,而你在 Spring Boot 的数据库连接驱动用的是旧版本,或者连接字符串没有指定allowPublicKeyRetrieval=true参数。另一个常见原因是你把sslMode=DISABLED写错了位置。解决方法是:升级mysql-connector-java到 8.0 以上版本,并在 JDBC URL 上追加两个参数:
jdbc:mysql://localhost:3306/school_schedule?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/ShanghaiallowPublicKeyRetrieval=true这个参数只在首次连接时需要,但很多教程不会写,不加上就会报错。serverTimezone=Asia/Shanghai也不能省,否则日期时间字段在插入时会偏移 8 小时,排课结果的时间全部错位。
5.2 SQL 脚本执行一半失败:为什么我推荐用命令行导入而不是 GUI 工具
很多同学习惯用 Navicat 或 DataGrip 导入 SQL 脚本,然后报错提示后又从头跑一遍,结果出现大量重复数据或者「表已存在」的错误。正确的做法是:在命令行用mysql -u root -p < init.sql方式导入,脚本里所有建表语句前面已经写了DROP TABLE IF EXISTS,所以每次重跑都是清理后重建,不会残留脏数据。另外,如果你的 SQL 脚本里有存储过程或触发器,注意客户端工具默认分隔符;可能和存储过程内部的;冲突,需要在脚本里用DELIMITER $$切换分隔符。这个细节如果不处理,存储过程会被切成好几段执行,报错很难定位。
5.3 排课算法死循环或超时:数据量上来了吗?
这是一个隐蔽的性能问题。排课接口在本地测试时 50 门课秒回,放到 400 门课程就转圈,最后直接 504 超时。原因通常出在遗传算法的迭代次数和种群数量没有做数据量自适应。400 门课、种群 100、迭代 500 代,意味着要计算 20000 次适应度函数,每次还要遍历所有课程检查冲突,这个时间复杂度大约是 O(种群数 × 迭代数 × 课程数),在普通电脑上可能要跑 5 分钟以上。解决方法是:设定一个超时阈值,当迭代 20 代适应度变化小于 0.5%,直接提前收敛返回结果;或者课程数超过 300 时,把种群缩小到 50,迭代次数降到 150。另外一个常被忽视的问题是内存泄漏:ScheduleContext里用 Map 缓存了大量占用信息,如果你每次排课都新建 context 而没有清理旧引用,老年代内存会慢慢涨,GC 频繁后系统变卡。建议每次执行排课前,显式调用context.clear()或者让 context 在方法内局部使用,避免被无效引用挂住。
5.4 课程排到第 17 周:周次解析的边界条件
有个典型 bug:课程设置的是 1~16 周每周 2 课时,结果排课结果里出现了第 17 周的安排。原因很简单,算法的周次生成逻辑用的是「起始周 + 周频率 × 每周课时」的线性推算,当起始周设置到 15 周且周频率为 2 时,计算出来的结束周是 17,超出了学期总周数。这类 bug 很难用常规测试覆盖到,因为你的模拟数据往往设置得比较规范。解决方法是写一个独立的学期边界校验函数,在排课前先校验所有课程的起始周和结束周是否在有效范围内,超出直接返回错误提示。这个函数的单元测试一定要写周全,覆盖「起始周为 1 结束周为 16」「起始周为 16 结束周为 16」「每周 4 课时但学期只剩 2 周」这三个边界场景。
5.5 教室墙上的课表和系统排出来的课表对不上:前端时区与缓存问题
这个问题通常在演示时发生,非常尴尬。教务老师看到系统里排好的课表没问题,但导出到 Excel 或者打印时,课程时间和实际对不上。排查后发现是前端把排课时间转换成时间戳时,使用了本地时区的时间偏移,而部署服务器是 UTC 时区,导致前端展示的时间比数据库实际存储的时间早 8 小时。解决方法是前后端统一使用时间戳或统一时区字符串,不要在多个地方各自做时间格式化。还有一个跟缓存有关的坑:修改完排课结果后,重新执行排课接口,前端界面还是显示旧数据,这是因为你没有给查询接口加@CacheEvict或者在更新后主动调用了查询接口刷新缓存。我习惯在排课执行成功后,强制刷新一次课表查询缓存,而不是等缓存自动过期。
6. 把毕设做深一个档次:排课系统的进阶玩法与验证方法
如果基础功能都跑通了,我强烈建议你花一个周末把下面这几件事做完。第一件事,是把「一次排课」改成「分批排课」——先排必修课,再排选修课,最后排重修课程。这样更贴近真实教务流程,而且能给答辩增加一个「业务理解深度」的加分项。第二件事,是设计一个排课结果的可视化冲突热力图——用表格或图表展示所有教室在一天内的使用率,哪间教室哪个时段是红的,一眼看出资源瓶颈。这个功能做起来不难,后端返回统计数据前端画个图,但它几乎就是答辩时的「全场最佳演示」。
第三件事,是完善测试脚本。我建议你在项目里加一个src/test/resources目录,里面放三个测试数据集:小规模(10 门课,用来快速验证算法不报错)、中规模(100 门课,用来验证冲突检测逻辑)、大规模(500 门课,用来压性能)。每个数据集配一个@Test方法,断言排课结果的冲突率为 0,教室利用率落在合理区间。这套测试代码写完后,你每次改算法参数都能一键回归,不用像无头苍蝇一样反复手动试。我习惯在最后一次排课结果里打印一行日志,包含「总课程数、成功排课数、冲突数、平均教室利用率、算法运行时间」,这行日志就是答辩时最实在的性能说明。
最后一件事,是处理「排课结果人工微调」功能。算法再强,教务老师一定会在结果基础上手动改一两节课,比如某位老师临时有事要调整。所以你的系统里一定要有「锁定与解锁」机制:锁定某条排课记录后,再次执行排课算法时这段记录不会被覆盖或调整。这个功能实现起来不复杂,在schedule_result表里加一个status字段,算法执行时跳过 status=1(已锁定)的记录即可。但它的存在直接决定了你这个系统是不是真的能被教务用起来——不能微调的排课系统,演示完就会被放进仓库吃灰。
做完整套系统,我最大的感受是:排课系统的难点不在编码,而在你肯不肯把边界想清楚。你自己亲手跑过 500 门课同时排课、处理过时区问题、优化过批量插入后,答辩时的信心是完全不一样的。希望这份笔记,能帮你少熬夜几个晚上,把时间花在真正能加分的事情上。
本文还有配套的精品资源,点击获取