ARTICLE DETAIL

资讯详情

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

SpringBoot实战:高校智能排课系统与贪心算法冲突检测实现

SpringBoot实战:高校智能排课系统与贪心算法冲突检测实现 高校排课这件事永远是教务处的噩梦。每学期开学前排课老师抱着Excel和草稿纸对着几百门课、上千个班级、几十间教室反复试探光是避免教师冲突、教室冲突、班级冲突就能让人怀疑人生。我前前后后帮几所学校做过排课类系统这次用SpringBoot完整落了一套高校智能排课系统走通从数据库设计、冲突检测、贪心排课到前后端联调的整个流程。这篇文章不谈虚的直接把我整理好的方案、核心代码、踩坑记录都放出来源码也做了脱敏处理方便直接参考复现。这套系统的核心价值在于把人工排课这个极其依赖经验的脑力活拆解成约束建模 冲突检测 自动分配三个可编码的步骤。你输入教师、班级、课程、教室的基本数据设定好节次规则和限制条件系统自动生成不冲突的课表。如果你正准备做Java课程设计、SpringBoot毕业设计或者你们单位正好有排课需求这篇文章的思路和代码能帮你少走很多弯路。1. 高校排课系统的核心需求解构1.1 排课到底在解决什么问题很多第一次接触排课系统的同学会把问题想简单了以为就是把课程放到时间表里。真正做过才知道排课的本质是多维度资源约束下的组合优化问题。一张课表里同时涉及四类资源教师、班级学生群体、教室物理空间、时间星期节次。任何一门课被安排在某个时间片里必须同时满足教师有空、学生有空、教室有空、不违反学校教学规则四个条件。这还不是最难的。学校的教学规则往往五花八门有的课程要求连上两节有的课程必须隔天再上体育课不能排在上午第一节某位老师周三下午固定不能排课某些教室只能容纳特定人数、配备特定设备……你如果用暴力枚举的方式去尝试所有排列组合数据量稍微一上来就是天文数字。以一所中等规模高校为例假设有500门课程、50间教室、每周25个可用时间片组合空间就接近500的25次方级别服务器直接跑废。所以设计排课系统的第一步不是写代码是把约束条件理清楚。我在做需求分析时会把约束拆成两类硬约束和软约束。硬约束违反则方案直接不可用比如教师冲突、教室冲突、班级冲突软约束是尽量满足的条件比如课程均匀分布、体育课尽量避开午后、下午尽量少排理论课等。系统优先保证硬约束全部满足软约束作为评分因子参与排序。1.2 核心业务模块与用户角色一套完整的高校智能排课系统至少包含五个核心业务模块基础数据管理、排课规则配置、自动排课引擎、课表查询与展示、冲突检测与提示。基础数据管理负责维护教师、班级、课程、教室四张主表的CRUD规则配置模块让教务处可以灵活设置每周教学天数、每天节次数、单门课周学时、连排规则、禁排时间等自动排课引擎是核心负责把课程分配到具体时间片课表查询按教师、班级、教室三个维度展示冲突检测模块则不只在排课阶段工作手工调课的时候同样要实时校验。用户角色我是按三种设计系统管理员、教务处排课员、普通师生。管理员管系统配置和用户权限排课员用排课参数配置、执行自动排课、手工微调、发布课表师生登录后可以查看自己的个人课表。权限这块用SpringSecurity JWT实现角色权限通过注解控制代码写在拦截器里每个接口都做登录校验避免课表数据被未授权访问。这里有一个容易被忽略的点排课系统的数据字典必须预留扩展位。比如正课实验课体育课选修课这些课程类型各校叫法不同、学时算法不同我在课程表里专门设计了一个course_type字段配合type_config表存储不同类型的规则参数这样学校调整规则的时候不用改代码改数据就行。2. 技术选型与数据库设计2.1 为什么选SpringBoot作为基础框架SpringBoot几乎已经成了Java后端项目的默认起点。我的选择理由很简单一是自动配置机制能省掉大量XML和样板配置二是Spring生态里的SpringSecurity、SpringData、SpringValidation可以无缝集成三是它在中小型系统里的性能表现足够用配合默认的Tomcat连接池几百个并发查询完全扛得住。你不必追求用什么最新版本稳定最重要。我用的是SpringBoot 2.7.x配套JDK 1.8这套组合经过了太多生产项目验证坑少。不过SpringBoot只是一个底座排课系统真正复杂的是业务算法不是框架本身。框架的选型决策本质上是把时间花在哪里的决策——我希望把80%的精力投入在排课算法和业务规则上SpringBoot帮我兜底了环境搭建、依赖管理、内嵌服务器这些杂事。如果你在写简历或者做课程设计答辩这个思考角度一定要有框架是手段业务模型才是你项目的真正亮点。有些同学一上来就想引入各种重量级组件微服务、消息队列、分布式缓存全套往上堆。我劝你冷静排课系统是典型的内部管理系统并发量根本到不了需要那套架构的程度。引入多余的复杂度只会让你的项目更难维护、更难演示、更难通过答辩。技术选型的核心原则永远是匹配真实场景不炫技。2.2 数据库表结构设计思路数据库是整个排课系统的地基表设计如果埋了雷后面写算法的时候必然返工。我最终落地的核心表有七张教师表、班级表、课程表、教室表、学生表可选、排课规则表、课表发布表排课结果表。以最关键的课表发布表为例我设计如下字段字段名类型说明idbigint主键course_idbigint关联课程表teacher_idbigint关联教师表class_idbigint关联班级表classroom_idbigint关联教室表week_dayint星期几1-7sectionint第几节次1-12week_startint起始周week_endint结束周statusint课表状态草稿/已发布这张表每一条记录代表某门课在某个时间片里被安排到了某间教室。冲突检测的所有查询几乎都围绕这张表做维度组合的count操作查同一教师是否已被占用查同一班级是否已被占用查同一教室是否已被占用。所以这里必须加联合索引我建的是(teacher_id, week_day, section)、(class_id, week_day, section)、(classroom_id, week_day, section)三个组合索引查询速度提升非常明显。排课规则表我用的是key-value 配置项模式不把规则硬编码在Java里。比如每周教学天数、每天节次数、同一课程最大连排数、教师每日最大课时数、教室容量匹配策略等全部用配置项存储。这样当学校教务处提出一个新的限制条件时不需要改一行Java代码新增一条规则配置即可生效。2.3 表关系设计与常见设计坑表关系上课程表和教师表是多对多一个老师可以教多门课一门课可以有多个老师合带课程表和班级表是多对多一个班级上多门课一门课有多个班级班级表和教室表没有直接关系而是在排课结果表里通过一次排课操作绑定。我实际做的时候教师-课程-班级的关系没有用传统的关联中间表而是把course_id、teacher_id、class_id直接放在一张开课计划表里每条记录代表某某老师在某学期为某某班级开设某门课。这样一来排课算法的主循环就非常清爽遍历开课计划逐个分配时间片。这里提醒一个常见的坑不要把开课计划的唯一性约束设成三个id组合唯一。真实场景中同一门课程可能由两位老师在不同时间分别给同一个班上比如实验课分A、B两组如果开课计划表直接设置三方联合唯一索引数据都插不进去。我最后只在业务层做校验数据库只对核心字段建立普通索引把灵活度留在代码里。另外教室表的容量字段和类型字段必须设置合理。排课时要保证班级人数 教室容量还要匹配教室类型普通教室、多媒体教室、机房、实验室。很多初版设计漏了教室类型导致明明有实验室却把实验课排进了普通教室排完的结果根本不能用。这两个字段在排课引擎里是硬约束级别的缺了数据、缺了判断逻辑系统做出来就是玩具。3. 排课引擎算法设计与实现3.1 从约束建模到贪心分配的主流程排课引擎的整体流程我用一句话概括把复杂的课表问题转化成依次为每个开课计划挑选可行时间片的序列决策问题。具体分为四个阶段。首先做数据准备。引擎启动时一次性加载全部开课计划、全部教室列表、全部可用时间片周一到周五、每天第1到第12节以及当前已发布的旧课表数据用于增量排课时避开已占用的资源。第二步是排序。不是所有课程都一视同仁优先分配我实现的排序策略是周学时多的课程优先、有特殊设备要求的课程优先、合班人数多的课程优先。排序的目的是让难排的课先占坑这对贪心算法的最终效果影响极大同样的数据换个排序顺序结果可能天差地别。第三步是冲突检测。对每个开课计划依次尝试每一个时间片用预加载的资源占用表判断是否满足全部硬约束。这一步是引擎的性能瓶颈我的做法是不依赖数据库查询而是在内存里维护三张HashMap结构的占用表——teacherMap、classMap、classroomMapkey是(资源id, 星期几, 节次)value是该时间片已安排的课程id。这样单次冲突检测就是三次HashMap查找复杂度O(1)几百门课几千个时间片跑起来毫秒级完成。第四步是分配与兜底。如果某个开课计划在遍历完所有时间片后仍找不到可用位置则进入兜底逻辑尝试放宽软约束比如允许安排到周六或者把该课程标记为待人工处理。兜底不是可选项没有兜底的排课系统一旦遇到数据极端情况就会直接崩溃或者产出空课表。3.2 冲突检测核心代码实现冲突检测是整个引擎里最核心也最容易写错的部分。我用一段简化但完整可运行的Java代码来展示判断逻辑public class ConflictChecker { // 资源占用表key如 TEACHER_1001_3_2 表示教师1001周二第2节已被占用 private final SetString teacherOccupied; private final SetString classOccupied; private final SetString classroomOccupied; public ConflictChecker() { teacherOccupied new HashSet(); classOccupied new HashSet(); classroomOccupied new HashSet(); } public boolean check(ScheduleRequest req, int weekDay, int section) { String teacherKey TEACHER_ req.getTeacherId() _ weekDay _ section; String classKey CLASS_ req.getClassId() _ weekDay _ section; String roomKey ROOM_ req.getClassroomId() _ weekDay _ section; if (teacherOccupied.contains(teacherKey)) { return false; // 教师时间冲突 } if (classOccupied.contains(classKey)) { return false; // 班级时间冲突 } if (classroomOccupied.contains(roomKey)) { return false; // 教室时间冲突 } // 额外硬约束教师每日课时上限 if (countTeacherSectionsPerDay(req.getTeacherId(), weekDay) req.getMaxDailySections()) { return false; } return true; } public void occupy(ScheduleRequest req, int weekDay, int section) { String teacherKey TEACHER_ req.getTeacherId() _ weekDay _ section; String classKey CLASS_ req.getClassId() _ weekDay _ section; String roomKey ROOM_ req.getClassroomId() _ weekDay _ section; teacherOccupied.add(teacherKey); classOccupied.add(classKey); classroomOccupied.add(roomKey); } }这段代码精简掉了读取教室容量、设备类型匹配的逻辑但主体框架就是这样的。有一个细节特别重要occupy方法必须在确认某个时间片被正式选定后才调用。我最初写的版本在尝试时间片的循环里就提前占用了资源结果一个课程尝试失败后资源没有释放导致后续所有课程大量冲突查了半天才发现是占用时机的问题。建议你在实现的时候把尝试检测和正式占用严格分离必要时用一个临时的rollbackStack记录占用操作一旦后续发现某个开课计划整体失败可以回滚之前几步的占用。3.3 贪心排序策略与软约束评分排序策略直接决定排课质量的优劣。我实现的排序器核心是一个计分函数分值 周学时权重 特殊需求权重 班级规模权重。举例来说同一门课程周学时是4另一门是2前者的排序得分更高先被分配。为什么这样排序有效你可以想象抢车位——大车难停所以先停小车后停可以见缝插针。周学时多的课需要占用的时间片多先安排可以减少后期无处安放的概率。软约束评分是我在粗排完成后加的一个优化步骤。具体包括同一个班级的课程尽量均匀分布在每天避免周三排满课周五一节没有同一门课程两次上课之间尽量间隔一天以上体育课尽量不排在上午前两节等。每个软约束我都定义了一个扣分函数总评分 初始分 - 各软约束扣分之和。自动排课完成后系统会对结果进行评分排课员可以查看评分雷达图分数低的时段能一键炸掉重排。这里必须说明一个事实贪心算法不保证全局最优。同样一批数据换个初始状态排序排课结果差异不小。我在项目文档里也写了这一点如果后续要引入遗传算法或模拟退火来提升质量贪心的结果可以作为种群初始化种子这算是系统预留的升级路径。但从实际使用体验来说贪心好的排序策略在绝大多数场景下已经能得到能用的课表先跑通再优化是正道。4. SpringBoot项目落地与实操4.1 项目目录结构与核心依赖我用的是标准的分层结构controller、service、mapper、entity、config、common、engine七层。排课引擎单独放在engine包里不和业务层耦合这样做的原因是引擎需要被多个业务模块调用自动排课、手工调课后的冲突校验、课表发布前审查独立成包之后依赖关系干净测试也好写。核心依赖我只用了这几个dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-security/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3.1/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId /dependencyMyBatis-Plus是我个人比较喜欢的选择单表CRUD不用写XMLBaseMapper直接提供现成的insert、selectList、updateById。关键查询我仍然手写SQL放到Mapper注解里这样复杂查询的SQL可控。有人会纠结用Spring Data JPA还是MyBatis我两个都用过结论很简单如果你习惯强类型SQL选MyBatis-Plus如果你希望省掉SQL选JPA。但排课引擎里有几个统计类查询统计某教师一周总课时、统计某教室一周利用率这类聚合SQL在JPA里写起来比较拧巴MyBatis更顺手。4.2 关键配置与启动说明application.yml里比较关键的是数据源、连接池和服务端口配置。连接池我用的是HikariCPSpringBoot默认集成的那套参数我调整过几项spring: datasource: url: jdbc:mysql://localhost:3306/course_scheduler?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: your_password hikari: maximum-pool-size: 10 minimum-idle: 2 connection-timeout: 30000 server: port: 8080serverTimezoneAsia/Shanghai这个参数必须加不然MySQL 8连接会报时区错误这是新手最常见的问题。另外注意characterEncodingutf8如果漏掉课表里查询出来的中文教师名大概率乱码。这两个坑我都是实际踩过的写出来省得你再踩一遍。前后端分离的话不要忘了在config包里配置CORS跨域。我的写法是在拦截器里加CorsFilter允许所有来源的GET、POST、PUT、DELETE请求开发阶段方便调试。部署到生产环境后把allowedOrigins改成实际域名别一直用通配符。另外所有接口统一加了/api前缀这样前端代理和后端网关都好做配置。4.3 从源码到运行的完整流程我拿到的源码工程可以直接用IDEA打开JDK环境1.8及以上Maven拉依赖。整个运行过程分五步走。第一步用Navicat或命令行执行项目根目录下的sql/course_scheduler.sql脚本建库建表并插入模拟数据。模拟数据我放了5个系、20个班级、60位教师、100门课程、30间教室够展示效果。第二步修改application.yml里的数据库用户名和密码。第三步在项目根目录执行mvn spring-boot:run或者在IDEA里直接运行CourseSchedulerApplication的main方法。第四步后端启动后访问/api/swagger-ui.html查看接口文档我集成了springfox的Knife4j增强版各接口参数一目了然。第五步前端工程是独立的Vue项目在frontend目录下执行npm install再npm run serve浏览器打开前端登录页面用管理员账号即可进入排课工作台。一个经验先跑通后端接口再联调前端。跑后端的时候用Postman或Apifox把核心接口挨个测试一遍确认数据正常再启动前端。这样如果页面数据有问题你能迅速定位是前端渲染的问题还是后端接口的问题而不是两边一起猜。我见过很多同学前后端同时启动出了Bug一头雾水Debug大半天就是因为没有做这种边界切割。5. 常见问题与排查技巧5.1 排课无解不是你算法错了是约束太紧排课引擎跑完发现有一部分课程始终分配不到时间片。第一反应不应该是去查代码而是回头检查排课规则配置参数。我调试的时候遇到过一次全天只剩一个可用时间片但每周教学天数设成5天的荒谬配置手工排课都排不出来算法当然更排不出来。解决办法是给规则配置增加一个校验器在保存排课规则时校验可用时间片总量是否大于所有开课计划所需时间片总量如果小于直接提示配置不合理而不是等引擎跑完才发现无解。还有一种情况是教室约束过紧。比如实验楼只有2间机房但实验课每周有30个课时需求再怎么排都有课程溢出来。这种问题属于资源配置不足工程上唯一的解法是把冲突的课程标记出来让教务处决定是扩班、加教室还是调整教学计划。5.2 查询性能骤降联合索引与缓存缺一不可课表发布后全校师生同时访问个人课表查询压力集中在schedule表的维度查询上。我上线初期遇到的问题是教师课表接口响应时间从50ms涨到3秒多排查后锁定为两个原因——第一个原因是前面提到的联合索引没有加MySQL对每次查询都走全表扫描第二个原因是每个请求都直接查数据库没有做任何缓存。解决方式是两层优化表上加联合索引查询接口加SpringCache的Cacheable注解缓存key设计成schedule:teacher: teacherId缓存过期时间12小时。教师课表一旦排好半天内基本不会变化缓存命中后查询耗时降到个位数毫秒。班级维度、教室维度的查询也是同样思路。这一块的优化思路同样适用于课表发布前的预演查询排课员点预览全校课表的时候接口要能撑住几百门课的数据量。5.3 手工调课的事务边界问题自动排课完后总有一些特殊需求要人工干预比如教务处长说王老师下周二的课全部调到周五。手工调课接口要解决的不仅是改一条记录而是要完成释放旧时间片 校验新时间片 写入新课表 更新缓存四个动作。这四个动作必须在同一个事务里执行中间任何一个环节失败都要整体回滚否则就会出现旧时间片已经释放、新时间片写入失败的数据空洞。我在实现时给调课接口加了Transactional(rollbackFor Exception.class)注解并封装了一个ScheduleTransactionService。这里有一个重要细节Transactional只对RuntimeException和指定异常生效如果代码里catch吞掉了异常事务不会回滚。我在测试中故意抛了一个自定义业务异常发现数据居然提交了查了半天才发现是catch块里吞了异常没有继续抛出。建议你在写调课逻辑时要么不catch要么catch后显式throw new RuntimeException(e)。提示如果你研究源码时发现我的ScheduleTransactionService里有一个ThreadLocal变量记录当前操作的事务上下文这个不是炫技。因为排课引擎里有多层方法嵌套调用直接用参数传递事务状态会非常啰嗦ThreadLocal能保证同一个线程内的所有子调用共享同一个事务记录器代价是要记得在方法结束后的finally块里remove()防止线程池复用导致上下文串扰。5.4 一个容易忽略的中文乱码问题接口返回的数据正常但存进MySQL的中文全部变成问号。这个问题的根源往往不在Java代码而是MySQL表级别和连接级别的字符集不一致。排查步骤很简单先执行show variables like character%看看character_set_server是不是utf8mb4再查看表的建表语句ENGINEInnoDB DEFAULT CHARSETutf8mb4最后检查application.yml里的连接参数确保有characterEncodingutf8。三重都设置对了中文基本不会再乱。如果是新导入的项目最快的办法是删库重新执行附带的SQL脚本因为我给源码里的建表语句已经加好了utf8mb4参数。6. 源码结构与二次开发建议6.1 源码工程整体导航如果你拿到了这份源码打开项目之后我建议按下面的顺序阅读避免一头扎进细节里出不来sql/course_scheduler.sql先看表结构理解了数据模型再去看代码事半功倍engine/包排课引擎这是整个项目的灵魂重点看GreedyScheduler和ConflictCheckerservice/包业务层的调用逻辑尤其看ScheduleService里如何编排引擎和事务controller/包RESTful接口定义理解对外暴露的能力frontend/目录Vue页面的课表展示组件。课表组件我用的是日历式网格布局周一至周日七列、节次十二行高亮当前时间这套组件可以复用到很多管理系统的视图中。二次开发的建议就一句话不要动引擎核心先动业务接口。引擎的约束建模和分配逻辑经过了多轮测试随意改动容易引入隐蔽问题。绝大多数学校落地时的定制需求比如某学院单独排课某类课程优先安排到周二下午都可以通过调整规则配置、增加排序权重系数来实现不需要改引擎代码。6.2 从课程设计到生产落地的差距很多人做完这个项目就会有一种感觉代码能跑、课表能排出来但真要给学校用好像还差了点什么。距离生产落地至少还缺三块内容一是账号体系和角色权限的前端细化比如班主任需要看到整个班级的课表、教师只能看到自己的课表这些要联动前端路由和后端接口的细粒度鉴权二是课表变更的审批流排课员调整课表后需要教务处审批通过才对外发布保证师生看到的一定是正式版本三是数据统计报表教室利用率、教师课时量、各课程冲突率等指标管理层需要这些数据做决策。我在源码里实现了前两块的基础版本报表模块留了接口占位。你可以把它当成一个良好的课程设计作业也可以作为生产系统的第一版原型后续按实际需求迭代。记住一个原则系统和真实业务的距离永远不在代码复杂度上而在对业务细节的理解深度上。6.3 后续可以扩展的方向如果这个项目你要继续往上走我抛几个真实可行的方向供参考。把排课引擎的贪心算法升级成遗传算法或者模拟退火算法提升全局最优性在排课结果生成后接入消息通知服务把课表变更实时推送到企业微信或钉钉用定时任务实现学期初自动排课、学期中增量调课的闭环如果学校有多校区教室资源的物理位置约束也要纳入模型跨校区上课时间需要预留通勤时间。这些都是在我这个版本基础上自然衍生的方向每一条都够单独写成一篇实战文章但根基还是今天讲的这套SpringBoot骨架和排课引擎设计。我个人在实际操作中的体会是排课系统这类业务型项目最大的坑从来不在框架而在需求分析和约束建模。你花三个小时写完冲突检测代码可能需要花三天去搞清楚为什么教务处的规则看起来简单组合起来就这么复杂。先把业务规则一条条列出来再动手写代码哪怕最后用最朴素的贪心算法产出的课表也是能用的反过来算法写得再花哨约束建模漏了一条排出来的课表就是废纸。最后再分享一个我调试排课引擎时的小技巧在纸上画一个星期×节次的网格每分配一门课就用铅笔在对应格子填上课程编号肉眼盯几轮冲突比你写几十个日志断言更直观。等网格逻辑跑顺了再回到代码里抽象成数据结构很多设计问题会一下子豁然开朗。
返回列表