简介:这是一套面向高校教务场景的Java智能排课系统源码,适合计算机专业学生做课程设计、毕业设计,也适合想研究排课算法的开发者参考。系统围绕系统管理与维护、排课算法设计与实现、课表查询与打印、课表调整与调度四大模块展开,涵盖教师与课程信息管理、教室资源分配、权限控制、冲突避免与公平性优化等核心问题,并采用Spring、MyBatis/Hibernate等技术栈实现。资源包共310个文件,以jsp页面、java源码、class编译文件、gif与jpg图片、jar依赖包为主,另含htm、js、css、xml、sql及doc文档,压缩包约9.59MB,结构完整便于二次开发。目前已有458人学习下载。通过阅读源码,读者可掌握贪心、回溯、遗传等排课算法的落地思路,理解数据库设计与角色权限分配方式,并借鉴课表查询、打印与动态调度的实现细节,是一份兼具算法学习与工程实践价值的参考资料。
1. 从一张排到凌晨三点的课表说起:Java 高校智能排课系统到底在解决什么
每学期开学前两周,教务处的灯基本是通宵亮的。我参与过一个二本院校的排课改造,教务老师拿着一份 Excel,横轴是周一到周五的节次,纵轴是班级,一个格子一个格子地拖,拖完班级拖教师,拖完教师拖教室,改一处红一片。最崩溃的是体育课和实验课,场地、器材、教师时间三重约束叠在一起,人工排出来的课表总有几个班一天连上四节,或者某位老师被排到两个校区之间只有十分钟转场。这就是「Java 高校智能排课系统」要啃的硬骨头:它不是把 Excel 搬到网页上,而是把排课当成一个带硬约束和软约束的组合优化问题,用 Java 后端去求解、去校验、去可视化。
这篇文章面向三类人:正在做课程设计或毕业设计的学生、想给学校做一套可用排课工具的后端工程师、以及被排课折磨过想自己动手的教务人员。我会按「问题怎么建模 → 数据怎么落库 → 算法怎么选 → 接口怎么设计 → 坑在哪」的顺序讲,代码用 Spring Boot + MyBatis-Plus 的技术栈,算法部分给可运行的遗传算法骨架。看完你应该能自己搭出一个能跑通小规模真实数据的版本,而不是停留在「有个想法」。
2. 排课问题的建模:把教务老师的口头规则翻译成约束
2.1 硬约束、软约束与目标函数
排课在计算机里是一个典型的约束满足问题(CSP),再叠加优化目标就变成约束优化。落地时第一件事是把教务老师嘴里的规则分成两类。硬约束是绝对不能违反的,违反了解直接作废:同一教师同一时间不能上两门课、同一班级同一时间不能上两门课、同一教室同一时间不能安排两场、教室容量必须大于等于班级人数、教师指定的不可用时间段必须避开。软约束是尽量满足、违反了扣分但不作废:上午优先排主课、同一门课两次课间隔至少一天、教师一天课时不超过四节、尽量不跨校区连排。
把软约束写成加权惩罚项,整个问题就有了目标函数:最小化总惩罚。硬约束用「可行性检查」在生成解的时候直接过滤,软约束用分数在进化过程中做选择压力。这里有个血泪经验:不要一上来就把所有规则都做成硬约束,否则可行解空间会被压到几乎为空,算法跑一晚上都出不来一个解。我一般先把最核心的四五条设为硬约束,其余全部转软约束,跑出结果后再逐步收紧。
2.2 数据模型:五张核心表怎么设计
排课系统的数据模型决定了后面算法好不好写。核心是五张表:班级(class_group)、教师(teacher)、课程(course)、教室(classroom)、教学任务(teaching_task)。教学任务是最关键的一张,它把「哪个班、哪门课、哪位老师、多少学时、每周几次」绑在一起,是排课的最小调度单元。
-- 教学任务表:排课的最小调度单元 CREATE TABLE teaching_task ( id BIGINT PRIMARY KEY AUTO_INCREMENT, course_id BIGINT NOT NULL COMMENT '课程ID', class_id BIGINT NOT NULL COMMENT '班级ID', teacher_id BIGINT NOT NULL COMMENT '教师ID', weekly_times INT NOT NULL DEFAULT 2 COMMENT '每周上课次数', total_hours INT NOT NULL COMMENT '总学时', required_room_type VARCHAR(32) COMMENT '需要的教室类型,如实验室、多媒体', student_count INT NOT NULL COMMENT '上课人数,用于匹配教室容量', UNIQUE KEY uk_course_class (course_id, class_id) ) COMMENT '教学任务'; -- 排课结果表:一条记录代表某个任务的一次课落在哪个时间哪个教室 CREATE TABLE schedule_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, task_id BIGINT NOT NULL, time_slot_id BIGINT NOT NULL COMMENT '时间片ID,如周一第1-2节', classroom_id BIGINT NOT NULL, week_pattern VARCHAR(16) DEFAULT 'ALL' COMMENT '单双周:ALL/ODD/EVEN', UNIQUE KEY uk_room_slot (classroom_id, time_slot_id, week_pattern), UNIQUE KEY uk_task_slot (task_id, time_slot_id) ) COMMENT '排课结果';时间片不要用「周一 8:00-9:40」这种字符串,要抽象成 time_slot 表,一行代表一个不可再分的节次单元,比如「周一第 1-2 节」。这样算法里所有时间冲突判断都退化成整数比较,速度差一个数量级。教室类型用 required_room_type 做软匹配,实验室、机房、多媒体各占一类,匹配不上就扣分而不是直接判死,给算法留腾挪空间。
2.3 时间片粒度:为什么我建议按两节连排切
高校排课有个现实:很多课是两节连上(一次 90 分钟),如果时间片按单节切,算法很容易排出「周一第 1 节、周二第 2 节」这种碎片课表,教务看了直接打回。常见做法是把时间片直接定义成两节连排的单元,一天五个单元(上午两个、下午两个、晚上一个),一周 25 个单元。这样搜索空间从 40 个单节降到 25 个单元,规模小了近一半,而且天然满足连排需求。代价是灵活性下降,遇到单节课(比如某些选修)需要额外处理,我一般给这类课单独开一个单节时间片池,不混进主流程。
3. 算法选型:遗传算法、模拟退火还是约束求解器
3.1 三种主流方案的适用边界
排课算法没有银弹,选型看规模。小规模(几十个班、几百个任务)用回溯 + 启发式就能秒出;中等规模(几百个班、上千任务)遗传算法和模拟退火是主流;大规模或者约束特别复杂的,上 OR-Tools 这类约束求解器更稳。我做过对比:一个 120 个班、约 900 条教学任务的学校,遗传算法在普通服务器上跑 3 到 5 分钟能收敛到可用解,模拟退火快一些但解的质量波动大,OR-Tools 的 CP-SAT 求解器质量最好但学习曲线陡、调参依赖建模功底。
对绝大多数高校课程设计和中小型真实项目,我推荐遗传算法起步。原因很实际:它的编码方式直观,约束好嵌,出问题好调试,网上资料多,遇到 bug 能搜到人问。模拟退火适合你已经有一版可行解、想快速局部优化的场景。约束求解器适合你团队里有人懂建模、且对解质量要求极高的场景。
3.2 遗传算法的编码:一条染色体长什么样
编码是遗传算法的命门。排课问题我用的编码是「任务-时间片-教室」三元组的数组,数组下标对应教学任务,值是一个 (timeSlotId, classroomId) 的组合。这样一条染色体就是一个完整课表,交叉和变异都在这个数组上操作。
/** * 排课染色体:genes[i] 表示第 i 个教学任务的排课方案 * 下标与 teachingTaskList 的顺序一一对应 */ public class ScheduleChromosome { // 每个基因编码:高32位存 timeSlotId,低32位存 classroomId private long[] genes; private double fitness; // 适应度,越大越好 private int hardViolations; // 硬约束违反数,必须为 0 才可用 public ScheduleChromosome(int taskCount) { this.genes = new long[taskCount]; } public void setGene(int index, long timeSlotId, long classroomId) { this.genes[index] = (timeSlotId << 32) | (classroomId & 0xFFFFFFFFL); } public long getTimeSlot(int index) { return genes[index] >>> 32; } public long getClassroom(int index) { return genes[index] & 0xFFFFFFFFL; } }用 long 把两个 ID 打包进一个基因,是为了让交叉变异以「整条排课方案」为单位进行,避免时间和教室被拆散导致大量无效解。适应度函数里先算硬约束违反数,违反数大于 0 的直接给极低适应度,等于 0 的再算软约束得分。这里有个参数细节:硬约束惩罚系数要设得足够大,比如软约束满分的 10 倍以上,否则算法会为了优化软约束而容忍硬冲突,排出来的课表看着漂亮但根本不能用。
3.3 交叉、变异与选择:三个必调参数
交叉用两点交叉,随机选两个切点交换片段;变异用单点变异,随机挑一个任务重新分配时间片和教室。选择用锦标赛选择,规模一般取 3 到 5。三个必调参数是种群规模、交叉率、变异率。我的经验值:种群 100 到 200,交叉率 0.8 到 0.9,变异率 0.05 到 0.15。变异率低于 0.05 容易早熟收敛,全班课表长得一模一样;高于 0.2 就退化成随机搜索,收敛慢。
// 锦标赛选择:从种群随机抽 k 个,返回适应度最高的 private ScheduleChromosome tournamentSelect(List<ScheduleChromosome> pop, int k) { ScheduleChromosome best = null; Random rand = new Random(); for (int i = 0; i < k; i++) { ScheduleChromosome candidate = pop.get(rand.nextInt(pop.size())); if (best == null || candidate.getFitness() > best.getFitness()) { best = candidate; } } return best; } // 单点变异:以 mutationRate 概率重排某个任务的时间片和教室 private void mutate(ScheduleChromosome c, double mutationRate, Random rand) { for (int i = 0; i < c.getGeneCount(); i++) { if (rand.nextDouble() < mutationRate) { long newSlot = validTimeSlots.get(rand.nextInt(validTimeSlots.size())); long newRoom = validClassrooms.get(rand.nextInt(validClassrooms.size())); c.setGene(i, newSlot, newRoom); } } }变异时不要完全随机挑教室,最好按任务的 required_room_type 和 student_count 过滤出候选教室再随机,这样能大幅减少无效变异,收敛速度肉眼可见地快。迭代终止条件用「连续 N 代最优适应度不提升」比固定代数更合理,N 取 50 到 100。跑完记得把最优染色体解码回 schedule_item 表,解码时再做一次硬约束校验,防止算法内部状态和数据库不一致。
4. Spring Boot + MyBatis-Plus 落地:接口、事务与并发
4.1 排课任务表与 MyBatis-Plus 实体映射
热词里 mybatisplus 根据 java 实体类生成建表 sql 是很多人关心的点,排课系统里实体和表结构必须严格对齐,否则算法读出来的数据就是错的。教学任务实体用 MyBatis-Plus 注解映射,字段名和表列名不一致时用 @TableField 显式指定。
@Data @TableName("teaching_task") public class TeachingTask { @TableId(type = IdType.AUTO) private Long id; private Long courseId; private Long classId; private Long teacherId; private Integer weeklyTimes; private Integer totalHours; private String requiredRoomType; private Integer studentCount; }实体类字段用驼峰,数据库列用下划线,MyBatis-Plus 默认开启驼峰转换,不用额外配置。要注意的是 studentCount 这类参与算法计算的字段,类型必须和数据库一致,我见过用 Integer 映射数据库 BIGINT 导致大班级人数溢出的翻车案例,人数一多就变负数,教室匹配全乱。
4.2 排课接口设计:异步任务 + 轮询结果
排课是耗时操作,绝不能做成同步 HTTP 接口,否则前端转圈转到超时。正确做法是提交排课请求后立即返回一个 taskId,后台异步跑算法,前端轮询进度。用 Spring 的 @Async 或者直接丢线程池都行。
@RestController @RequestMapping("/api/schedule") public class ScheduleController { @Autowired private ScheduleService scheduleService; // 提交排课任务,立即返回 taskId @PostMapping("/submit") public Result<String> submit(@RequestBody ScheduleRequest req) { String taskId = scheduleService.submitAsync(req.getSemesterId()); return Result.ok(taskId); } // 轮询排课进度和结果 @GetMapping("/progress/{taskId}") public Result<ScheduleProgress> progress(@PathVariable String taskId) { return Result.ok(scheduleService.getProgress(taskId)); } }进度用 ConcurrentHashMap 或者 Redis 存,key 是 taskId,value 是当前代数、最优适应度、是否完成。算法每迭代一代更新一次进度,前端就能看到「第 320 代,适应度 0.87」这种实时反馈,体验比干等好太多。注意线程池要设合理的队列容量和拒绝策略,排课任务堆积时直接拒绝新请求比拖垮整个服务强。
4.3 结果落库的事务边界
算法跑完把最优解写回 schedule_item 表,这一步必须在一个事务里完成,先删该学期的旧排课结果再批量插入新结果。如果中途失败,旧课表不能被破坏,否则教务看到的是半截课表,比没排还糟。
@Transactional(rollbackFor = Exception.class) public void saveScheduleResult(Long semesterId, List<ScheduleItem> items) { // 先清理旧结果,再批量插入,同一事务保证原子性 scheduleItemMapper.delete( new LambdaQueryWrapper<ScheduleItem>() .eq(ScheduleItem::getSemesterId, semesterId) ); // 分批插入,避免单条 SQL 过大 for (int i = 0; i < items.size(); i += 500) { int end = Math.min(i + 500, items.size()); scheduleItemMapper.insertBatch(items.subList(i, end)); } }批量插入每批 500 条是个经验值,太大容易触发数据库包大小限制,太小事务提交次数多影响性能。删除旧结果和插入新结果之间不要做任何耗时操作,事务持有时间越短越好,否则排课高峰期容易锁表。
5. 避坑与排查:排课系统上线后最容易翻车的五件事
5.1 现象:算法跑了一小时没出结果,日志停在「初始化种群」
原因通常是硬约束太严,初始种群根本生成不出可行解,算法卡在 while 循环里反复重试。解决方法是给初始化加一个最大重试次数,超过就放宽某条硬约束(比如临时允许教室容量超 10%),先保证有解,再在后续迭代里收紧。我一般会在初始化阶段打印「第 N 次尝试生成可行个体」,卡住时一眼能看出是约束问题还是代码死循环。
5.2 现象:排出来的课表教师一天连上六节,教务投诉
原因多半是软约束里「教师日课时上限」的权重设得太低,算法为了满足其他约束牺牲了这条。解决方法是把这条软约束的惩罚系数调高,或者直接升级成硬约束。但升级前要确认:如果某位老师课时本来就多,硬约束会导致无解,这种情况要允许「超限但扣重分」,而不是一刀切禁止。
5.3 现象:同一门课两次课排在了同一天
原因是没有把「同一任务两次课间隔至少一天」写进软约束,或者写了但权重不够。解决方法是给同一 task 的多个 schedule_item 加一个间隔检查,间隔小于一天就扣分。注意间隔计算要基于时间片的天数差,不是节次差,周一第 5 节和周二第 1 节虽然节次差小,但天数差是 1,算合格。
5.4 现象:教室冲突校验通过,但实际还是撞了
原因通常是单双周没考虑进去。如果两门课都是单周上课,它们可以共用同一教室同一时间片,但你的唯一索引 uk_room_slot 如果没带 week_pattern,数据库会直接拒绝插入。解决方法是唯一索引必须包含 week_pattern,冲突校验逻辑也要按周次模式分别判断。这个坑我在两个项目里都踩过,血泪教训。
5.5 现象:算法每次跑结果都不一样,教务要求可复现
原因是随机种子没固定。遗传算法本身是随机的,但你可以把 Random 的种子设成固定值(比如学期 ID 的哈希),这样同样的输入每次跑出同样的结果。调试阶段固定种子,生产环境可以放开,但要在排课记录里存下种子值,方便复现问题。另外种群初始化、交叉、变异都要用同一个 Random 实例,否则种子固定了也没用。
6. 让排课结果可解释:适应度分解与人工微调接口
算法给出的课表,教务不一定买账,因为「为什么这么排」是个黑匣子。我的做法是在结果里附一份适应度分解报告:硬约束违反 0 条,软约束扣分项逐条列出,比如「上午主课未满足扣 12 分、教师日课时超限扣 8 分、跨校区连排扣 5 分」。教务看到扣分项,就知道算法在哪些地方做了妥协,也方便他们判断要不要调整约束权重重跑。
更进一步,我留了一个人工微调接口:允许教务在算法结果上手动拖动某次课,系统实时校验冲突并给出提示。微调后的课表可以「锁定」,下次重跑算法时锁定的课不动,只优化其余部分。这个功能在实际使用中比算法本身还受欢迎,因为教务要的往往不是「最优解」,而是「我能掌控的解」。
// 人工微调:校验单次调整是否引入硬冲突 public ConflictCheckResult checkManualAdjust(Long itemId, Long newSlotId, Long newRoomId) { ScheduleItem item = scheduleItemMapper.selectById(itemId); List<ScheduleItem> conflicts = scheduleItemMapper.selectList( new LambdaQueryWrapper<ScheduleItem>() .eq(ScheduleItem::getTimeSlotId, newSlotId) .eq(ScheduleItem::getClassroomId, newRoomId) .ne(ScheduleItem::getId, itemId) ); if (!conflicts.isEmpty()) { return ConflictCheckResult.fail("该时间该教室已被占用"); } // 再校验教师和班级冲突,逻辑同上,此处省略 return ConflictCheckResult.ok(); }微调接口的校验必须和算法内部的硬约束检查用同一套逻辑,否则会出现「算法说不行、人工说行」的矛盾。我一般把约束检查抽成一个独立的 ConstraintChecker 类,算法和接口都调它,保证口径一致。
最后说个习惯:每次排课任务跑完,我都会把输入参数(种群规模、交叉率、变异率、约束权重、随机种子)和输出结果一起存进一张 schedule_run_log 表。上线半年后回头看,这张表帮我定位了至少三次「为什么这学期课表特别差」的问题,全是某次调参手滑改了权重没记录。排课系统不是跑一次就完事,它是个需要持续调优的活系统,留好后悔药比什么都重要。希望帮到你。
本文还有配套的精品资源,点击获取