ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Java高校智能排课系统实战:遗传算法与Spring Boot落地

Java高校智能排课系统实战:遗传算法与Spring Boot落地 简介这是一套面向高校教务场景的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(ListScheduleChromosome 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 ResultString submit(RequestBody ScheduleRequest req) { String taskId scheduleService.submitAsync(req.getSemesterId()); return Result.ok(taskId); } // 轮询排课进度和结果 GetMapping(/progress/{taskId}) public ResultScheduleProgress progress(PathVariable String taskId) { return Result.ok(scheduleService.getProgress(taskId)); } }进度用 ConcurrentHashMap 或者 Redis 存key 是 taskIdvalue 是当前代数、最优适应度、是否完成。算法每迭代一代更新一次进度前端就能看到「第 320 代适应度 0.87」这种实时反馈体验比干等好太多。注意线程池要设合理的队列容量和拒绝策略排课任务堆积时直接拒绝新请求比拖垮整个服务强。4.3 结果落库的事务边界算法跑完把最优解写回 schedule_item 表这一步必须在一个事务里完成先删该学期的旧排课结果再批量插入新结果。如果中途失败旧课表不能被破坏否则教务看到的是半截课表比没排还糟。Transactional(rollbackFor Exception.class) public void saveScheduleResult(Long semesterId, ListScheduleItem items) { // 先清理旧结果再批量插入同一事务保证原子性 scheduleItemMapper.delete( new LambdaQueryWrapperScheduleItem() .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); ListScheduleItem conflicts scheduleItemMapper.selectList( new LambdaQueryWrapperScheduleItem() .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 表。上线半年后回头看这张表帮我定位了至少三次「为什么这学期课表特别差」的问题全是某次调参手滑改了权重没记录。排课系统不是跑一次就完事它是个需要持续调优的活系统留好后悔药比什么都重要。希望帮到你。本文还有配套的精品资源点击获取
返回列表