简介:一份基于遗传算法的自动排课系统源码包,面向教务人员、教育信息化开发者及算法学习者,用于解决多约束条件下课程表自动生成与优化问题。压缩包共334个文件,其中272个class为编译后的可运行组件,52个java为可供阅读修改的源码,另有PDF实现文档、PNG算法示意图及工程配置文件,整体仅573KB,轻量易用。已有3646人学习下载。源码包含GAGroup、GA、CSFitness等核心类,完整呈现遗传算法的编码、选择、交叉、变异与适应度评估环节,同时还涉及教师冲突检测、教室资源分配等关键逻辑;配套文档和示意图可辅助从原理到实现逐步理解。系统支持节数、主副科、上午与下午课程数及晚自习等参数配置,稍作调整即可适配不同学校场景,适合课程设计、毕业设计或教学管理二次开发。
1. 自动排课系统:课程表生成不是填格子,是约束迷宫
外行看排课,就是给老师、班级、教室各占一格,不重不漏就行;真正接过排课需求的人都知道,光“不重不漏”四个字后面压着教师同一时段只能在一间教室、一个班级同一时段只能上一门课、教室容量不能低于班级人数、实验课必须连排两节、公开课不能安排在下午第一节……一大堆互相咬死的约束。人工排一套中学课表,熟练的教务老师也要花三到五天,稍作调整又可能牵一发动全身。自动排课系统解决的就是这件事:在一堆硬约束全部满足的前提下,用算法快速生成合法课程表,并在软约束上尽量逼近人工排课的效果。它适合教务管理者、培训机构排课负责人,也适合在公司内部做会议室预约、实验排程时复用同一套思路的开发者。源码包里的引擎可以直接改着用。
2. 算法选型:为什么贪心初排加回溯修复,能扛住九成排课场景
2.1 先分清硬约束和软约束,课表才算“合法”
自动排课的第一步不是写循环,而是把约束书面化。我一般会把排课系统的约束分成两层:硬约束(不能违反)和软约束(违反越少越好)。
| 约束类型 | 典型规则 | 违反后果 |
|---|---|---|
| 硬约束 | 教师同一时间段只能出现在一间教室 | 直接冲突,必须避免 |
| 硬约束 | 班级同一时间段只能上一门课 | 直接冲突,必须避免 |
| 硬约束 | 教室座位数不小于班级人数 | 无法上课,必须检查 |
| 硬约束 | 一门课每周课时数要排满 | 课时缺失,后期补课成本高 |
| 软约束 | 同一门课尽量不要连排在同一天 | 学生易疲劳,尽量分散 |
| 软约束 | 主课尽量排在上午 | 教学效果偏好,尽量满足 |
把约束分开是有意的:硬约束决定了某门课程候选时间段集合,软约束决定了返回哪个时间段。后面贪心算法和回溯修复都只对硬约束做判定,软约束只在候选槽位排序打分时发挥作用。这一步不写清楚,后面所有代码全是白搭。
还有一点容易漏:班级人数与教室容量你都得提前拿到;很多排课系统上线第一天出问题,不是算法代码写错了,而是在这个环节把数据表的字段就给漏了。注意这个阶段要多想一步,把“上课周次”也带进数据模型里——单双周课、前八周后八周这类需求在真实排课里非常常见,早设计,比排完再补划算得多。另一个经常被忽略的是“教师最大日课时”这类软上限,比如同一个老师一天最多上四节,超过之后下午课明显没精神。这虽然属于软约束,但如果完全不做,排出来也会被教务当场退回。所以我在设计前期把硬约束和软约束用表格列清楚,再跟需求方一条条对,谁改谁负责。这个流程看起来琐碎,实际是排课系统能不能落地的生死线。
2.2 贪心初排:先排最难的课,MRV 启发式
搭好约束之后,下一步是排课主循环。最朴素的做法是按课程列表顺序一个个安排:第一门课找到空闲槽位就坐下,第二门接着找,遇到冲突就往后挪。这个方式在课程数少于 50 门的时候勉强能跑,课程一多就非常容易出现“最后一门课所有槽位都被占光”的场面。
我在排课系统里用的是带 MRV(Minimum Remaining Values,最少剩余可选值)的贪心初排:每一轮都去找还没有安排且“剩余可用槽位最少”的课程先排。举个例子,体育课只有一个指定操场,可选时间是周三下午第 5 节,那这门课就直接放到队首先排;如果不先排它,等其它课把周三下午占满,体育课就再也塞不进去了。MRV 的思想是把“选择余地小”的课提前“锁死”,把弹性大的课程留在后面填空。
实现上,每轮都要统计每个未排课程在每个时间槽位上的冲突情况。这里的冲突字典是三个哈希表:教师时间表、班级时间表、教室时间表。只要这三个表里都还空着,就认为该槽位可用。虽然每轮都要做一次全量扫描,但这种做法对 500 门课以内的场景完全够用,比上来就上整数规划要轻得多。优先级的排序规则我从两个维度走:一是剩余可用槽位个数,二是学时数,学时多的大课靠前。
# 贪心初排 + 回溯修复的骨架 def schedule(remaining_courses): if not remaining_courses: return True course = select_by_mrv(remaining_courses) # 剩余可用槽位最少的课程先排 for slot in sort_slots_by_score(course): if is_legal(course, slot): occupy(course, slot) if schedule(remaining_courses - {course}): return True release(course, slot) return False这段骨架代码里,select_by_mrv 负责选出“最紧迫”的课程,sort_slots_by_score 负责把软约束偏好折算成槽位得分,is_legal 只判断硬约束。实际项目里我不会把上面这个函数直接当生产代码用,因为它对每门课都做一次集合复制,课程量一上来就把性能吃掉了。生产代码会把 remaining_courses 改成 list,配合一个 visited 集合做增量更新。不过逻辑主线是一样的:先选最难的课,再把候选槽位按软约束排序,最后用递归层数控制回溯深度。
2.3 回溯修复:给贪心没排完的课找后悔药
贪心初排最大的毛病是局部最优解不等于全局合法解。课程 A 和课程 B 单独看都选了“当时看来最好”的槽位,结果到课程 C 时怎么都塞不进去。这时候不是把 C 随机往后挪,而是回到最近几步做一次“换课”尝试:把占用槽位的某个课程踢出去,让它另找空位,再把当前课程放进来。这个操作在算法里就是回溯(backtracking)。
回溯的具体做法一般是一个深度优先搜索:递归地尝试把未排课程放到某个可用槽位,排完就往下走,走不通就撤销上一个动作,换下一个槽位再试。为了防止用户等半天没结果,我在回溯循环外面套两层护栏:最大回溯层数(max_depth)和最大 restart 次数。回溯到一定深度还解不开,就抛弃当前解,用新的随机种子重新跑贪心初排。
这里涉及一个实际经验:遇到“差一点点就排完”的情况,单独加深回溯可能不如多做几次 restart。因为贪心初排产生的死局往往只是某一门课程被某个槽位卡死,换一个初排顺序就绕开了。实践中我一般把 max_depth 设在 50 到 200,restart 次数 20 到 50,这样既能避免无限递归,又能在几秒内出结果。具体参数放在主配置里,按课程量调,别写死在代码里。
还有一个细节:回溯时不要把整张课表都退回去。正确的做法是把“当前分支上被追加的两个动作”做逆序删除,而不是从头释放所有课程。课程量小的时候看不出区别,课程量到一千门时,两种做法的耗时会差出两个数量级。这也是我在实际项目里把 occupy 和 release 封装成对称函数的原因,一个 add,一个 remove,保证回溯可以精确撤销到上一步,而不是把前面辛辛苦苦排好的成果全扔掉。多说一句,如果你准备用遗传算法或者模拟退火来做软约束优化,那就更要把硬约束检查和软约束打分分成两个函数,这两个函数的输入输出要保持稳定,不然后面换算法时你还要把排课引擎整个重写一遍。
3. 数据建模:教师、班级、教室和时间段如何存才不打架
3.1 时间段建模:5 天乘 5 节不够用,补上“周次”字段
真实的排课系统里最容易被新手忽略的是时间段不是二维的,而是三维的:星期几、第几节、第几周。如果只按“周一第 1 节”来建表,单双周课、前八周课就没有地方挂。我在项目中用的建模方式简单直接:用一个元组做时间槽位(day, period, week_type),week_type 取 0 表示每周都上,1 表示单周,2 表示双周。这样在检查冲突时,把周次参数也并进比较,就能自动过滤不需要占用的槽位。
实际代码里我会把常用槽位预生成到 list 里,方便后面对候选槽位做遍历排序。
# 时间槽预生成:每周5天,每天5节,周类型0=每周、1=单周、2=双周 def build_slots(days=5, periods=5, week_types=(0, 1, 2)): slots = [] for d in range(days): for p in range(periods): for wt in week_types: slots.append((d, p, wt)) return slots注意这样生成的槽位总数是 5×5×3=75 个,而一个课程如果标为单周课,它的候选槽位就只有 25 个。之后在统计 MRV 时,这段预生成列表直接参与 set 操作,排重和判断归属都非常快,比一遍遍地 for 循环判断“是不是这周有课”要省事得多。对大多数中小学或培训机构的规模,75 个槽位的候选集合已经足够,不需要再去精确算周历上的第几周。
但这里有个细节值得单独说:如果你只需要排“每周课表”,不处理周次,那么 week_type 可以固定为 0,槽位总数就是 25。不要把 75 个槽位直接全发给课程,否则周次过滤做不好,单周课会被错误地安排到双周星期几的空位上,最后导出的课表会出现“这一周这间教室空着,下一周又撞了”的诡异结果。所以槽位过滤和课程 week_type 必须配套用。后面讲冲突判定时,这三个维度会被当作一个整体放进 set,判断两个课程是否冲突就是判断元组是否重复,逻辑非常简单。
3.2 四个实体类的字段设计:ID 用字符串,软约束尽量不带进算法
在动手写算法前,先把实体类定义好。我用 Python dataclass 定义了四个实体:Teacher、ClassGroup、Classroom、Course。这里最关键的一点是:所有实体只能用 ID 作为唯一标识,不要用姓名做 key。真实数据里同名同姓的教师太常见了,我用姓名做 key 的系统上线第一周就出过“两位同名教师课时互相覆盖”的坑。
from dataclasses import dataclass, field @dataclass class Teacher: tid: str name: str max_daily_hours: int = 5 # 一天最多排课节数的软上限 @dataclass class ClassGroup: cid: str name: str size: int # 班级人数,用于教室容量校验 @dataclass class Classroom: rid: str name: str capacity: int @dataclass class Course: cid: str class_id: str # 开课的班级 ID teacher_id: str subject: str hours_per_week: int # 每周课时数,硬约束 preferred_slots: list = field(default_factory=list) # 软约束偏好 week_type: int = 0 # 0 每周,1 单周,2 双周字段设计看起来简单,却决定了后面冲突判定和回溯的效率。hours_per_week 直接决定这门课要在时间表里占用几个槽位;preferred_slots 可以在贪心打分时把上午第一节这类偏好加进去;week_type 参与时间槽过滤。所有 ID 都用字符串类型,不要用自增数字,因为数据自外部导入时数字很容易错位。另外一个习惯是给 Course 加上 subject 字段。有人会问:课表输出只要知道教师、班级、教室和节次就行,为什么还要科目名?因为导出 CSV 给教务看的时候,没有科目名根本没法读,你总不能在文件里写“course_c003_teacher_t001”。所以宁可多存一个冗余字段,也不要让下游去回表查。
还有一个容易被忽略的细节:Teacher 的 max_daily_hours 这种软约束字段不要直接参与 is_legal 判定。它应该只影响 sort_slots_by_score 里的打分,否则硬约束一旦和软约束混在一起,回溯很容易把一个老师一天排到 8 节,最后全校课表都合法但不合理。排课系统的参数设计原则是:硬约束放在判定函数里,软约束放在打分函数里,中间隔一层,永远不要互相渗透。这样以后教务提出“每天不超过 6 节”的新要求,只需要改打分权重,不需要动冲突判定。
3.3 三个冲突索引:teacher_schedule、class_schedule、room_schedule
这是整个排课引擎的心脏,所有冲突判断都围绕这三个字典转。每一个字典的 key 是对象 ID,value 是一个 set,存放这个对象已经被占用的时间槽元组。只要把新课程分配的槽位元组 add 进对应三个 set,再检查同一个课程槽位是否与这三个 set 有交集,冲突就能在 O(1) 时间内判断出来。
# 冲突索引的初始化与占用/释放逻辑 teacher_schedule: dict[str, set] = {} # teacher_id -> set(slot) class_schedule: dict[str, set] = {} # class_id -> set(slot) room_schedule: dict[str, set] = {} # room_id -> set(slot) def is_conflict(course, slot, room_id): if (course.teacher_id, slot) in teacher_schedule.get(course.teacher_id, set()): return True if (course.class_id, slot) in class_schedule.get(course.class_id, set()): return True if (room_id, slot) in room_schedule.get(room_id, set()): return True return False def occupy(course, slot, room_id): teacher_schedule.setdefault(course.teacher_id, set()).add((course.teacher_id, slot)) class_schedule.setdefault(course.class_id, set()).add((course.class_id, slot)) room_schedule.setdefault(room_id, set()).add((room_id, slot)) def release(course, slot, room_id): teacher_schedule[course.teacher_id].discard((course.teacher_id, slot)) class_schedule[course.class_id].discard((course.class_id, slot)) room_schedule[room_id].discard((room_id, slot))这里 set 里存的不是裸 slot,而是(教师ID, slot)这种复合元组。为什么?因为如果只存 slot,这个 set 没法区分同一个时间槽位上不同教师的占用记录,冲突判断时只能靠遍历。加上对象 ID 以后,set 的语义变成“该教师在该时间槽的这个 ID 被占用”,这样即使不同教师同时占用同一个时间槽,也不会互相干扰。班级和教室同理。三个 set 都以元组为单位,既能精确判断冲突,又能在需要时快速取出某个具体对象在当前槽位的占用情况,方便回溯时定点释放。
至于为什么用三个 set 而不是一个大 dict,原因在于排课引擎的核心操作是“查冲突”和“撤销冲突”,set 的 add / remove 都是均摊 O(1)。如果用课程列表去每次遍历,七百门课的场景下回溯效率会直接崩掉。还有人在这个环节用多维数组来做时间片占用标记,也能跑,但一旦加入周次类型和教室维度,三维数组的维度管理会非常繁琐,容易手写出越界 bug。三个 hash set 加元组的做法,是几个方案里最不容易翻车的一种。
注意 is_conflict 的参数里没有单独传“周次”字段,这不是漏了,而是因为 slot 元组里已经包含 week_type。如果课程是单周课,那么在遍历 slot 时只需要把周类型等于 1 的槽位列进候选集合,双周与每周课所需要的槽位根本不会同时出现在同一门课的候选列表里。两个课程在同一时间槽上是否冲突,完全由元组相等性决定,不需要额外写乘除逻辑。
4. 排课引擎实现:一个能跑通 300 门课程的参考代码
4.1 贪心初排实现:MRV 排序与软约束打分
现在把前面的数据结构和算法合起来,写一个最小可用的排课引擎。第一步是贪心初排函数。它接收课程列表、教室列表和预生成的时间槽,返回一个 assignment 字典。排序的核心是 count_available:对每一门未排课程,统计它在剩余槽位里有多少个不冲突的候选。然后按这个数字升序排列,数字越少越靠前。软约束打分函数 sort_slots_by_score 则根据课程偏好、时间段热度给每个槽位打一个整数分,得分高的优先。
# 贪心初排:MRV 排序 + 软约束打分,返回课程id->(slot, room) 的映射 def greedy_schedule(courses, rooms, slots): assignment = {} remaining = list(courses) while remaining: remaining.sort(key=lambda c: count_available(c, assignment, rooms)) c = remaining[0] chosen = None best_score = -1 for slot in slots: if c.week_type and slot[2] != c.week_type: continue for room in rooms: if room.capacity < find_class(c.class_id).size: continue if is_conflict(c, slot, room.rid): continue score = soft_score(c, slot) if score > best_score: best_score = score chosen = (slot, room.rid) if chosen is None: return None # 贪心阶段失败,交给回溯处理 assignment[c.id] = chosen occupy(c, chosen[0], chosen[1]) remaining.remove(c) return assignment这段代码里有两个关键参数:rooms 列表用于容量校验,slots 列表用于过滤 week_type。count_available 内部做了两件事:对每门课先算“该课程允许的槽位集合”,再减去三个冲突索引里已有的槽位,剩下的就是不冲突槽位。这个函数如果频繁调用,建议直接维护一个剩余可用槽位计数字典,分配和释放时只对这个字典做加减,不要在 while 循环里重复全量扫描。上面这段为了可读性保留了扫描版本,实际拿去做几百门课的排程时,记得换成增量计数。
4.2 回溯修复实现:深度优先搜索加策略
贪心返回 None 就说明出现死锁,这时进入回溯修复。下面这段是标准的带深度限制回溯,思路和 2.3 节骨架一致,区别在于这里会真正修改 schedule 字典和三个冲突索引,并且释放动作和占用动作完全对称。
# 回溯修复:带最大深度限制,返回排完的 assignment 或 None def backtrack(assignment, remaining, max_depth): if not remaining: return assignment if max_depth <= 0: return None course = min(remaining, key=lambda c: count_available(c, assignment, rooms)) for slot in slots: if course.week_type and slot[2] != course.week_type: continue for room in rooms: if is_conflict(course, slot, room.rid): continue assignment[course.id] = (slot, room.rid) occupy(course, slot, room.rid) next_remaining = [x for x in remaining if x.cid != course.cid] result = backtrack(assignment, next_remaining, max_depth - 1) if result: return result # 撤销当前选择,回到上一步状态 release(course, slot, room.rid) del assignment[course.id] return None这段回溯的要点有三个。第一个是 max_depth 递减,每次递归尝试都看作是消耗一层深度,防止死循环。第二个是 room 遍历顺序,我把容量大的教室放在列表前面,这样大班课优先占到大教室,小班课后续可选的教室更多。第三个是释放动作必须对称,occupy 里 add 了什么,release 里就 discard 什么,否则多释放或少释放一次,后面的冲突判断全部会错。你可以在这段代码后加一个计数器,统计回溯中“走到底然后返回”的次数,这个数值在优化排课速度时非常有用:如果次数很高,说明启发式排序还不够好,优先去改进 count_available 的实现,而不是盲目增大 max_depth。
4.3 主循环:随机重启与最长等待时间
单独一次回溯往往不够,我在主函数里套了随机重启。做法是:每次重启前先打乱课程列表,重置三个冲突索引,再重新执行贪心和回溯。打乱课程列表相当于换了个初排起点,能得到不同的局部解。restart 上限和最大回溯深度放在配置里,不要写死。对三百门课、每天五节、每周五天这个规模,我一般给 max_depth=80,restarts=30,跑完一次平均十秒到半分钟,完全可以接受。
# 主循环:随机重启,直到解出或者达到重启上限 def solve(courses, rooms, slots, max_depth=80, max_restarts=30): for attempt in range(max_restarts): random.shuffle(courses) teacher_schedule.clear(); class_schedule.clear(); room_schedule.clear() assignment = greedy_schedule(courses, rooms, slots) if assignment is None: assignment = backtrack({}, courses, max_depth) if assignment and len(assignment) == len(courses): return assignment return None注意这里有个容易被忽略的隐藏问题:greedy_schedule 和 backtrack 都修改了三个全局冲突索引,所以每次 restart 之前必须 clear。如果忘记 clear,上一次失败的占用记录会残留到下一轮,然后你会在第二十次尝试时发现课表排得越来越快但结果越来越怪。真出现这种“排序更顺但结果反而错”的现象,十有八九就是全局状态没清干净,这是我见过最频繁的排课系统翻车现场之一。
提示:程序跑得“异常快”往往不是优化成功,而是全局索引没清空,冲突判断全部失效。先把三个 set 清空再谈性能。
4.4 把结果导出成可读课程表:CSV 直接给教务看
排完课,最后一步是输出。代码里导出一份 CSV,包含星期、节次、班级、课程、教师和教室,直接用 Excel 打开就能看。教务需要课表,学生家长也可能要一份班级课表,所以导出时我会额外生成一个按班级拆分的子表。下面是一个最简单的输出函数:
# 导出 CSV:每个班级一张表,字段是星期/节次/课程/教师/教室 import csv def export_timetable(assignment, courses, class_id): rows = [] for c in courses: if c.class_id != class_id: continue slot, room_id = assignment[c.id] rows.append([slot[0] + 1, slot[1] + 1, c.subject, c.teacher_id, room_id]) rows.sort(key=lambda x: (x[0], x[1])) with open(f"timetable_{class_id}.csv", "w", newline="") as f: writer = csv.writer(f) writer.writerow(["星期", "节次", "科目", "教师编号", "教室编号"]) writer.writerows(rows)导出前记得再校验一次 assignment 的完整性,别把半截回溯结果直接输出。比较稳妥的做法是写一个 verify(assignment, courses, rooms) 函数,在 export 之前跑一遍,发现问题就直接抛异常,而不是给教务发一份有冲突的课表。这个 verify 函数就是第六章要讲的重点,也是我所有排课项目里雷打不动的一道工序。
5. 排课避坑手册:五个“看着没问题,跑起来翻车”的实战场景
5.1 现象:同名同姓的教师,课时被合并到一个人头上
现象:一天午后,教务发来消息说“某位老师的课表多了一倍,另一位同名老师一节课都没有”。检查排课结果发现,课程文件里同时出现了两位同名教师,因为数据库里直接将教师姓名作为主键,同名教师被合并成了同一个人,排课时这位“同名教师”的所有课时都被当成一个人的硬约束,自然就出现了一个人多课、另一个人空课的错乱。
原因:实体主键设计时没有用唯一 ID,直接把 name 当成了 key。排课系统的核心约束全部依赖教师主键来聚合课时,key 一旦合并,后面所有 set、字典都会跟着错乱。
解决:所有实体统一用字符串 ID,姓名只作为输出显示字段,不参与任何逻辑。导入数据时做一轮去重查重,发现同名教师自动追加后缀编号。从那以后,所有排课项目我都要求上游先给统一 ID,哪怕一个学校只有几百个教师,也不要嫌麻烦走捷径用姓名当键。
5.2 现象:教室容量明明够,结果还是有两节课挤在同一间教室
现象:程序跑完,导出课表,发现同一时间同一教室里重叠了两门课。教室容量校验写了,班级人数也没超,为什么还会撞?
原因:教室冲突判定只做了“人数校验”,却没有把教室同一个槽位的占用记录做冲突检查。也就是说 is_conflict 里检查了 teacher_schedule 和 class_schedule,但 room_schedule 的判断被漏掉,或者 room 参数没传到 is_conflict 里。因为不同课程可以分配给不同教室,漏掉 room 冲突时,回溯会因为“看起来不冲突”而把两门课塞进同一个物理空间。
解决:在 is_conflict 中加入 room_schedule 判断,并把(room_id, slot)复合元组和 teacher/class 同等对待。同时写一个自动化完整性检查脚本,专门检测三张表里任意两张的重叠记录,一发现问题立刻打印是哪几门课程在哪个时段冲突。
5.3 现象:单双周课和每周课都排上了,但导出的课表周日冲突
现象:周一第 2 节既出现了数学课,又出现了单周日英语课。人工核对日子发现周日根本没上课,但导出的 CSV 里两节课就叠在同一行。
原因:时间槽位模型没有引入 week_type,或者引入后冲突判断仍然用(day, period)而不是(day, period, week_type)来判定。课程分配时,单周课用了(1,1)这种槽位,每周课也用了(1,1),两个槽位在 set 里被视为同一个 key,于是被当成冲突保护住了;但导出时只输出 day 和 period,漏掉了 week_type,导致两节课叠在一起被打印出来。
解决:槽位元组固定为三元组,week_type 同时参与占用和导出。导出表格时把 week_type 也写进 CSV,哪怕教务要求只显示每周课,也先保留周次列用于核对。还有一个习惯:不要用 0 和 False 混用,周次类型用一个枚举值,0=每周、1=单周、2=双周,别直接用 boolean 表示“单周”状态,免得后面加“前八周”这种需求时无处下手。
5.4 现象:回溯深度调到 1000 还解不开,重启后却秒出结果
现象:测试时发现课程量增大后,回溯递归深度设置到 1000 依然超时,但把 max_depth 改回 100、把 restart 次数从 10 改为 50,反而更快解出了结果。
原因:回溯深度过大时,程序在一棵极其不平衡的搜索树上浪费了大量时间。很多人本能地以为“深度不够就加深”,其实对排课这种约束多、分支多的搜索来说,深度过大反而容易让程序陷入无效的重复尝试。增加随机重启次数相当于给搜索过程换起点,从多个初始状态并行探索,远比在一棵树上硬挖更有效。
解决:把 max_depth 控制在 30 到 200 之间,restart 次数按课程数量线性增长。每次 restart 后统计一次“解出一门课的平均回溯次数”,如果这个值偏高,优先优化启发式排序而不是继续加深度。这是典型的算法调参玄学:看起来像参数问题,本质是启发函数质量不够。
5.5 现象:同一间教室被连续分配三节不同课程,学生上课体验很差
现象:排课结果没有冲突,但同一班级同一天下午要跑三个不同教室上三门课,路上时间完全不够。这类问题不是算法 bug,而是软约束打分缺失。
原因:软约束打分只考虑了课程的 preferred_slots,没有考虑“同一班级同一天在不同教室的移动成本”。排课系统的目标不只是“合法”,还要“合理”。教室与教室之间物理距离远时,班级学生一天来回跑几次,哪怕课表没冲突也会被当成废品。
解决:在软约束打分里增加教室分布维度。具体做法是统计某个班级在一天内的上课教室分布,算出移动次数,把它作为负向分值加进 sort_slots_by_score。移动惩罚项权重可以先用 0.3 到 0.5 之间试跑,再看教务反馈逐步调。这个参数属于纯软约束,每个学校的物理布局不一样,没有通用最优值,只能是针对当前环境微调。
6. 排课质量怎么验:30 行校验脚本,把硬约束一键查完
排完课先别急着导出和交付。我习惯在交付前强制跑一遍全量校验:把每个教师、每个班级、每个教室的课程按时间槽展开,逐项检查是否有重课、漏课、超容量。这个校验脚本很简单,但价值很高,甚至可以当“后悔药”用——在交付前把能发现的错全揪出来,总比被教务当场退回强。
# 全量校验:检查教师/班级/教室三张表是否有冲突,并统计课时数 def verify(assignment, courses, rooms): errors = [] teacher_times = {} class_times = {} room_times = {} for c in courses: slot, room = assignment[c.id] key_t = (c.teacher_id, slot) key_c = (c.class_id, slot) key_r = (room, slot) if key_t in teacher_times: errors.append(f"教师冲突: {c.cid}") if key_c in class_times: errors.append(f"班级冲突: {c.cid}") if key_r in room_times: errors.append(f"教室冲突: {c.cid}") teacher_times[key_t] = c.cid class_times[key_c] = c.cid room_times[key_r] = c.cid if errors: raise RuntimeError(errors) print(f"校验通过:{len(courses)} 门课程,无冲突")这段脚本除了查冲突,我还把课时数统计放在同一个循环里,检查实际排出的节数和 Course 里的 hours_per_week 是否相等。这两个数据对不上,通常意味着回溯把某门课漏排或重复排了。输出时不要只打印“成功”两个字,要把每个教师、每个班级的课时数列出来,教务一眼就能看出问题。
进阶一点的做法是给验证脚本加一个软约束达标率报告:统计上午课占比、同班同天不连续课比例、单日超四节的比例。这些数字不参与合法性判断,但能作为“课程表好不好的指标”。我一般会在项目上线后把软约束报表生成成 HTML 放到内网,排课负责人自己就能看到改参数后效果的变化,不用每次来问“为什么排出来这么难看”。
还有个好用的排查技巧:如果校验报错但又看不出原因,就随手挑十个课程、十个时间槽,把你自己的选课逻辑在纸上走一遍,复现“课程 C 为什么在这里没有冲突”的过程。大多数时候问题出在你的 if 判断和实际占用记录对不上,比如某个 set 忘记清空,或者释放动作多调了一次。这个调试习惯搭配 verify 函数,基本能覆盖排课系统八成以上的逻辑故障。
这个习惯是从一次交付翻车中养成的。那时我把一个周六的课表排满,忘了跑校验,教务核对时发现周六的课表有一半课程被另一个培训项目借走了。从那以后,我每次排完课都强制走一遍全量校验,校验脚本输出的统计表就是排课质量的兜底保障。希望这次的自动排课系统资源和踩坑记录,能帮你少走一段弯路。
本文还有配套的精品资源,点击获取