高校排课这件事,永远是教务处的噩梦。每学期开学前,排课老师抱着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 数据库表结构设计思路
数据库是整个排课系统的地基,表设计如果埋了雷,后面写算法的时候必然返工。我最终落地的核心表有七张:教师表、班级表、课程表、教室表、学生表(可选)、排课规则表、课表发布表(排课结果表)。
以最关键的课表发布表为例,我设计如下字段:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| course_id | bigint | 关联课程表 |
| teacher_id | bigint | 关联教师表 |
| class_id | bigint | 关联班级表 |
| classroom_id | bigint | 关联教室表 |
| week_day | int | 星期几,1-7 |
| section | int | 第几节次,1-12 |
| week_start | int | 起始周 |
| week_end | int | 结束周 |
| status | int | 课表状态:草稿/已发布 |
这张表每一条记录代表"某门课在某个时间片里被安排到了某间教室"。冲突检测的所有查询,几乎都围绕这张表做维度组合的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、classroomMap,key是(资源id, 星期几, 节次),value是该时间片已安排的课程id。这样单次冲突检测就是三次HashMap查找,复杂度O(1),几百门课几千个时间片跑起来毫秒级完成。
第四步是分配与兜底。如果某个开课计划在遍历完所有时间片后仍找不到可用位置,则进入兜底逻辑:尝试放宽软约束(比如允许安排到周六),或者把该课程标记为"待人工处理"。兜底不是可选项,没有兜底的排课系统一旦遇到数据极端情况就会直接崩溃或者产出空课表。
3.2 冲突检测核心代码实现
冲突检测是整个引擎里最核心也最容易写错的部分。我用一段简化但完整可运行的Java代码来展示判断逻辑:
public class ConflictChecker { // 资源占用表,key如 "TEACHER_1001_3_2" 表示教师1001周二第2节已被占用 private final Set<String> teacherOccupied; private final Set<String> classOccupied; private final Set<String> 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> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3.1</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> </dependency>MyBatis-Plus是我个人比较喜欢的选择,单表CRUD不用写XML,BaseMapper直接提供现成的insert、selectList、updateById。关键查询我仍然手写SQL放到Mapper注解里,这样复杂查询的SQL可控。有人会纠结用Spring Data JPA还是MyBatis,我两个都用过,结论很简单:如果你习惯强类型SQL,选MyBatis-Plus;如果你希望省掉SQL,选JPA。但排课引擎里有几个统计类查询(统计某教师一周总课时、统计某教室一周利用率),这类聚合SQL在JPA里写起来比较拧巴,MyBatis更顺手。
4.2 关键配置与启动说明
application.yml里比较关键的是数据源、连接池和服务端口配置。连接池我用的是HikariCP,SpringBoot默认集成的那套,参数我调整过几项:
spring: datasource: url: jdbc:mysql://localhost:3306/course_scheduler?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: your_password hikari: maximum-pool-size: 10 minimum-idle: 2 connection-timeout: 30000 server: port: 8080serverTimezone=Asia/Shanghai这个参数必须加,不然MySQL 8连接会报时区错误,这是新手最常见的问题。另外注意characterEncoding=utf8,如果漏掉,课表里查询出来的中文教师名大概率乱码。这两个坑我都是实际踩过的,写出来省得你再踩一遍。
前后端分离的话,不要忘了在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;再查看表的建表语句,ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;最后检查application.yml里的连接参数,确保有characterEncoding=utf8。三重都设置对了,中文基本不会再乱。如果是新导入的项目,最快的办法是删库重新执行附带的SQL脚本,因为我给源码里的建表语句已经加好了utf8mb4参数。
6. 源码结构与二次开发建议
6.1 源码工程整体导航
如果你拿到了这份源码,打开项目之后我建议按下面的顺序阅读,避免一头扎进细节里出不来:
sql/course_scheduler.sql:先看表结构,理解了数据模型再去看代码,事半功倍;engine/包:排课引擎,这是整个项目的灵魂,重点看GreedyScheduler和ConflictChecker;service/包:业务层的调用逻辑,尤其看ScheduleService里如何编排引擎和事务;controller/包:RESTful接口定义,理解对外暴露的能力;frontend/目录:Vue页面的课表展示组件。课表组件我用的是日历式网格布局,周一至周日七列、节次十二行,高亮当前时间,这套组件可以复用到很多管理系统的视图中。
二次开发的建议就一句话:不要动引擎核心,先动业务接口。引擎的约束建模和分配逻辑经过了多轮测试,随意改动容易引入隐蔽问题。绝大多数学校落地时的定制需求,比如"某学院单独排课""某类课程优先安排到周二下午",都可以通过调整规则配置、增加排序权重系数来实现,不需要改引擎代码。
6.2 从课程设计到生产落地的差距
很多人做完这个项目就会有一种感觉:代码能跑、课表能排出来,但真要给学校用,好像还差了点什么。距离生产落地,至少还缺三块内容:一是账号体系和角色权限的前端细化,比如班主任需要看到整个班级的课表、教师只能看到自己的课表,这些要联动前端路由和后端接口的细粒度鉴权;二是课表变更的审批流,排课员调整课表后需要教务处审批通过才对外发布,保证师生看到的一定是正式版本;三是数据统计报表,教室利用率、教师课时量、各课程冲突率等指标,管理层需要这些数据做决策。
我在源码里实现了前两块的基础版本,报表模块留了接口占位。你可以把它当成一个良好的课程设计作业,也可以作为生产系统的第一版原型,后续按实际需求迭代。记住一个原则:系统和真实业务的距离,永远不在代码复杂度上,而在对业务细节的理解深度上。
6.3 后续可以扩展的方向
如果这个项目你要继续往上走,我抛几个真实可行的方向供参考。把排课引擎的贪心算法升级成遗传算法或者模拟退火算法,提升全局最优性;在排课结果生成后接入消息通知服务,把课表变更实时推送到企业微信或钉钉;用定时任务实现学期初自动排课、学期中增量调课的闭环;如果学校有多校区,教室资源的物理位置约束也要纳入模型,跨校区上课时间需要预留通勤时间。这些都是在我这个版本基础上自然衍生的方向,每一条都够单独写成一篇实战文章,但根基还是今天讲的这套SpringBoot骨架和排课引擎设计。
我个人在实际操作中的体会是:排课系统这类业务型项目,最大的坑从来不在框架,而在需求分析和约束建模。你花三个小时写完冲突检测代码,可能需要花三天去搞清楚"为什么教务处的规则看起来简单,组合起来就这么复杂"。先把业务规则一条条列出来,再动手写代码,哪怕最后用最朴素的贪心算法,产出的课表也是能用的;反过来,算法写得再花哨,约束建模漏了一条,排出来的课表就是废纸。最后再分享一个我调试排课引擎时的小技巧:在纸上画一个"星期×节次"的网格,每分配一门课就用铅笔在对应格子填上课程编号,肉眼盯几轮冲突,比你写几十个日志断言更直观。等网格逻辑跑顺了,再回到代码里抽象成数据结构,很多设计问题会一下子豁然开朗。