ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

C++实战:课程安排中的约束建模与搜索优化

C++实战:课程安排中的约束建模与搜索优化 1. 从排课这个老问题说起为什么值得用C认真做一遍课程安排这件事看起来是个教务处的活儿但真正动手写过的人都知道它本质上是一个带约束的组合优化问题。每学期开始前教务老师面对的是几十个班级、上百门课程、几十位教师、十几间教室还有同一个老师不能在同一时间上两门课同一班级不能在同一时段被排两门课教室容量要够某些课程必须连排这一堆硬性条件。手工排一次课往往要反复调整好几天改一个地方就牵一发而动全身。我最早接触这个问题是帮一个做教务系统的朋友处理排课模块。当时他们用的是最朴素的贪心策略——按课程顺序一门门往时间表里塞能塞就塞塞不下就往后挪。结果排出来的课表虽然能用但质量很差有的老师一周五天全被排满有的老师几乎没课有的班级上午四节连堂下午全空。教务老师看了直摇头说这表还不如自己手排的。这件事让我意识到课程安排不是能不能排出来的问题而是排得好不好的问题。而排得好不好恰恰是算法设计要解决的核心。用C来做这件事优势非常明显执行效率高能在可接受的时间内跑完大量候选方案的评估对数据结构的控制精细可以用位运算、邻接表、优先队列这些手段把约束检查做到极致而且C的模板和STL让算法原型的迭代速度并不慢。这篇文章面向的是有一定C基础、想拿课程安排这个真实场景练手算法设计的读者。我会从问题建模讲起把约束怎么表达、冲突怎么检测、搜索怎么剪枝、局部搜索怎么跳出局部最优这些环节一层层拆开配上可直接编译运行的代码骨架。读完你不仅能得到一份能跑的排课程序更重要的是能理解把现实约束翻译成算法语言这套思维方式——这套东西换个场景比如考试安排、会议室调度、生产排程照样能用。2. 把排课翻译成算法语言约束建模的三种表达方式2.1 先分清硬约束和软约束动手写代码之前必须先把约束分类。这是整个项目里最关键的一步分类错了后面算法怎么调都别扭。硬约束是绝对不能违反的违反了方案直接作废。典型的有同一教师在同一时间段只能上一门课同一班级在同一时间段只能上一门课同一教室在同一时间段只能容纳一门课教室容量必须大于等于课程选课人数教师在其不可用时间段比如出差、开会不能排课软约束是可以违反的但违反得越少越好它们决定了课表的舒适度同一教师的课程尽量分散在不同天避免一天连上四节同一班级的课程尽量均匀分布避免某天过满某天过空尽量把重要课程排在上午尽量减少教室切换带来的奔波硬约束用可行性判断处理软约束用目标函数处理。这个区分直接决定了后面搜索算法的结构硬约束是门槛软约束是打分。2.2 用三维数组还是用冲突表最直观的建模方式是三维布尔数组occupied[time][room][teacher]之类。但排课问题的维度多教师、班级、教室、时间四个维度两两之间都可能有冲突全用多维数组会浪费大量空间而且检查冲突时要遍历多个数组。更实用的做法是为每一类资源维护一张时间占用表。以教师为例用一个二维结构teacherBusy[teacherId]存该教师已被占用的时间段集合。时间段可以用位掩码表示——假设一周排5天、每天4个大节一共20个时间段那么一个32位整数就能表示一位教师的全部占用情况。判断冲突只需要一次按位与// 检查教师 t 在时间段掩码 slotMask 上是否空闲 bool isTeacherFree(int t, uint32_t slotMask) { return (teacherBusy[t] slotMask) 0; }这种位运算的写法比遍历数组快了不止一个数量级。当搜索算法要评估成千上万个候选方案时这点性能差距会被放大到秒级甚至分钟级。2.3 课程、班级、教师的数据结构设计我习惯把一门待排课程抽象成一个结构体把它的所有属性打包在一起struct Course { int id; int teacherId; int classId; int studentCount; int weeklySessions; // 每周需要排几次 bool preferMorning; // 是否倾向上午 std::vectorint allowedRooms; // 可用教室列表 };这里有个容易踩的坑weeklySessions大于1的课程它的多次课之间往往有隐含约束。比如实验课要求连排两节或者同一门课两次之间至少隔一天。这些约束如果不在建模阶段想清楚后面排出来的课表会出现周一上午上完周一下午接着上这种反人类的安排。我的建议是给课程加一个sessionGap字段表示两次课之间最少间隔几个时间段在冲突检测时一并检查。教室这边除了容量还要考虑类型。机房、实验室、普通教室的可用范围不同。用一个roomType字段标记排课时先按类型过滤候选教室再按容量筛选能大幅减少无效尝试。3. 冲突检测排课算法里最该被优化的热路径3.1 为什么冲突检测是性能瓶颈不管你后面用回溯、遗传算法还是模拟退火有一个操作会被调用无数次给定一个把课程c放到时间t、教室r的候选动作判断它是否与现有安排冲突。这个判断就是冲突检测。在一个中等规模的排课问题里比如50门课、20个时间段、15间教室搜索空间是 (20×15)^50 这个量级当然实际会被约束大幅剪枝但冲突检测被调用几十万次是家常便饭。如果每次检测都要遍历所有已排课程复杂度是 O(n)整体就会变成 O(n²) 甚至更糟。所以冲突检测必须做成 O(1) 或接近 O(1)。3.2 位掩码方案的具体实现前面提到的位掩码在这里就派上大用场了。我们为教师、班级、教室各维护一个占用掩码数组std::vectoruint32_t teacherBusy; // 每个教师一个掩码 std::vectoruint32_t classBusy; // 每个班级一个掩码 std::vectoruint32_t roomBusy; // 每间教室一个掩码当要把课程 c 排到时间段 t对应掩码1u t时检测逻辑是uint32_t bit 1u t; bool conflict (teacherBusy[c.teacherId] bit) || (classBusy[c.classId] bit) || (roomBusy[r] bit);三次按位与一次或运算常数时间完成。排入时把对应位置1撤销时清0天然支持回溯。这里有个细节要注意如果一周的时间段超过32个比如排7天、每天6节就是42个uint32_t就不够用了。这时候要么换成uint64_t支持64个时间段一般够用要么用std::bitset64。我实测下来uint64_t配合位运算的性能最好编译器能把它优化成单条指令。3.3 连排和间隔约束的额外检查位掩码解决的是同一时间点的冲突但连排和间隔约束涉及时间点之间的关系需要额外处理。连排两节本质是要求时间段 t 和 t1 同时可用且这两个时间段在物理上是相邻的。这里要小心跨天的情况周五最后一节和周一第一节在编号上可能相邻但物理上不相邻。解决办法是给每个时间段预计算一个后继时间段表只有真正相邻的才建立映射。间隔约束则要检查 t 和已排的同一课程的其他时间段之间的距离。如果课程 c 已经排在时间段 t0现在要排到 t1要求间隔至少 g那么需要abs(t1 - t0) g。这个检查是 O(已排次数)但每门课的周次数通常很小1到3次所以开销可以忽略。提示连排约束在建模时最好拆成虚拟课程。比如一门需要连排两节的课可以视为一个占用两个连续时间段的整体单元这样冲突检测时只需要检查这个单元覆盖的所有时间段逻辑更统一。4. 搜索策略选型从回溯到局部搜索的取舍4.1 回溯法能解决多大问题回溯是最容易想到的方案按课程顺序每门课尝试所有可能的时间-教室组合遇到冲突就换下一个全部试完还不行就回退到上一门课。这个方案在小规模问题上表现不错。我试过30门课、15个时间段、10间教室的规模用带回溯的深度优先搜索配合合理的课程排序先排约束最紧的课比如周次数多、可用教室少的通常几秒内能出解。但规模一上去就不行了。50门课的时候回溯的搜索树会爆炸即使有剪枝也可能跑几分钟都出不来解。根本原因是回溯法没有全局观它只保证当前这一步不冲突不保证整体能排完。前面排得太随意后面就会走进死胡同。4.2 贪心局部搜索的实用组合工业界更常用的方案是两阶段法先用贪心快速构造一个可行解哪怕质量差再用局部搜索不断优化。贪心阶段的关键是课程排序。我的经验是按约束紧度降序排先排周次数多的、可用教室少的、教师时间窗口窄的。这样把难排的先安置好后面简单的课随便塞都有位置。局部搜索阶段核心操作是交换和移动移动把一门课从当前(时间,教室)挪到另一个位置交换把两门课的时间-教室对调每次操作后重新计算目标函数软约束违反程度如果变好就接受变差则以一定概率接受模拟退火或直接拒绝爬山法。double evaluate(const Schedule s) { double cost 0; cost 10 * countTeacherSameDayOverload(s); // 教师一天课太多 cost 5 * countClassGapTooLarge(s); // 班级课程分布不均 cost 3 * countMorningPreferenceViolation(s); // 重要课没排上午 return cost; }权重是我根据实际反馈调的教师连堂的抱怨最多所以权重最高上午偏好相对次要权重最低。这个权重没有标准答案得根据使用方的反馈反复调。4.3 模拟退火里的温度参数怎么定模拟退火比爬山法强的地方在于能跳出局部最优。但温度参数设不好效果还不如爬山。我的经验是初始温度设为随机两个解的目标函数差值的平均值的量级让初始接受率在0.8左右。降温系数用0.95到0.99之间每轮迭代降温一次。终止条件用连续若干轮没有改进或者温度低于阈值。有个实操技巧把搜索过程分成多个独立的退火轮次每轮从不同的随机初始解出发最后取所有轮次里最好的解。这比单轮跑很久更有效因为单轮容易在某个局部区域打转。我一般跑10到20轮每轮几千次迭代总时间控制在几秒内。注意局部搜索的邻域操作要保证可行性。移动或交换后如果违反了硬约束这个操作必须直接拒绝不能进入目标函数评估。否则搜索会在不可行解之间乱跳浪费时间。5. 目标函数设计什么样的课表才算好5.1 软约束的量化方式好课表是个模糊概念算法需要把它变成数字。我通常从这几个维度量化软约束维度量化方式建议权重教师单日课时上限超过阈值的节数累加10班级单日课时上限超过阈值的节数累加8课程分布均匀度各天课时数的方差5上午偏好满足度未满足偏好的课程数3教室切换次数相邻两节换教室的次数2权重不是拍脑袋定的而是通过让教务老师对两份课表排序来反推。简单说如果老师觉得A表明显比B表好那A的目标函数值就应该明显低于B。多收集几组对比就能把权重调到一个合理范围。5.2 避免目标函数被钻空子设计目标函数时最容易犯的错是让算法找到一种作弊方式来降低代价但实际效果很差。举个例子如果只惩罚教师单日课时超过4节算法可能把所有课都排成每天正好4节看起来没超标但老师天天满负荷累得够呛。解决办法是加一个教师单日课时方差项鼓励课时在几天之间均匀分布而不是卡在阈值边缘。再比如如果只奖励重要课程排上午算法可能把所有课都标成重要课全塞上午导致上午爆满。所以要给上午的课程总数设上限超过就惩罚。这类钻空子问题只有实际跑出来看结果才能发现。我的习惯是每调一次目标函数就把排出来的课表打印出来人工扫一遍看看有没有明显不合理的模式。5.3 多目标之间的权衡软约束之间经常互相矛盾。比如教师课程分散和班级课程集中就可能冲突老师希望课分散但班级可能希望上午集中上完下午休息。处理这种矛盾有两种思路。一种是加权求和把多个目标合成一个数简单但权重难调。另一种是帕累托前沿保留一组互不支配的解让使用者自己选。后者更灵活但实现复杂而且给使用者太多选择反而增加负担。我的建议是先用加权求和跑通把权重调到一个大多数情况都满意的设置。如果使用方对某些维度特别敏感再考虑把这些维度做成可配置参数让他们自己调。6. 实测中那些文档不会告诉你的坑6.1 数据不规范比算法难搞真正让我头疼的从来不是算法而是输入数据。教师不可用时间填得五花八门有的写周三下午有的写13:00-15:00有的干脆写尽量别排周三。教室容量有的是座位数有的是可容纳班级数。课程周次数有的写2有的写每周两次。这些脏数据必须在进入算法之前清洗干净。我的做法是写一个独立的预处理模块把所有输入统一成标准格式遇到无法解析的字段就报错并指出具体位置而不是默默用默认值。默默用默认值的后果是排出来的课表看着正常但某个老师的周三下午不可用被忽略了实际使用时才发现问题。6.2 无解时的诊断比求解更重要排课问题不一定有解。当硬约束太紧时可能根本排不出可行方案。这时候算法如果只是返回无解使用者完全不知道该怎么办。更好的做法是做不可行性分析找出是哪几个约束组合导致了矛盾。比如教师A在周三只有上午可用但周三上午的机房全被占了而教师A的课又必须用机房这种链条一旦定位到使用者就知道该放宽哪个约束。实现上可以在回溯失败时记录最后冲突的约束集合或者在局部搜索里统计哪些硬约束被违反得最频繁。这些信息比一个冷冰冰的无解有用得多。6.3 性能调优的优先级很多人一上来就想着用多线程、GPU加速。但排课问题的性能瓶颈往往不在计算本身而在搜索策略。我做过对比同样的硬件把课程排序从随机改成按约束紧度降序求解时间从几十秒降到一两秒。把冲突检测从遍历数组改成位掩码又降了一个数量级。这两项优化加起来比开四个线程的收益大得多。所以性能调优的顺序应该是先优化数据结构和冲突检测再优化搜索顺序和剪枝最后才考虑并行化。并行化在局部搜索阶段效果最好——多个线程跑不同的退火轮次互不干扰最后合并结果。6.4 结果的可解释性教务老师拿到课表后第一反应往往是为什么这门课排在这个时间。如果算法给不出理由他们就不信任结果宁愿自己手排。所以我在输出课表的同时会附上一份排课说明每门课为什么排在这个位置是因为教师时间限制、教室容量限制还是为了满足某个软约束。这份说明不需要多复杂一两句话就行但能极大提升使用者的接受度。7. 从能跑到好用几个可以继续深挖的方向如果你已经把基础版本跑通了下面这几个方向可以让程序从能跑进化到好用。增量排课。实际使用中课表不是一次排完就固定的。中途会有教师请假、教室维修、课程调整。每次都从头重排代价太大更好的做法是支持增量修改只重排受影响的部分保持其余部分不变。这需要把排课状态持久化并设计一套局部重优化的逻辑。多学期联动。有些课程有先修关系这学期没排上会影响下学期。把多个学期的排课放在一起考虑虽然问题规模变大但能避免这学期排了但下学期冲突的尴尬。与真实教务系统对接。把排课程序做成一个独立服务通过标准接口读取课程、教师、教室数据输出排课结果。这样它就能嵌入现有的教务流程而不是一个孤立的工具。可视化。把课表用网格图展示出来不同颜色代表不同课程类型冲突位置高亮。可视化不仅能帮使用者快速理解结果也能帮开发者调试——很多逻辑错误一眼就能从图上看出来。我在实际项目里的体会是算法本身可能只占整个工作量的三成剩下七成都在处理数据、对接接口、优化交互。但正是这三成的算法决定了整个系统能不能解决核心问题。把课程安排这个场景吃透你对约束建模、搜索策略、目标函数设计的理解会上一个台阶这些能力迁移到任何调度类问题上都管用。
RELATED READING

延伸阅读

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