ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

自动排课系统从0到1:数据建模与贪心、回溯、遗传算法实战指南

自动排课系统从0到1:数据建模与贪心、回溯、遗传算法实战指南

简介:自动排课系统是一套基于遗传算法等智能优化策略的课程表生成工具,面向教务管理人员、教育信息化开发者及算法学习者,适合课程设计或实际排课场景。它支持设定主课/副科、上下午节数及晚自习等参数,在约束条件下自动生成合理排课方案,可显著减轻人工排课负担。压缩包共334个文件,以Java源代码及编译后的类文件为主,另有PDF说明文档、算法流程示意图、项目配置文件等,体积仅573KB,便于快速获取与本地运行。已吸引3646人学习下载。通过资源中的源码与文档,读者可深入理解排课建模过程、遗传算法编码方式、适应度函数设计,以及教师冲突、教室分配等约束的处理思路;配套图示和示例封面有助于快速把握系统结构,适合作为课程设计或教育管理系统的参考实现。

1. 自动排课系统(课程表)为什么“自动”起来这么难:从填格子到约束满足

做排课系统的人,基本都经历过同一个场景:教务老师手工填课程表,填到最后一列发现某位老师同时出现在两间教室,只能推倒重排;你把这张表交给程序去生成,以为按顺序填格子就行,结果程序跑完,最后总有一两节课插不进去。自动排课系统(课程表)看起来是个“填空”问题,本质上却是在“时间 × 班级 × 教师 × 教室”四维空间里求解约束满足问题(CSP),候选组合数会随课程数量指数级膨胀,所以排不出来是常态,不是程序 bug。这篇文章会告诉你如何把课表翻译成机器能搜的数据结构、三种主流算法怎么选,以及哪些细节会让排课结果直接翻车。适合正在做教务系统、培训机构排课工具,或者想用算法替代手工排课的开发者。

2. 排课数据建模:实体关系、时间片索引与硬约束的代码落地

排课算法写得再好,数据模型没立住一样白搭。我见过太多第一版排课代码,把“课程表”直接做成一棵嵌套数组,往里塞字符串,等做到冲突检测、连排、单双周这些需求时发现数组结构根本没法扩展,只能推翻重来。排课系统的地基是数据建模,先把实体边界、时间片切法、约束分类这三件事做对,后面所有算法都是在这套结构上做搜索。

2.1 四类实体一张关系表:教师、班级、课程、教室的数据结构设计

排课的最小数据单元不是“课程”,而是“课次”。一门课每周上4节,在排课系统里就是4个独立的排课单位,每个单位占用一个时间片。数据建模的第一步,是把课程需求、教师、班级、教室四类实体分开存,再在课程需求里用编号引用其它实体。

# schedule_demo:课程需求的 JSON 结构 course_requests = [ { "course_id": "MATH101", # 课程唯一编号 "name": "高等数学", "teacher_id": "T001", # 任课教师编号 "class_ids": ["A01", "A02"], # 合班上课,展开后占两个班级的课次 "weekly_hours": 4, # 每周课时数 "preferred_slots": "morning", # 软约束:偏好上午 "consecutive": True, # 是否要求两节连排 "room_id": "R101", # 建议教室,可为空 }, ] teachers = {"T001": {"name": "A老师", "max_hours_per_day": 4}} classes = {"A01": {"name": "高一(1)班"}, "A02": {"name": "高一(2)班"}} rooms = {"R101": {"name": "101阶梯教室", "capacity": 60}}

这段结构里,关键是class_ids用列表而不是单个字符串。它解决的是合班课:一个教师、一间教室、同一时间,多个班的学生一起上,排课时要同时占用这些班级的时间片。weekly_hours决定这个词条要拆成多少个排课单位,拆的时候要带上 course_id 引用,不能把课次做成完全独立的对象,否则排完课无法溯源到课程。preferred_slots是软约束,只参与评分,不参与硬性过滤。max_hours_per_day这种教师维度的约束要单独建表存,不能靠手工数课程表去判断。

数据模型建好后,合班课、多教师课(一个老师带多个班)、合教室课(实验课和讨论课共用机房)都能在这个结构上展开。反过来,如果你一开始用“一班一行、每行七个格子”的二维表建模,遇到合班就要复制行数据,后面所有修改都得同步多份,排课算法的每一次状态回退都会变得非常痛苦。

2.2 时间片切法与索引换算:从“周一第3节”到整数下标

课程表真正要排的,是“每周哪些时间片被哪些课次占用”。时间片的切法决定了整个系统的复杂度。常见做法有两种:用二维数组[5][8]表示一周五天每天八节,或者把一周所有时间片压成一维下标 0~39。我一般推荐一维下标,因为后面做冲突检测、回溯、遗传算法的交叉变异时,都需要随机选取和交换时间片,一维结构比多维结构好处理得多。

DAYS = 5 SLOTS_PER_DAY = 8 WEEK_TIME_SLICES = DAYS * SLOTS_PER_DAY # 每周 40 个时间片 def time_slice_index(weekday, slot_in_day): """把'周三第5节'换算成整数下标,weekday 从 1 开始,slot_in_day 从 0 开始""" return (weekday - 1) * SLOTS_PER_DAY + slot_in_day def index_to_time(index): """逆运算,调试打印课程表时用""" weekday = index // SLOTS_PER_DAY + 1 slot = index % SLOTS_PER_DAY return f"周{weekday} 第{slot + 1}节"

一维下标是所有占用表和冲突检测的基础。time_slice_index做的是“语义坐标 → 数值下标”的换算,index_to_time则是调试阶段打印课程表时用的逆运算。之所以不直接用二维列表,是因为回溯和遗传算法经常要洗牌、切段、交换,一维数组用切片和随机交换都很直接,二维数组处理起来要同时维护两个坐标。这个设计选型在数据量小的时候看不出差别,一旦课程数量上百,代码可读性和调试效率差距就出来了。

另外一个坑在时间片粒度上。时间片通常是“一节课”,但如果学校存在长短课,比如上午四节 45 分钟、下午两节 90 分钟,就不能简单按“天数 × 节数”来切。这时要在课次结构里存“起始时间 + 时长”,冲突检测比的是时间区间是否重叠,而不是时间片下标是否相等。这个问题在培训机构和高校的排课系统里很常见,建议一开始就确认好,不要等到做完了才发现模型不支持。

2.3 硬约束与软约束:哪些规则必须绝对满足,哪些只影响质量

排课规则分两类,代码里必须分开对待。硬约束是必须满足的:同一时间一个教师不能同时在两门课;同一时间一个班级不能同时上两门课;同一时间一个教室不能被两门课占用;单个教师每天课时不能超过上限;部分教师指定某时间段不能排课。这些规则不满足,课程表就是废纸。

软约束则是不满足可以、最好满足的规则:专业课优先排在上午;同一门课一天内不要超过两节;教师的周课时尽量均衡;某些课不要排在周五下午。软约束之间还可能互相打架——“上午优先”和“课时均衡”就在抢同一个时间片。把软约束写进硬约束过滤条件里,是最常见的建模错误,会导致搜索空间被提前剪掉一大块,明明有解却搜不到。

HARD_RULES = [ "teacher_no_same_time", "class_no_same_time", "room_no_same_time", "teacher_max_hours_per_day", ] SOFT_WEIGHTS = { "morning_first": 10, # 上午优先 "avoid_friday_afternoon": 5, # 避免周五下午 "consecutive_bonus": 15, # 连排奖励 }

硬约束直接决定一个时间片能不能选,软约束决定在多个可用时间片里选哪个。SOFT_WEIGHTS是一组可调参数,要允许从配置文件或外部参数传入。权重不能拍脑袋定,调整后必须跑一遍校验和统计对比,我在第 6 章会专门写这个验证环节。数据上线前还有一个前置检查:教师表中的编号是否都能在课程需求里对应上,教室容量是否大于选课人数,周课时数是否超过每周可排课时上限。这些检查要放在排课之前,否则算法运行到一半才发现数据错误,一上午就白跑了。

3. 三种排课算法选型:贪心、回溯与遗传算法怎么选怎么用

数据模型建好后,下一步是选搜索算法。排课问题在算法层面是 NP-hard,不存在一个“又准又快又简单”的万能方案。实际工程里,三种算法各有用武之地:贪心算法适合做第一版原型,回溯适合中等规模确定性求解,遗传算法适合课程数量大、约束复杂的场景。这一章把三种算法怎么落地、参数怎么调都过一遍,你可以按自己的数据规模直接套。

3.1 最小编排贪心法:先排最紧课程的最小可用代码

贪心算法的核心不是“按顺序填格子”,而是“谁最难排谁先排”。衡量难排程度有两个指标:每周课时多,占用的时间片多;可选时间片少,教师时间被切得很碎、特定时间段又不能排。按这个优先级排序后,每门课优先选择最早的可用时间片。

def greedy_schedule(course_requests, teachers, classes, rooms): # 把每周课时拆成一条条课次,每条课次仍然带着课程的完整信息 units = [] for req in course_requests: for _ in range(req["weekly_hours"]): units.append(req) # 关键排序:课时多、可用时间片少的排前面 units.sort(key=lambda req: ( -req["weekly_hours"], len(available_slots(req, teachers, classes, rooms)), )) schedule = [] for req in units: slot = first_available_slot(req, teachers, classes, rooms) if slot is None: continue # 正式实现里要记为失败,不能静默跳过 commit(schedule, req, slot, teachers, classes, rooms) return schedule

这段代码里有几个设计点需要说清楚。units把一条条课次展开,是为了排序和后续回溯统一下料;排序元组第一个元素用负的weekly_hours,Python 的sort默认升序,负号让课时多的排到前面;第二个元素用len(available_slots(...)),可用时间片少的排到前面,这就是教科书里的“最小编排”启发式。注意available_slots必须在循环内动态计算,因为每 commit 一次,教师、班级、教室的占用表都会变化,后面课程的可用时间片会越来越少,同一个函数在排课前和排课中跑出来的结果不一样。

贪心法不保证有解时一定找到解,但它的价值是快和稳定。给教务老师做第一版筛选工具时,贪心 10 秒内能出一份“基本能看”的草稿,老师拿直接人工微调,这比半年后交付一个完美的回溯算法要实际得多。我第一次独立做排课功能时,就是先用贪心跑通了全部数据链路,再逐步往上加算法,这条路径可以少走很多弯路。

3.2 回溯搜索兜底:处理无解分支与剪枝的递归实现

当多个约束交叠,贪心会在中途找不到可用时间片,这时候需要回溯:尝试把当前课次排到某个时间片,继续排后面的课,排不下去就释放当前占用的时间片,换下一个继续试。这是典型的深度优先搜索,配上“可选时间片少的课次先排”的剪枝策略,几十门课的规模能稳定找到解。

def backtrack(units, idx, schedule): """每个课次一个位置,排完最后一个课次说明找到了一个可行解""" if idx == len(units): return True req = units[idx] for slot in sorted(available_slots(req), key=slot_quality): if is_free(req, slot): commit(schedule, req, slot) if backtrack(units, idx + 1, schedule): return True release(schedule, req, slot) # 撤销占用,换下一个时间片 return False

这段递归的关键在release必须紧跟在递归返回之后。很多回溯实现写错,就是 commit 和 release 没有成对出现,回溯发生后占用表上残留脏数据,后面所有分支都会误判冲突。slot_quality是时间片偏好排序,比如优先上午、优先周一,这样在找到的第一个解就是质量较高的解,而不是随便从一个解开始后处理。搜索顺序在回溯里非常重要,好的排序能让你早几个数量级命中目标;如果可用时间片很多,解空间会指数级膨胀,剪枝带来的收益比任何优化都明显。

参数方面,必须加步数上限。课程数乘 40 个时间片是理论上界,实际执行时大部分分支会在前几个课次就冲突,剪枝很快。但如果数据本身无解,回溯会把整棵树遍历完再返回 False,几十门课也能跑几分钟。实践中我会在外面套一层步数计数器,超过比如 200 万次尝试就放弃,并把当前找到的最优前缀输出给人工排查。这个“疑似无解”的提示,比让用户等一个永不返回的进度条体面得多。

3.3 遗传算法的大规模场景:适应度函数、选择与变异参数

课程规模上百门时,回溯的耗时指数上升,遗传算法反而更合适。它的思路是把一张课程表编码成一个个体,用适应度函数给每个个体打分,再通过选择、交叉、变异不断迭代种群。常见编码方式:每个课次固定一个位置,个体就是一个长度为课次数、值为时间片下标的数组。

def fitness(individual): """individual: 长度为 N 的列表,第 i 个元素是第 i 个课次的时间片下标""" hard_conflicts = count_hard_conflicts(individual) # 遍历课次查三类占用表 soft_score = calc_soft_score(individual) # 上午优先、连排奖励等 return -hard_conflicts * 1000 + soft_score def evolve(units, pop_size=120, generations=300, seed=42): random.seed(seed) population = [init_individual(units) for _ in range(pop_size)] # 把贪心解注入第 0 代,保证种群起点不是纯随机 population[0] = greedy_schedule(units) for _ in range(generations): new_pop = [] for _ in range(pop_size): parent_a = tournament_select(population, k=3) parent_b = tournament_select(population, k=3) child = crossover(parent_a, parent_b) mutate(child, rate=0.1) new_pop.append(child) population = new_pop return best_individual(population)

这里最容易被忽略的是把贪心解注入population[0]。这一手能让遗传算法的起点高于纯随机初始化,否则第一代种群可能全是高冲突个体,前面几十代都在浪费算力把随机解洗到一个正常水平。tournament_select是锦标赛选择:随机抽 3 个个体,取适应度最高的当父本,参数 k=3 的选择压力适中。crossover是单点交叉:在随机位置切一刀,前半段来自 A、后半段来自 B,因为个体是时间片下标的一维数组,交叉操作不会破坏课程之间的对应关系。

参数方面,种群大小 100~200、迭代 200~300 代在这个问题规模上性价比最高,超过后收益递减。变异率取 0.1~0.2,太高会把好解震碎,太低会早熟收敛到局部最优。适应度里硬约束一个冲突扣 1000 分,软约束评分整体控制在 ±100 以内,这样软约束永远不能通过牺牲硬约束来换分数,这条边界是遗传算法调参里最关键的防线。

4. 业务约束与冲突检测:连排、限排与增量更新的实现细节

算法框架搭起来后,真正让排课系统“像样”的是业务约束的落地。课程表不是把课次填满就行,还有连排、单双周、教师限排、教室容量这些细节。这一章讲三件事:三种占用表怎么同步检测、连排和单双周怎么建模、回溯状态怎么安全回退。

4.1 三类冲突同时检测:教师表、班级表、教室表怎么保持同步

排课系统最常见的 bug 是只检查了其中一张占用表。一个时间片要同时满足三个条件:教师空闲、该课程涉及的所有班级都空闲、教室空闲。这三张表必须一起查、一起写,少一个维度就会产出“老师空闲但教室撞了”这种半截课表。

teacher_time = {} # teacher_id -> [False] * 40 class_time = {} # class_id -> [False] * 40 room_time = {} # room_id -> [False] * 40 def is_free(course, slot): if teacher_time[course["teacher_id"]][slot]: return False for class_id in course["class_ids"]: if class_time[class_id][slot]: return False if room_time[course["room_id"]][slot]: return False return True def commit(course, slot): teacher_time[course["teacher_id"]][slot] = True for class_id in course["class_ids"]: class_time[class_id][slot] = True room_time[course["room_id"]][slot] = True

注意commit里不再做二次检查,这是一个有意的取舍:所有写入都走这个入口,不再单独维护“哪里能写、哪里不能写”的规则,性能更好,也不容易产生绕过检查的旁路。班级维度用class_ids列表展开,合班课的同一个时间片会同时占用多个班的同一格,这些班级本来就在同一个教室上课,不构成冲突。三张占用表的查询和更新都是 O(1),不要在排课主流程里用四重循环全表扫描,那是校验器做的事,不是搜索器做的事。

需要留意的是教室分配策略。如果room_id是建议值而非强制值,commit 之前要加一层判断:建议教室满了就换同一容量等级的备用教室。这个兜底逻辑写在first_available_slot里,课程表数据里不要存死教室编号,否则“教室冲突”会成为系统里最常见的报错,而实际上是排课逻辑设计不合理。

4.2 连排与单双周:课程表里最常见的两种业务约束

连排课,比如实验课、体育课、语言听力课,要求连续两个时间片紧挨着上。处理方式不是把两节课拆成独立课次去随机排,而是要把连续的两个时间片当作一个“超时间片”整体占用。拆分后随机排,极大概率会排成周三第1节和周五第5节,这种课表在教务那儿根本过不了审。

def try_assign_consecutive(course, slot): """尝试把连排课放进以 slot 起头的连续两个时间片""" if not is_double_free(course, slot): return False commit_double(course, slot) return True def is_double_free(course, first_slot): # 两个下标必须在同一天内,跨天相邻没有意义 if first_slot // 8 != (first_slot + 1) // 8: return False return is_free(course, first_slot) and is_free(course, first_slot + 1)

连排最容易忽略的是边界:周三第8节是当天最后一节,first_slot + 1会跨越到周四,必须拦截;同理周五最后两节也不能被当成连排目标。first_slot // 8判断两个下标是否在同一“天块”,这个除法在切块模型里是判断边界的最直接手段。如果时间片粒度是长短课混合,“相邻”的定义又不一样,需要单独比较起止时间。

单双周是另一个高频需求:一门课单周上一次、双周上一次。我不建议把模型扩展成“周数 × 天 × 节”的三维数组,那会把每周 40 个时间片膨胀成几百上千个,冲突检测和调试都变难。更常见的做法是在课次上挂一个week_mask字段,取值“单周”“双周”“每周”,搜索时根据当前周次过滤时间片:

def slot_in_week(slot, week_no, week_mask): if week_mask == "每周": return True if week_mask == "单周": return week_no % 2 == 1 return week_no % 2 == 0

单双周和连排还会叠加出现,比如“单周两节连排、双周不上”,这时的处理方式是先按周次过滤,再按连排逻辑判断两个相邻时间片是否同时可用。这两个约束叠加是排课系统里最容易出隐藏 bug 的地方,要做专门用例覆盖。

4.3 增量占用与回溯撤销:避免重复扫描全校课表

回溯算法里 commit 和 release 频繁交替,状态回退不能靠每次重算一遍全校课表,那样时间会爆炸。正确做法是 commit 时记录操作日志,release 时按日志反向擦除。因为回溯是严格的后进先出,撤销操作可以直接弹栈。

def commit_with_log(schedule, course, slot): schedule.append((course["course_id"], slot)) commit(course, slot) def release_with_log(schedule, course, slot): schedule.pop() # 后进先出,弹掉最后一条记录 teacher_time[course["teacher_id"]][slot] = False for class_id in course["class_ids"]: class_time[class_id][slot] = False room_time[course["room_id"]][slot] = False

schedule.pop()能直接拿到刚 commit 的那条记录,是因为回溯过程严格后进先出,中间不会交叉插入其它课次的提交。如果中间夹了别的 commit 就会出现错位,所以 release_with_log 必须和 commit_with_log 成对调用,中间不能做其它写入。这个“日志式回退”的思路也适合在贪心算法做“重新分配”:某门课排不进时,可以尝试把之前占用的课次往后挪,先释放再重排,比整表重跑乱序要可控得多。

遗传算法不这么干,它每次生成新一代个体时直接重建占用表,做全量适应度计算,不做增量回退。两种模式不要混用:回溯里建占用表、遗传里写回退日志,是最典型的架构混乱。我的经验是,搜索回退逻辑单独封装成一个类,对外暴露commit和release两个方法,内部再决定是日志式还是重建式,这样上层算法切换时不需要改业务代码。

5. 自动排课避坑指南:五个让排课结果直接翻车的细节

排课系统的算法模型讲得再漂亮,落地时总有一些不起眼的细节把整个结果带沟里。这一章写五个我实际踩过的坑,每一条都按“现象 → 原因 → 解决”来记,覆盖占用表、无解处理、随机性、权重调参和浅拷贝这几个高频翻车点。

5.1 坑一:占用表只更新不检查,冲突列表永远是空的

现象:程序跑完,课程表每个格子都填上了,数据完整,但人工抽查时发现某位老师同一节出现在两个班。进代码看,占用表确实写了,但整个流程里没有任何一处调is_free。

原因:最早版本为了省事,把“检查空闲”和“写入占用”写在同一个函数里,后来优化性能时把检查逻辑删了,写入逻辑还在。占用表被不断写入,但所有写入都没有经过验证,脏数据越积越多,最后产出一张表面完整、实际冲突密集的课表。

解决:所有写入路径强制走一个唯一入口,比如reserve(course, slot),内部顺序固定为assert is_free(course, slot)再commit(course, slot)。用断言做防守,冲突时立刻报错,而不是静默覆盖。这个入口设计还能顺便统计“某门课尝试了多少次才排上”,给算法调优提供数据。

5.2 坑二:无解输入导致无限回溯,程序直接卡死

现象:课程只有 20 门,回溯跑了几分钟还不返回,CPU 占满,界面像死机。检查数据,发现某位老师每周排了 45 节课,超过每周 40 个时间片的上限。

原因:硬约束集合自身矛盾,搜索空间再大也找不到解。回溯在无解时会遍历整棵搜索树,复杂度是时间片数量的课程次方,课程数和时间片数稍微多一点就会跑出来一个天文数字。

解决:预处理阶段先做容量校验——每个教师的周课时不超过每周时间片数,每个班级的总课时不超过每周时间片数。回溯里加步数上限,超过阈值立刻终止,输出“疑似无解”和当前最优前缀。宁可告诉用户数据有问题,也不能让程序挂在那里变成黑匣子。

5.3 坑三:随机算法同数据两次结果天差地别,结果全靠“玄学”

现象:遗传算法同一份数据跑两次,第一次结果很好,第二次全是冲突,于是开始怀疑是算法实现有 bug,反复查代码却查不出问题。

原因:初始化种群是纯随机的,随机种子没有固定。种群的起点和最终收敛结果强相关,遗传算法在高维离散空间里的初值敏感性特别高,不同种子走出来的路线完全可能是两个质量等级。

解决:固定random.seed(42),测试阶段用多个种子各跑几遍,看最优适应度的分布,而不是拿一次结果下结论。把贪心解注入第 0 代,让起点有一个确定性下限,随机种子的影响会被明显压缩。

5.4 坑四:软约束权重瞎调,优化半天质量反而更差

现象:把“上午优先”的权重从 10 调到 80,跑出来课表确实上午排满了,但出现个别老师上午四节连堂、下午全空,整体分布均匀度下降,还伴随少量硬冲突。

原因:软约束之间在争抢同一个时间片。放大某一项权重,会挤压其它软约束的空间;更严重的是,当权重跨度超过硬约束惩罚时,算法会为了讨好软约束而牺牲硬约束,直接产出不可用课表。

解决:硬约束惩罚固定为单个冲突 1000 分,软约束评分整体压缩到 ±100 区间,让软约束只能“择优”不能“压过”硬约束。调权重的正确姿势是整组按比例缩放,不是单独放大某一维。每改一次权重,跑一遍第 6 章写的校验器,对比课程分布统计再决定要不要保留这组参数。

5.5 坑五:把嵌套列表当课表用,深拷贝引用问题导致“改一发而动全身”

现象:想保存上一版本的课表做对比,把schedule赋值给另一个变量后再排,回头一看历史版本也被改了,两个版本指向同一份数据。

原因:schedule是嵌套结构,外层列表、内层字典,直接list.copy()只复制外层,内层字典还是同一份引用。排课中途修改某个属性时,历史版本和当前版本会同时被改。

解决:存储历史版本时用copy.deepcopy(schedule),或者把排课记录直接存成不可变元组(course_id, slot),元组不存在引用共享问题。调试阶段做版本对比时,把两份课表格式化成长字符串再比较,能快速看出差异集中在哪些班级和哪些时间块。

6. 排课结果的验证与比较:写一个课程表校验器和三种算法同台对比

6.1 课程表校验器:把冲突分类统计,不给模糊的“排完了”

排完课不校验等于白排。我会写一个独立的校验函数,输入课表,输出冲突分类列表,在每次排完和每次改完权重后都跑一遍,不通过就回滚。校验器只做一件事:把所有同时间片内的教师、班级、教室重合情况找出来。

def validate(schedule): errors = [] by_slot = {} for course in schedule: by_slot.setdefault(course["slot"], []).append(course) for slot, courses in by_slot.items(): teachers = [c["teacher_id"] for c in courses] rooms = [c["room_id"] for c in courses] if len(teachers) != len(set(teachers)): errors.append(f"教师冲突: {slot}") if len(rooms) != len(set(rooms)): errors.append(f"教室冲突: {slot}") class_ids = [] for c in courses: class_ids.extend(c["class_ids"]) duplicates = set([cid for cid in class_ids if class_ids.count(cid) > 1]) if duplicates: errors.append(f"班级冲突: {slot} {duplicates}") return errors

这个函数复杂度是 O(n²),但只在校验阶段跑一次,不是排课主流程,性能可以接受,换来的是冲突的直接可视化。它输出的错误列表就是你和教务老师沟通的语言:教师冲突、教室冲突、班级冲突,按时间片归类,能直接定位到哪个时段出问题。

6.2 同一份数据跑三种算法,用表格代替感觉

拿到一个新需求,我会用同一份数据把三种算法各跑一遍,对比三个指标:耗时、硬冲突数、软约束得分。这里的数字参考区间(数据规模为几十门课、不到十个班时),不同机器的绝对耗时差异很大,但相对趋势稳定:

指标贪心回溯遗传
耗时秒级分钟级(可设上限)分钟级
硬冲突偶有残留有解时为 0收敛后趋近 0
软约束质量中等高高
实现成本低中高

参数方面,我的长期默认组合是:种群大小 120、迭代 300 代、变异率 0.1、锦标赛规模 3。这几个参数在多数排课数据集上表现稳定,出现不理想结果时先检查硬约束建模,而不是急着调参。参数调整每次只改一个维度,记录修改前后的适应度和冲突数,才有依据判断改动是正向还是反向。

我现在的习惯是,接到排课需求先用贪心跑通全链路,再把同一份数据喂给回溯拿一个确定性的解,只有规模真的上来才上遗传算法;每次调整软约束权重,都把前一天的课表存档,跑一遍校验器确认没有引入新冲突再入库。这套流程救过我很多次,希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进