尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

OR-Tools CP-SAT求解排班问题:Java建模实战与调优

OR-Tools CP-SAT求解排班问题:Java建模实战与调优 简介基于OR-Tools Java API的人员排班建模求解示例面向需要处理线性/整数规划问题的Java开发者与运筹优化学习者。资源围绕“人员排班”这一典型约束满足场景演示如何通过布尔变量表示员工排班、添加工作时长上限、连续休息、技能匹配等约束并调用求解器获得可行且优化的排班方案。包内共4个文件含1个Java源文件EmployeeSchedulingProblem.java和3个CSV数据文件班次、需求人数及需求人数长列表整体仅3KB但内容完整。Java代码可直接运行CSV数据便于替换场景进行复现或扩展。已有964人学习浏览适合希望快速理解OR-Tools建模思路并动手实践的人群。通过该示例可以掌握Model创建、LinearConstraint/IntervalVariable约束设置、求解参数配置与结果输出等关键步骤为后续迁移到更复杂的员工排班、资源调度项目提供可参考的模板与排错思路。1. 问题场景与选型思路1.1 排班问题为什么那么难搞做排班系统这件事很多团队的第一反应是写一堆 if-else 硬编码规则再嵌套个双层循环去暴力匹配。小规模场景下确实能跑但一旦涉及几十个员工、多班次、连续天数分区约束、个人偏好代码复杂度直接指数爆炸。我这次遇到的需求是一家中型呼叫中心运营时间是早上8点到晚上24点拆成早班、中班、晚班三个班次再加一个休息日一共四个状态。约束包括每个班次每天至少要有固定人数覆盖、员工每周最多上五天班、晚班结束后不能直接接早班、员工对班次有偏好得分。这还只是基础规则加上请休假、技能等级差异、特殊岗位资质等条件手工排班几乎是不可能任务。排班问题本质上是一个组合优化问题学名叫做护士排班问题Nurse Scheduling Problem属于经典的 NP-hard 问题族。暴力枚举十个人七天就有 4 的 70 次方种组合所以必须靠约束求解器来做。约束求解的思路和传统算法最大的区别在于你不是去搜索一个可行解而是先声明变量和约束条件让求解器自己去剪枝、传播、搜索。这就像是你告诉一个管家房间需要哪些家具、家具不能碰撞、颜色要搭配管家自己安排摆放位置而不需要你亲自动手一件件挪。1.2 为什么选择 or-tools 的 Java API选工具的时候我也纠结过 OptaPlanner、Choco、Gecode 这些方案。OptaPlanner 是纯 Java 实现基于元启发式算法更适合做资源调度优化但它的学习曲线也不低而且内部还是基于启发式搜索对于约束传播的数学保证弱一些。Choco 和 Gecode 都是纯粹的约束编程库学术味更浓文档对工程实践不太友好。or-tools 是谷歌开源的运筹优化套件最核心的卖点是内置了 CP-SAT 求解器。CP-SAT 融合了约束编程和 SAT 求解技术对布尔变量、整数变量的约束问题支持极好而且支持多线程并行搜索在排班这类高约束密度场景下表现非常稳定。Java API 封装得也比较干净虽然底层是 C 写的但通过 JNI 接口调用工程接入成本并不高。Maven 直接拉依赖就能跑很多常见的坑社区也都有踩过留下的解决方案遇到问题不至于束手无策。2. 排班需求梳理与模型抽象2.1 从业务规则到数学约束的翻译动手写代码之前最重要的一步是把业务规则翻译成数学模型。很多排班项目做不下去就是因为业务方和开发对规则的理解不一致或者说需求本身就有歧义。我这次先把规则逐条列出来再逐条转成约束表达式。拿这次的项目举例核心规则拆解如下业务规则数学表达约束类型每个班次每天至少N人sum(x[e][d][s]) N覆盖约束每人每天最多一个班次sum(x[e][d][s]) 1排他约束每周最多上5天班sum(x[e][d][s]) 5上限约束晚班后不能上早班x[e][d][晚] x[e][d1][早] 1关联约束员工偏好最大化obj weight * x[e][d][s]目标函数注意第三条规则我需要说明一下每周最多上五天的计算口径在 CN 语境下是包含周六周日的自然周而不是滚动窗口。业务方如果规则不写清楚到实现阶段很容易出现结果不符合预期的情况。所以我建议在建模之前把这种容易产生歧义的规则点和业务方确认清楚形成一个文字版的规则清单后续测试验证也以这个清单为准。2.2 决策变量设计与状态编码变量设计是建模的灵魂。我一开始想用整数变量 x[e][d] 0/1/2/3 来表示休息/早/中/晚但这在后续添加约束时特别麻烦。比如晚班后不能上早班如果要判断 x[e][d] 3 且 x[e][d1] 1你需要引入额外的辅助变量或者用复杂的线性化技巧处理起来非常痛苦。更好的做法是为每个员工、每天、每个班次定义一个独立的布尔变量也就是三维数组 x[e][d][s] ∈ {0, 1}。这样每条业务规则都能直观地写成线性约束排查问题也方便。虽然变量数量变多了员工数 × 天数 × 班次数但对 CP-SAT 求解器来说再轻松不过了。以十个人七天为例变量总数是 10 × 7 × 4 280 个这个规模在 CP-SAT 面前就是开胃菜。决策变量定义 x[e][d][s] 1, 表示员工e在第d天被分配班次s x[e][d][s] 0, 表示未分配 其中 e ∈ {0..E-1}, d ∈ {0..D-1}, s ∈ {0..S-1} s 0 表示休息日s 1 早班s 2 中班s 3 晚班2.3 目标函数的多目标权衡排班问题通常不是单纯找一个可行解而是需要一个尽量好的解。这里的好如何衡量我这次用了两个维度的目标一是最大化员工的偏好满意度二是尽量均衡地分配晚班次数避免某个员工连续被排太多晚班。CP-SAT 求解器本身支持多个目标线性加权。简单做法是把两个目标处理成线性组合总目标 w1 × 总偏好得分 w2 × (−(|晚班次数 − 平均晚班次数|)的线性化)。不过要注意绝对值是非线性的需要引入辅助变量做线性化。还有一种工程上更省事的方案把偏好满足作为主目标求解一次再把晚班均衡作为次目标通过两次求解逐步逼近。我这里最终采用了线性加权的方式因为排班规模不大权值调到偏好 0.7、均衡 0.3 就能得到一个业务方认可的结果。权值的调整是个玄学多跑几组数据比对效果比坐着推导最优权值靠谱得多。3. 核心建模实现Java API 实战3.1 Maven 依赖与环境初始化or-tools 的 Java 版本通过 Maven 直接引入很方便具体依赖坐标如下我用的版本是 9.8.9966截至写文章时这个版本比较稳定dependency groupIdcom.google.ortools/groupId artifactIdortools-java/artifactId version9.8.9966/version /dependency引入依赖后代码里第一件事是加载 JNI 原生库。这一步是新手最常见的坑我见过很多人卡在这里import com.google.ortools.Loader; public class ShiftScheduling { public static void main(String[] args) { // 加载 or-tools 原生库必须在创建模型之前调用 Loader.loadNativeLibraries(); // 后续代码... } }Loader.loadNativeLibraries() 会自动从 jar 包里解压并加载对应平台的原生库文件。如果你使用的是 Linux 服务器部署需要确保有 glibc 2.17 以上的版本Windows 上通常不会有什么问题macOS 需要注意架构是 x86_64 还是 arm64不同版本需要匹配对应依赖。3.2 构建 CP-SAT 模型建模的核心对象是 CpModel。我先定义基础数据然后创建布尔变量矩阵。import com.google.ortools.sat.CpModel; import com.google.ortools.sat.CpSolver; import com.google.ortools.sat.CpSolverStatus; import com.google.ortools.sat.IntVar; import com.google.ortools.sat.LinearExpr; import com.google.ortools.sat.LinearExprBuilder; public class ShiftScheduling { // 基础数据 static final int NUM_EMPLOYEES 10; static final int NUM_DAYS 7; static final int NUM_SHIFTS 4; // 0:休 1:早 2:中 3:晚 // 每个班次每天最少需求人数 static final int[][] REQUIRED { {0, 2, 2, 1}, // 周一 {0, 2, 2, 1}, // 周二 {0, 2, 2, 1}, // 周三 {0, 2, 2, 2}, // 周四 {0, 2, 2, 2}, // 周五 {0, 1, 2, 2}, // 周六 {0, 1, 2, 2}, // 周日 }; // REQUIRED[d][s] 表示第 d 天班次 s 的需求人数 // 员工偏好得分employeePref[e][d][s] 越大表示越偏好 static int[][][] EMPLOYEE_PREF new int[NUM_EMPLOYEES][NUM_DAYS][NUM_SHIFTS]; public static void main(String[] args) { Loader.loadNativeLibraries(); CpModel model new CpModel(); // 1. 创建决策变量 x[e][d][s] IntVar[][][] x new IntVar[NUM_EMPLOYEES][NUM_DAYS][NUM_SHIFTS]; for (int e 0; e NUM_EMPLOYEES; e) { for (int d 0; d NUM_DAYS; d) { for (int s 0; s NUM_SHIFTS; s) { x[e][d][s] model.newBoolVar(x_ e _ d _ s); } } } // 2. 约束每天每个班次非休息日人数需求 for (int d 0; d NUM_DAYS; d) { for (int s 1; s NUM_SHIFTS; s) { LinearExprBuilder expr LinearExpr.newBuilder(); for (int e 0; e NUM_EMPLOYEES; e) { expr.add(x[e][d][s]); } model.addEquality(expr, REQUIRED[d][s]); } } // 3. 约束每人每天最多一个班次 for (int e 0; e NUM_EMPLOYEES; e) { for (int d 0; d NUM_DAYS; d) { LinearExprBuilder expr LinearExpr.newBuilder(); for (int s 0; s NUM_SHIFTS; s) { expr.add(x[e][d][s]); } model.addLessOrEqual(expr, 1); } } // 4. 约束每周最多工作5天 for (int e 0; e NUM_EMPLOYEES; e) { LinearExprBuilder expr LinearExpr.newBuilder(); for (int d 0; d NUM_DAYS; d) { for (int s 1; s NUM_SHIFTS; s) { expr.add(x[e][d][s]); } } model.addLessOrEqual(expr, 5); } // 5. 约束晚班(3)后不能接早班(1) for (int e 0; e NUM_EMPLOYEES; e) { for (int d 0; d NUM_DAYS - 1; d) { LinearExprBuilder expr LinearExpr.newBuilder(); expr.add(x[e][d][3]); expr.add(x[e][d 1][1]); model.addLessOrEqual(expr, 1); } } // 6. 目标函数最大化偏好得分 LinearExprBuilder objective LinearExpr.newBuilder(); for (int e 0; e NUM_EMPLOYEES; e) { for (int d 0; d NUM_DAYS; d) { for (int s 0; s NUM_SHIFTS; s) { objective.addTerm(x[e][d][s], EMPLOYEE_PREF[e][d][s]); } } } model.maximize(objective); // 7. 求解 CpSolver solver new CpSolver(); CpSolverStatus status solver.solve(model); System.out.println(求解状态: status); if (status CpSolverStatus.OPTIMAL || status CpSolverStatus.FEASIBLE) { System.out.println(最优目标值: solver.objectiveValue()); // 输出排班表 for (int d 0; d NUM_DAYS; d) { System.out.print(第 (d 1) 天: ); for (int e 0; e NUM_EMPLOYEES; e) { for (int s 0; s NUM_SHIFTS; s) { if (solver.booleanValue(x[e][d][s])) { System.out.print(员工 e shiftName(s) ); } } } System.out.println(); } } } private static String shiftName(int s) { switch (s) { case 0: return 休; case 1: return 早; case 2: return 中; case 3: return 晚; default: return ?; } } }3.3 每个约束的意图与易错点这段代码看起来结构清晰但有几个易错点值得单独拎出来提醒一下。第一约束每天每个班次人数需求我用了 addEquality 而不是 addGreaterOrEqual。这是业务要求的需求人数是硬性指标多排也不行。如果业务方其实允许至少N人那就应该用 addGreaterOrEqual排多了不报错但会增加员工工作负担。这里的取舍需要和业务方提前对齐。第二每人每天最多一个班次约束中我包含了休息日 s 0。因为 x[e][d][0] 也是变量如果不加进来求解器可能同时选中 s 0 和 s 2导致数据自相矛盾。这个坑我一开始踩过排出来的班次表里有人既是休息又上了中班打印的时候看起来就怪怪的。第三晚班后不能接早班约束我用的是相邻两天的变量同时为 1 时和必须小于等于 1。这个表达式背后的逻辑是x[e][d][3] 和 x[e][d1][1] 同时为 1说明员工第 d 天晚班、第 d1 天早班这个组合非法。所以两者之和只能取 0 或 1不能取 2。3.4 求解参数与结果调试CP-SAT 求解器默认设置就能处理大多数中小规模的排班问题。但当约束复杂、变量暴涨时可以手动调整一些求解参数来获得更好的性能。// 设置求解时间上限单位秒防止某些问题跑太久 solver.getParameters().setMaxTimeInSeconds(60.0); // 启用多线程求解 solver.getParameters().setNumSearchWorkers(4); // 记录搜索进度日志 solver.getParameters().setLogSearchProgress(true);参数解释一下setMaxTimeInSeconds 是硬性时间上限。CP-SAT 求解器是找到最优解就停的思路但一旦遇到难以求解的模型它会一直搜索下去如果不设时间限制就有可能在容器环境里跑挂。求实时务场景并不一定非要数学最优解很多时候业务方要的只是一个足够好的解。所以这里我建议设置时间上限比如 60 秒或 120 秒配合已找到可行解就返回的策略可以在可控时间内拿到可用的排班结果。setNumSearchWorkers 用来开启并行搜索。CP-SAT 在并行场景下扩展性不错但要注意线程数不是越多越好设置为机器 CPU 核数的 1~2 倍通常比较合适太多线程反而因为争抢锁导致性能下降。4. 常见问题与排查技巧实录4.1 模型不可行求解器返回 INFEASIBLE模型报 INFEASIBLE 是所有排班项目都会遇到的噩梦。我这次调试过程中也不只一次踩进去。通常原因是约束过强比如人员总数不够覆盖每日需求或者同时要求全员连休两天且每天每个班次至少2人这种本身就矛盾的条件。排查思路是有套路的。第一步打开求解器的日志输出看它停在哪里第二步逐个注释掉约束分段验证哪些约束组合会导致无解第三步如果发现是覆盖约束过强可以检查所有员工可提供的总人天数是否大于等于每天需求之和。比如 10 个人每周最多 5 天总可用人天 10 × 5 50而本周需求 每天需求小时和 3334444 25看起来没问题。但如果把每周最多工作天数改成 3总人天变成 30需求还是 25表面够但考虑到部分班次的连续性约束可能就布不开了。最终我遇到的情况是晚班后不能接早班和周五周六晚班需求高组合在一起导致某些员工被卡死。解决办法是增加一名兼职员工或者放松连续休息的要求。提示遇到 INFEASIBLE 时不要盲目改代码。先把所有约束列出来用纸面上算一遍人天供需平衡的方式排查大部分问题在推理阶段就能暴露。4.2 求解速度慢从约束强度找突破口如果模型是可行的但求解器长时间跑不出结果常见原因有两个目标函数不够紧凑或者约束的传播强度不足。例如我最初只对每周最多工作 5 天做了单纯的上限约束但没有约束最少工作天数。这导致求解器可能先找到很多员工只工作 2~3 天的可行解但它又不满足覆盖需求于是反复回溯。后来我增加了一个每周最少工作 4 天的下限约束把搜索空间大幅度剪枝求解速度立刻上来了。技巧是排班问题的约束最好是上下限双约束不要只给一个方向的限制。凡是业务上合理的双向限制都尽量加到模型里让求解器少走弯路。另一个提速技巧是给关键约束设置高优先级。CP-SAT 支持设置约束的暗示层次hint虽然对求解速度提升因人而异但配合 setNumSearchWorkers 和 maxTimeInSeconds中小规模排班通常在几秒到几十秒内就能给出可用的最优解。4.3 原生库加载失败Linux 环境下的排查记录or-tools Java 在 Linux 服务器上部署时经常出现 UnsatisfiedLinkError 或 NoClassDefFoundError。我的排查路径如下第一步确认 Maven 依赖是否完整拉取尤其是 ortools-java 的 jar 包是否存在于本地仓库。第二步确认操作系统的 glibc 版本是否满足要求。or-tools 官方要求 glibc 2.17低版本的 CentOS 6.x 会直接加载失败。可以用 ldd --version 查看版本号。第三步确认 CPU 架构。aarch64 架构需要用对应 ARM 版本的 jar 包x86 环境不要强行加载 ARM 版本。第四步如果确认都没问题可以尝试在代码里显式添加 java.library.path 来指定原生库路径。其实加载失败最根本的原因在于 JNI 库和系统环境不兼容。我遇到过一次是因为公司内部用了一系列基础镜像镜像是精简版缺少某些系统库文件后来在 Dockerfile 里补装了 libgomp1 才解决。如果是容器部署建议先在镜像里跑一个小 demo 验证环境通不通别把大模型直接丢进去。5. 再往深处走一步排班模型做完之后后面还有几个可以继续扩展的方向。第一个方向是软约束加权。我在上面的实现里直接把所有需求都硬约束化了但真实业务中员工偏好周末休息这类诉求往往是软性的硬约束会造成无解或解质量很差。可以考虑把偏好实现为软约束目标函数里加一个惩罚项违反偏好的时候扣分让求解器在硬约束满足的前提下尽可能满足软约束。第二个方向是调班与次优方案推荐。求解器输出的是静态排班表但实际运营中经常有人请假、突发换班。可以做一套滚动重排的机制每次有变动时只对受影响的窗口重排保留大部分原始排班不变这样业务方更容易接受。第三个方向是可视化呈现。排班表如果只输出到控制台或者 Excel 是给技术自己看的业务方拿着不方便。做一个日历视图把员工、班次、休息日用颜色块展示出来运营调整效率会高很多。我当时是把结果输出成 JSON 让前端渲染比直接打印表格好用得多。我在实际项目里还发现一个很重要的点排班问题的求解结果不是终点最终面向的还是人。哪怕模型解算得很好只要业务方觉得某人连续上了三个晚班不合理这个直觉过不了关系统就很难推广。所以建模之前多花一个小时和业务方聊清楚他们心里那些只能意会的默认规则比在代码里调约束调一天有用得多。本文还有配套的精品资源点击获取
返回列表