简介基于OR-Tools的Java API解决人员排班问题的完整参考实现面向需要掌握整数规划建模与求解器调用的Java开发者以及运筹优化领域的初学者。压缩包共4个文件由1个Java源码和3个CSV数据文件组成整体仅3KB轻量且便于快速上手。Java源码完整呈现了从创建Model、定义布尔/整数决策变量、添加LinearConstraint约束到调用求解器获得排班方案的过程并包含结果解析与可验证性检查的思路三个CSV文件分别存储需求人数、班次列表和长列表数据为模型提供可直接运行的输入。目前已有978人学习下载。通过研读该示例读者可以掌握员工工作时长上限、连续工作后必休一天、技能匹配等约束的建模技巧理解IntervalVariable的用法并能够将这套代码框架迁移到更复杂的轮班或资源调度场景是入门OR-Tools Java API的高价值实践参考。1. 用 or-tools 做人员排班瓶颈在建模不在求解——这是给 Java 后端团队的落地笔记人员排班这个需求几乎每个规模化团队都会撞上员工技能等级不同、固定班次与轮换班次要混排、法定工时与加班上限卡得很死、突发请假又要即时补位。手写规则的人会越改越乱外包一个排班系统又贵又慢最后多数团队落到了 or-tools 上。我观察到一个反直觉的现象用 or-tools 按 Java API 做人员排班真正卡住进度的几乎都不是求解器性能而是建模阶段——业务规则翻译成约束模型时少写了一条、写成了软约束、或者把目标函数定成了无法收敛的形式。本文会把排班问题拆成「变量设计 → 硬软约束 → 目标函数 → Java API 建模 → 求解参数 → 生产验收」这条完整链路代码都是可跑的 Java 实现适合正在选型、或者已经在调试但结果总不对的开发者和实施顾问。2. 先把业务规则翻译成约束模型排班问题的变量设计与硬软约束2.1 变量设计为什么排班建模几乎只用 BoolVar人员排班在 or-tools CP-SAT 里建模绝大多数情况不需要整数变量也不需要区间变量核心就是一张二维布尔表x[员工][时间槽]值为 1 表示该员工在这个时间槽当班0 表示不在。这个设计的理由很直接排班这个动作在业务里就是「上不上」的离散选择布尔变量可以最自然地表达「某天某时段一个人至少被分一个班次」「某员工当天不能同时上早班和晚班」这类互斥逻辑而整数变量的取值无法直接表达互斥。时间槽的粒度是整个模型性能的分水岭。我一般按班次切而不是按小时切——把一天切成早班、中班、晚班三个槽或者按业务里的实际班型比如行政班、值班、夜班来切。按小时切会让变量数量膨胀到原来的 8 倍约束数量也随之翻倍求解时间可能从秒级变成分钟级而业务上根本不关心某个员工是 8 点还是 9 点打卡。先想清楚业务的最小调度粒度再定变量维度这是建模的第一个决策点。变量规模可以粗略估算员工数 × 时间槽数。一个 30 人、每天 3 个班次、排 30 天的模型变量数是 30×3×30 2700 个布尔变量CP-SAT 处理这种规模非常轻松。但注意如果加入「技能等级」「可选的岗位」这些维度变量维度会变成员工×岗位×时间槽这才是模型爆炸的主要来源。常见的做法是不把技能等级建模进变量而是先按技能把员工分组用分组建模跨组的替代关系用约束表达。2.2 硬约束与软约束把「必须」和「尽量」分开建模业务规则里天然分成两类「违反就不能发班表」和「违反了会被投诉但也能接受」。这两类必须分开建否则模型会在一个不重要的软目标上浪费大量搜索时间甚至因为把软约束写成硬约束而直接报无解。硬约束通常有这几类每个时间槽的在岗人数下限和上限、员工每天最多一个班次、连续上班天数上限、必须保证的休息间隔、固定的不可用时间请假、培训、法定休息日。软约束通常是员工总工时偏离期望值的惩罚、周末值班次数的均衡性、员工偏好班次的满足度。工程上最稳妥的做法是先把全部硬约束塞进模型求解一次看是否有可行解有解之后再逐步往目标函数里加软约束的惩罚项。一上来就混在一起写之后排查「为什么无解」会非常痛苦。我习惯用一张表来整理规则这张表会直接翻译成代码规则描述类型建模方式每个晚班至少 2 人在岗硬time 维度上的 in 计数下限员工连续上班不超过 6 天硬滑动窗口内上班次数 ≤ 6某员工本周五请假硬对应 x 值直接赋 0每人每周总工时尽量接近 40h软目标函数里对偏差加罚夜班尽量由高技能员工担任软目标函数里加偏好奖励整理表还有个附带好处业务方来确认规则时对着表沟通远比对着代码沟通高效。排班业务有一个特点规则文档永远滞后于实际执行表格至少能把「当前版本模型到底实现了什么」钉死后面迭代也有据可查。2.3 目标函数怎么定最小化处罚总和而不是最大化「公平」这里有一个最常见的建模误区。很多人会把排班目标写成「最大化公平」然后试图用各种比例指标去表达公平结果模型跑出来一团乱——因为公平不是一个可以全局最优的目标它是一组相互拉扯的软约束。正确的做法是把所有「尽量」「希望」「倾向」的因素全部转成惩罚项目标函数统一成最小化惩罚总和。比如每周工时偏离 40 小时 1 小时罚 1 分周末被安排夜班罚 5 分员工偏好被满足奖励 3 分等价于未满足罚 3 分。惩罚权重怎么定没有标准答案但有可用的办法先用一组业务默认值跑一版然后看求解结果里各项惩罚的占比。如果某项惩罚占到总惩罚的 80%说明这项目标实际在主导排班结果这时要么把权重调低要么确认业务上确实应该优先满足它。反过来如果某项惩罚永远为 0说明这项约束已经被天然满足权重可以调小避免干扰搜索方向。目标函数是模型和业务之间最直接的沟通渠道值得反复调不要指望一次到位。3. 用 Java API 建模求解最小可跑通的排班模型3.1 依赖与模型骨架ModelBuilder 的四个核心类or-tools 的 Java API 核心类是ModelBuilder、BoolVar、LinearExpr、CpSolver。ModelBuilder负责承载变量和约束BoolVar是决策变量LinearExpr用来做线性表达式组合CpSolver负责求解。注意 Java API 和 Python API 的类名有区别Java 侧以ModelBuilder为主不是 Python 里的CpModel这个差异很容易让照着 Python 教程写的 Java 代码编译不过。下面是一段能直接编译运行的最小模型骨架排 3 个员工、2 个班次、1 天的情况import com.google.ortools.Loader; import com.google.ortools.sat.*; public class ShiftSchedulingMinimal { public static void main(String[] args) throws Exception { Loader.loadNativeLibraries(); int numEmployees 3; int numShifts 2; int numDays 1; ModelBuilder model new ModelBuilder(); BoolVar[][][] x new BoolVar[numEmployees][numShifts][numDays]; for (int e 0; e numEmployees; e) { for (int s 0; s numShifts; s) { for (int d 0; d numDays; d) { x[e][s][d] model.newBoolVar(x_e e _s s _d d); } } } // 硬约束每天每个班次至少 1 人在岗 for (int s 0; s numShifts; s) { for (int d 0; d numDays; d) { LinearExpr[] employeesOnShift new LinearExpr[numEmployees]; for (int e 0; e numEmployees; e) { employeesOnShift[e] x[e][s][d]; } model.addLessOrEqual( LinearExpr.newConstant(1), LinearExpr.sum(employeesOnShift)); } } // 硬约束同一天同一个员工最多上 1 个班次 for (int e 0; e numEmployees; e) { for (int d 0; d numDays; d) { LinearExpr[] shiftsForEmployee new LinearExpr[numShifts]; for (int s 0; s numShifts; s) { shiftsForEmployee[s] x[e][s][d]; } model.addLessOrEqual(LinearExpr.sum(shiftsForEmployee), 1); } } CpSolver solver new CpSolver(); CpSolverStatus status solver.solve(model); if (status CpSolverStatus.OPTIMAL || status CpSolverStatus.FEASIBLE) { for (int e 0; e numEmployees; e) { for (int s 0; s numShifts; s) { for (int d 0; d numDays; d) { if (solver.value(x[e][s][d]) 1) { System.out.println(员工 e 班次 s 第 d 天上班); } } } } } else { System.out.println(无解状态: status); } } }这段代码做了三件事定义三维布尔变量用addLessOrEqual写入两个硬约束然后求解并输出变量取值。LinearExpr.sum是 Java API 里最常用的工具方法它接受LinearExpr[]数组并返回一个线性表达式对象可以直接放进约束或目标函数。单个BoolVar本身也是LinearExpr的子类型所以数组里可以直接放变量。newConstant(1)把常量 1 包装成表达式这一步在 Java API 里是必须的不能直接写model.addLessOrEqual(1, LinearExpr.sum(...))因为方法签名要求参数是LinearExpr类型。另外Loader.loadNativeLibraries()必须写在最前面它负责加载 or-tools 的本地动态库漏掉这一行会直接抛UnsatisfiedLinkError。3.2 约束与目标的 Java 写法LinearExpr 与 BoolVar 组合上面的代码只是骨架实际排班需要更丰富的约束组合。最常用的是「连续上班天数上限」这种带滑动窗口的约束以及「每周总工时浮动」这类目标函数。CP-SAT 的 Java API 提供了两个很趁手的辅助类LinearExpr的静态方法用于求和、加权求和BoolVar的not()方法返回一个取反后的线性表达式在写「如果上了早班就不能上晚班」这类条件约束时很实用。「连续上班天数上限」的建模思路按员工维度对每个连续 7 天的窗口窗口内上班总天数 ≤ 6。把该员工每天所有班次的变量求和得到「当天是否上班」的表示然后对窗口内这 7 个值求和。这里的实现手法是把「当天是否上班」用一个中间表达式workDay[e][d]表示它是当天所有班次变量之和取值范围天然是 0 或 1因为已经限制了每天最多一班。用中间表达式有两个好处一是约束代码从三维降到二维逻辑清晰很多二是求解器能更好地做预求解presolve变量关系更紧凑搜索效率更高。「每周总工时」的目标函数需要把工时系数乘到变量上。比如早班 8 小时、晚班 7 小时每周目标工时 40 小时那么目标表达式是sum(x[e][s][d] * hours[s] for all e,s,d) - 40的绝对值。CP-SAT 不支持直接用绝对值函数标准解法是引入两个非负偏差变量over和under用两个约束把偏差绑定// 每位员工的周工时偏差 for (int e 0; e numEmployees; e) { LinearExpr totalHours LinearExpr.sum(hoursPerShiftAndEmployee[e]); IntVar over model.newIntVar(0, 100, over_e e); IntVar under model.newIntVar(0, 100, under_e e); model.addEquality(LinearExpr.sum(new LinearExpr[]{totalHours, under}), LinearExpr.sum(new LinearExpr[]{LinearExpr.newConstant(40), over})); objectiveTerms.add(over); objectiveTerms.add(under); } model.minimize(LinearExpr.sum(objectiveTerms));这段代码的巧妙之处在addEquality的写法totalHours under 40 over。当实际工时小于 40 时under记录差值over为 0实际工时大于 40 时反过来。两个偏差变量不会同时为正因为如果同时为正目标函数一定会推动其中一个归零。IntVar是 CP-SAT 的整数变量类型newIntVar(0, 100, ...)里的上界 100 要大于最大可能的单周工时偏差否则模型会无解——这是新手最容易踩的第一个坑。3.3 求解与结果解析状态判断和变量取值还原CpSolver.solve(model)返回CpSolverStatus常见状态有OPTIMAL、FEASIBLE、INFEASIBLE、MODEL_INVALID。实际生产中小规模模型通常能直接求到OPTIMAL但上了规模之后就很难在可接受时间内证明最优跑出FEASIBLE就足够用了。这两种状态的结果都可以输出区别只是有没有证明最优性。结果解析有一个我强烈建议养成的习惯不要直接在遍历变量的循环里打印而是先构建一张排班表的数据结构再统一输出。因为实际项目里变量三维数组的下标和员工的 ID、班次的名称之间往往有一层映射关系直接从变量名硬拼字符串可读性很差。另外solver.value()只接受Literal类型参数如果你想取出一个整数表达式的值要用solver.value(expr)的重载版本它接受LinearArgument。这个 API 细节会导致编译期不报错、运行期才暴露类型问题值得留意。提示Java API 中变量命名x_e0_s0_d0这类名字虽然只在日志里可见但在排查无解问题时是救命信息。命名里带上员工编号、班次编号、日期编号求解器输出 conflict 信息时你才能一眼看出是哪条约束出问题。4. 求解参数与模型迭代从可行解到可用的排班结果4.1 求解器参数这些参数真的值得调or-tools CP-SAT 的默认参数已经相当能打但碰到大规模排班问题参数调整能把求解质量拉开一个档次。我实际使用中真正有效果的参数只有这几个按优先级排列参数作用建议值备注timeLimitInSeconds求解时间上限秒30~120排班一般不需要追求最优numWorkers并行搜索线程数8~16本地开发设 4生产按 CPU 核数maxMemoryInGb求解器内存上限2~4防止大模型吃光堆内存logSearchProgress输出搜索日志true关键时刻打开平时关掉randomSeed随机种子固定值同一份数据必须能复现结果Java API 里设置这些参数的入口是solver.getParameters()返回的CpSolverParameters对象直接setTimeLimitInSeconds、setNumWorkers这类方法设置即可。注意randomSeed固定下来极其重要——否则同一份数据跑两次结果不同业务方会直接质疑系统的可靠性。这不算玄学这是排班系统上生产的基本功。4.2 模型规模边界什么情况下要重新审视建模or-tools 对排班问题的处理能力取决于变量数、约束稠密度和目标函数结构而不是单纯的员工数。我实际遇到的曲线是这样的变量数在 1 万以下时几乎秒级出可行解到 5 万左右需要几十秒到几分钟超过 10 万就要认真考虑时间限制和并行度了否则搜索会陷入长时间无改进。30 个员工、每天 3 班、排 30 天变量数是 2700非常轻松但如果把岗位维度也乘进去比如 30 人 × 5 岗位 × 90 天变量数直接变 13500再叠加技能匹配约束复杂度就上来了。遇到模型膨胀我一般按三个方向依次处理第一检查是否可以把时间粒度从小时改成班次第二检查是否可以按部门或技能组拆分排班子问题每个子问题独立求解再合并结果第三检查软约束里有没有权重极小、作用极弱的项这些项会拖慢搜索但不影响结果质量直接删掉。拆分排班子问题的代价是可能失去全局最优但排班业务本身对全局最优并不敏感业务方要的是「快、稳、可解释」这一点要想清楚再决定要不要拆。4.3 迭代方法永远保持「先可行后优化」的节奏排班模型的调试节奏我强烈建议固定成三步第一步只加硬约束求一个可行解第二步逐条加软约束每次只加一条并求解观察目标值变化和求解时间第三步统一调权重看惩罚分布是否合理。这个习惯可以避免最常见的翻车方式——一次性写完几十条约束然后面对一个无解模型根本不知道是哪条规则和哪条规则打架。每加一条软约束记录一下目标值。如果目标值出现明显跳变说明这条约束的权重和现有约束冲突很大需要重新考虑权重量级。比如原本总惩罚 50加了一条「周末尽量少排班」的约束后总惩罚跳到 90说明这条约束的权重相对其他目标太强了它几乎主导了排班结果。这时候把权重减半再跑观察目标值变化是否线性如果非线性就要谨慎——可能模型里存在多条约束在同一个员工身上同时生效产生了叠加效应。5. 排班建模避坑指南我在生产环境踩过的 5 个坑5.1 现象模型报 INFEASIBLE业务却说规则明明可排这是一个会把新手卡死一整天的场景。模型输出INFEASIBLE但业务方的老排班员手工排出了一个符合所有显性规则的班表。原因排查通常不是业务规则本身矛盾而是你把某条软规则写成了硬约束或者某条硬约束的边界值设置错误。比如「每人每天最多 1 个班次」写成了「每人每天必须 1 个班次」当某天法定休息日时模型就无解了。解决遇到无解第一步打开求解器的日志找到 conflict 相关的输出第二步把硬约束逐个注释掉重新求解二分定位到冲突的那一组约束第三步检查边界值——addLessOrEqual和addGreaterOrEqual的右值是不是多写了一个 1。生产排班场景里这类问题多半出在节假日和特殊情况没有单独建模业务规则在节假日根本不适用。5.2 现象结果违反某条「隐性约束」业务验收时直接拒收模型求解出来是OPTIMAL排班表看起来也很合理但业务方发现某个高技能员工被排到了低技能岗位或者某个员工连续两周被排了同一个夜班。原因排查这类「隐性约束」往往没有写进需求文档里它们来自业务方的多年习惯。比如高技能员工优先某些岗位这种偏好如果完全没进模型求解器自然按目标函数最小化来排结果必然不符合业务期待。解决把这些隐性规则显式化。跟业务方逐条核对排班结果时把「为什么不能这样排」全部记录下来翻译成软约束或硬约束加进模型。如果这些规则没法准确量化一个可用妥协方案是在目标函数里加员工偏好权重让最喜欢的员工获得最优先的班次分配。切记排班模型是给业务用的不是给自己用的隐性规则没有进模型就不算数。5.3 现象求解很久都出不来结果日志显示搜索无进展模型不是无解但长时间停在同一个目标值附近日志里的objective数值不变化。原因排查目标函数里可能存在大量权重等价的软约束导致搜索空间里存在大量目标值相同的解求解器在证明最优性时陷入疲惫或者是某个软约束的权重大到压过所有其他目标搜索过程几乎完全被这一项目标主导。解决把权重离散化减少权重的取值种类。比如原来权重从 1 到 100 各种数值都有改成只允许 1、5、20、100 四档。这样目标函数的梯度变稀疏求解器剪枝效率会明显提升。另外设置一个合理的时间上限到点就接受当前最优可行解不要无限等下去——排班表的业务价值在「准时拿到」不在「全局最优」。5.4 现象Java 侧拿到的变量值和 solver 日志里的不一致运行时发现solver.value(x[e][s][d])返回 0但日志里显示的排班结果里明明该员工在这个班次。原因排查大概率是下标错位。Java 里的三维数组x[e][s][d]和你自己脑子里排的业务维度顺序不一致比如你真的遍历顺序是x[s][e][d]两个循环的索引顺序对不上读出来的值自然不对。解决统一约定下标顺序并且只通过一个工厂方法来创建和访问变量。比如封装一个getVar(employeeId, shiftId, dayId)方法所有读写都走这个入口杜绝手写多层数组下标。这个问题看起来低级但在真实项目里出现的频率非常高尤其当模型从 2 个班次扩展到 4 个班次时多维数组的下标会越来越难肉眼追踪。5.5 现象软约束惩罚总量收敛了但公平性极差——一个人被连续排了 20 天夜班目标值已经最小化但单个员工的体验极差。这是个经典的「目标函数设计缺陷」问题你只惩罚了总量偏差但没有惩罚单次连续夜班天数。原因排查目标函数里的惩罚项是全局求和求解器可以在一个人身上集中施加所有惩罚。只要总惩罚最小它不在乎惩罚是不是集中在某个倒霉蛋身上。解决在目标函数里增加「单次连续夜班天数超过阈值」的额外惩罚项或者为每个员工分别计算惩罚并加入「最大惩罚最小化」的第二层目标。具体实现上可以用一个辅助布尔变量表示「该员工连续 3 天夜班」一旦满足就罚一个较大的权重。这种约束在 CP-SAT 里有成熟做法滑窗内夜班天数 ≥ 3 时触发惩罚。加完这个约束后目标值可能略有上升但排班结果的可用性会显著提高。6. 上生产前的验收方法结果可解释、可追溯、可回放排班模型上线前最后一道关卡是验收。我的做法不是看「模型求出的目标值多少」而是用三组独立手段验证规则抽样审计、随机回放对比、约束违反自动记录。规则抽样审计的含义是把排班结果导出成 CSV随机抽 10 个员工、5 天人工对照规则清单逐条核对。这一步不能省它抓到过我的模型在法定节假日没有避开休息日的真实 bug。随机回放对比是换一组随机种子重新求解对比两次结果——如果核心排班结构差异过大比如员工排班天数从 5 天变成 8 天说明模型对初始条件过于敏感需要检查约束强度。约束违反自动记录则是把模型的约束在结果侧重新校验一遍任何违反都直接输出到日志而不是依赖求解器内部保证。做好验收还有一个细节每次迭代都固定randomSeed并记录求解参数、运行时间、目标值、求解状态这四个字段。这样当业务方拿着排班表来问「为什么这次和上次不一样」时你能快速确认是模型改动导致的还是随机搜索导致的。没有这套记录所有排班问题都会沦为「玄学」——反正换了个种子结果就变了谁也说不清。我的经验是排班项目里 80% 的验收问题都出在「模型是对的但业务方看不懂求解器为什么这么排」。所以最终验收一定要把排班结果转成业务人员熟悉的形式班次表格按员工分行、按日期分列而不是一长串 x 变量的取值。这一步虽然不涉及模型本身但它是排班系统能不能真正被用起来的关键。希望这篇笔记能帮你在 or-tools 排班这条路上少走几步弯路落地上手时少一点纠结。本文还有配套的精品资源点击获取