简介:一套面向2026年东三省数学建模B题的完整解决方案包,聚焦大型展销会临时工招聘与排班优化问题,适合数学建模参赛者、运筹优化学习者参考。内容涵盖赛题解析、问题抽象、线性与整数规划建模思路、三问求解流程及结果对比,同时兼顾临时工偏好等非定量因素,体现从建模到落地的完整闭环。压缩包共43个文件、总大小5.22MB,以tex/pdf论文文档、py代码脚本、png结果图表、json数据结果、drawio思路图及md阶段笔记为主,目录按赛题阶段和问题编号组织,便于按需查阅。其中main.tex可编译论文,四个py脚本覆盖组内排班、跨天跨组排班、日内跨组排班及图表生成,png图表包含甘特图、需求热力图、在岗休息分布等关键可视化。目前已有260人学习下载。这套资料不仅提供完整论文与可运行代码,还给出了问题1至问题3的逐步解题思路和灵敏度分析,能让读者快速理解招聘人数控制、工作时间分配与排班优化的建模要点,并可直接修改复用,适合用来复盘竞赛思路或作为同类排班问题的研究起点。
1. 大型展销会临时工招聘与排班优化:为什么这个B题把招聘和排班绑在一起算
展销会开幕前,主办方手上通常只有一张“哪天哪个时段需要几个人”的需求表和一堆报名临时工的可用时间。很多人的第一反应是先按时薪从低到高把人定下来,再拿这份名单去填班表。结果往往很尴尬:要么晚班没人顶,要么某个人被连续排了五天、每天两个班次,现场累到直接罢工。2026 年某地区高校联赛 B 题的“大型展销会临时工招聘与排班优化问题”,要解决的正是这个矛盾——招谁、排谁、排哪班,必须放进同一个整数规划里联合决策。先选人后排班的两段式思路,本质上把固定招聘成本和排班约束割裂开了,得到的只会是局部最优。
这道题适合两类人。一类是准备冲奖的参赛队,需要一条从问题分析、模型建立到代码求解的完整链路;另一类是真在做展会、门店排班的从业者,想把拍脑袋排班换成可复算、可解释的优化方案。下面的数据全部是模拟数据,重点讲清楚约束条件怎么写、求解器参数怎么调、以及哪些坑最容易让模型“看起来对、跑起来废”。
2. 先把排班问题翻译成整数规划:决策变量、约束与目标
2.1 决策变量怎么定:录用标志和排班标志是两套变量
这类问题第一道坎是:临时工还没有最终录用名单,排班变量该落在哪些人身上?我一般同时定义两个布尔变量:(y_i) 表示是否录用第 (i) 个临时工,(x_{i,d,s}) 表示第 (i) 个人是否在第 (d) 天第 (s) 个班次上班。二者之间用一条“只有录用才能排班”的约束串起来,让模型自己去权衡“少招人多加班”还是“多招人少排班”。
之所以拆成两套变量,而不是用一个变量直接代表“录用并承担某班次”,是因为固定成本没法表达。一个人一旦被录用,不管最终上几个班次,都要支付体检、工牌、培训这些一次性费用;这部分成本在展会这类短期用工场景里占比很高。拆开之后,目标函数里固定成本乘 (y_i),可变工资乘 (x_{i,d,s}),成本结构清清楚楚。如果只用排班变量,固定成本就只能平均摊进每个班次,模型就会错误地鼓励多排班。
| 变量 | 类型 | 含义 |
|---|---|---|
| (y_i) | 0-1 变量 | 是否录用临时工 (i) |
| (x_{i,d,s}) | 0-1 变量 | 临时工 (i) 在第 (d) 天第 (s) 班次是否上班 |
| (shortage_{d,s}) | 非负整数 | 第 (d) 天第 (s) 班次的人员缺口,仅在软约束模型中使用 |
2.2 必写的四类约束:需求、可用时间、连续工作与预算
第一类需求覆盖约束。每个班次在岗人数不能低于最低需求,写成 (\sum_i x_{i,d,s} \ge demand_{d,s})。这里要特别注意用大于等于而不是等于,等于意味着“一个不能多”,一旦某时段可用人数恰好不足,整个模型直接无解。实际展会中多一两个人做机动是常态,我一般会留出冗余。
第二类可用时间约束。每个临时工只在自己登记可用的班次里被安排,模型里通过限定 (x) 的变量集合来实现,而不是额外写约束。比如某个人周二全天不便,那么所有 ((day=1, s)) 的 (x_{i,1,s}) 都不创建;变量不存在,自然就不会被排进去。很多人喜欢给每个时段都建 (x),再用约束强行禁止,这就是白白增加求解器负担。
第三类连续工作约束。这是临时工排班里最容易漏的部分。我会写三条:每人每天最多两个班次,也就是每天不超过 8 小时;每人每 4 天最多上 3 天班,防止连续疲劳;同一天不能同时排早班和晚班,避免早上 9 点进场、晚上 9 点离场的“耗一天”式安排。最后一条在代码里经常被忽略,因为从工时看 8 小时并不超,人却等于一整天被锁在现场。
第四类预算约束。工资总额不能超过主办方给的预算上限。但我不建议把它写成硬约束,因为一旦需求和预算冲突,模型会直接无解。更稳妥的做法是把预算放进目标函数里做权衡,或者在硬预算之外允许“超支惩罚”,让模型在有解和成本之间选一个平衡点。
2.3 目标函数:总成本最小化,但缺勤要留“后悔药”
目标函数的主体是总成本,包括一次性招聘成本和所有排班班次的工资。写成: [ \min \sum_i (HIRE_COST \cdot y_i) + \sum_{i,d,s} (wage_{i,s} \cdot x_{i,d,s}) ] 这里 HIRE_COST 是一次性成本,比如 80 元一个人;wage 是按班次计的工资。注意同一个临时工在不同班次的期望时薪可能不一样,晚班通常比早班贵,所以 wage 要按人按时段分别给。
但在实际比赛中,只写总成本最小化会带来一个问题:如果某个时段需求特别高,把所有能用的人都排上也覆盖不了,模型会直接失败。我一般会在需求覆盖约束里加一个软缺勤变量,允许缺岗但每缺一个人次扣一笔大额惩罚。这样模型永远有解,同时惩罚系数又保证它尽量满足需求。缺勤惩罚的量级,通常设成单班工资的 3 到 5 倍,比如 300 元。
3. 用 OR-Tools CP-SAT 在本地跑通最小排班系统
3.1 环境与模拟数据:pip install ortools 之后先构造一张需求表
这一章的代码,我默认你已经装好了 Python 环境。只需要安装一个依赖库:
pip install ortoolsOR-Tools 的 CP-SAT 求解器在排班调度这类约束满足问题上非常成熟,接口稳定,而且本地跑不需要联网。下面构造一组模拟数据:展销会持续 5 天,每天 3 个班次,每个班次 4 小时,候选临时工 14 人。
import random from ortools.sat.python import cp_model random.seed(2026) DAYS = 5 # 展销会天数 SHIFTS = 3 # 每天班次数:0早 1中 2晚 PEOPLE = 14 # 候选临时工总数 HIRE_COST = 80 # 每人一次性签约/培训成本 # 每天每个班次的最低需求:demand[d][s] demand = [ [5, 5, 4], [6, 5, 5], [6, 6, 5], [5, 6, 6], [5, 5, 5], ] # 每个人平均开放约六成班次供选择 availability = {} for i in range(PEOPLE): avail = [(d, s) for d in range(DAYS) for s in range(SHIFTS) if random.random() < 0.6] if not avail: avail = [(0, i % SHIFTS)] availability[i] = avail # 不同人不同班次的期望工资(单位:元/班次,一个班次4小时) wage = {i: {s: random.randint(80, 110) for s in range(SHIFTS)} for i in range(PEOPLE)}随机种子固定为 2026,保证每次运行生成的可用班次表完全一致,方便复现。每个人开放约六成班次,模拟真实报名时“我能上但并不是随时都能上”的状态。如果一个人一个班次都没开放,就给他一个兜底班次,避免后面出现“录用名单里有他、却一个班都排不了”的边界情况。
3.2 建模代码:把 2.2 的约束逐条写进求解器
接下来是核心的建模代码。所有约束都用一个 cp_model.Model() 实例承载,我把第二章的四类约束逐条落地。
model = cp_model.Model() # 排班变量:只有存在于 availability 里的班次才创建变量 x = {} for i in range(PEOPLE): for (d, s) in availability[i]: x[(i, d, s)] = model.NewBoolVar(f"x_{i}_{d}_{s}") # 录用变量 y = {} for i in range(PEOPLE): y[i] = model.NewBoolVar(f"y_{i}") # 约束1:每个时段需求覆盖 for d in range(DAYS): for s in range(SHIFTS): vars_ds = [x[(i, d, s)] for i in range(PEOPLE) if (d, s) in availability[i]] model.Add(sum(vars_ds) >= demand[d][s]) # 约束2:只有被录用的人才能排班 for i in range(PEOPLE): for (d, s) in availability[i]: model.Add(x[(i, d, s)] <= y[i]) # 约束3:每人每天最多2个班次,且同一天不能早班+晚班连排 for i in range(PEOPLE): for d in range(DAYS): daily = [x[(i, d, s)] for s in range(SHIFTS) if (d, s) in availability[i]] model.Add(sum(daily) <= 2) if (d, 0) in availability[i] and (d, 2) in availability[i]: model.Add(x[(i, d, 0)] + x[(i, d, 2)] <= 1) # 约束4:每人每4天最多上3天班 work_day = {} for i in range(PEOPLE): for d in range(DAYS): daily = [x[(i, d, s)] for s in range(SHIFTS) if (d, s) in availability[i]] if daily: work_day[(i, d)] = model.NewBoolVar(f"work_{i}_{d}") model.Add(sum(daily) >= 1).OnlyEnforceIf(work_day[(i, d)]) model.Add(sum(daily) == 0).OnlyEnforceIf(work_day[(i, d)].Not()) for i in range(PEOPLE): for d0 in range(DAYS - 3): model.Add( sum(work_day[(i, d)] for d in range(d0, d0 + 4) if (i, d) in work_day) <= 3 ) # 目标:招聘固定成本 + 所有班次工资 obj_terms = [] for i in range(PEOPLE): obj_terms.append(HIRE_COST * y[i]) for (d, s) in availability[i]: obj_terms.append(wage[i][s] * x[(i, d, s)]) model.Minimize(sum(obj_terms))约束 3 里sum(daily) <= 2表达的是“一天最多上两个班次”,每个班次 4 小时,也就是一天最多 8 小时。x[(i, d, 0)] + x[(i, d, 2)] <= 1是早班和晚班互斥。注意这里没有禁止“中班+晚班”,因为 13 点到 21 点虽然也是 8 小时,但属于连续工作,体力上是能接受的。
约束 4 是很多人第一次写会出错的点。OnlyEnforceIf表示只有当work_day[(i, d)]为真时,才强制当天至少有一个班次;反之如果为假,则当天所有班次都必须为 0。先构造出“当天是否上班”的辅助变量,再用滑动窗口统计每 4 天的上班天数上限。没有这层辅助变量,直接对 (x) 求和是表达不了“每 4 天休息 1 天”的。
3.3 求解与输出:看懂 status、目标值和排班表
模型建好之后,实例化 CpSolver,设置两个关键参数后直接求解。
solver = cp_model.CpSolver() solver.parameters.max_time_in_seconds = 20 # 限制求解时间 solver.parameters.num_search_workers = 8 # 8线程并行搜索 status = solver.Solve(model) print("status:", solver.StatusName(status)) print("objective:", solver.ObjectiveValue()) if status in (cp_model.OPTIMAL, cp_model.FEASIBLE): hired = [i for i in range(PEOPLE) if solver.Value(y[i]) == 1] print("录用人数:", len(hired)) for d in range(DAYS): for s in range(SHIFTS): on_duty = [str(i) for i in range(PEOPLE) if (i, d, s) in x and solver.Value(x[(i, d, s)]) == 1] print(f"第{d+1}天 班次{s}", ",".join(on_duty))max_time_in_seconds = 20的意思是求解器最多搜 20 秒,到期后返回当前找到的最好可行解。对于这个 14 人、5 天的小规模问题,通常几秒就能出最优解。num_search_workers = 8让多核 CPU 并行搜索,如果你机器核数少,改成 4 或 2 即可。
StatusName返回的结果有三种常见值:OPTIMAL表示找到最优解且已证明最优;FEASIBLE表示在时间限制内找到了可行解但不一定最优;INFEASIBLE表示模型无解——这是后面避坑章节的头号问题。
4. 排班优化实战避坑:不可行、连班、超时与黑匣子结果
这一章的内容大多是我自己翻车之后写下来的。初版模型能跑通和能上现场,中间隔着几条血泪经验。下面按“现象、原因、解决”逐个记录。
4.1 现象一:所有代码都正常,求解器却返回 INFEASIBLE
最常见的情形是把需求约束写成了等式。也就是把model.Add(sum(vars_ds) >= demand[d][s])写成了==。当一个班次所有可用人数加起来都不足需求数时,等式约束直接构造出一个无解空间,求解器根本不需要搜索,开局就报 INFEASIBLE。
另一个隐蔽原因是数据自身矛盾:需求表里某天晚班要 6 个人,但报名者里只有 5 个人在晚班可用。解决这个问题,我习惯在建模之前先跑一个数据体检,把每个时段的可用人数打印出来:
for d in range(DAYS): for s in range(SHIFTS): coverage = sum(1 for i in range(PEOPLE) if (d, s) in availability[i]) if coverage < demand[d][s]: print(f"第{d+1}天班次{s} 可用人数不足," f"需求{demand[d][s]} 实际只有{coverage}")如果体检报告显示某个时段可用人数不足,有三种补救办法:增加该时段的开放班次、降低该时段需求、或者把该时段的硬需求改成软缺勤约束。不要直接删需求数据——那是为了跑通而造假。
4.2 现象二:模型合法结果里出现“早上 9 点干到晚上 9 点”
这是我最开始踩过的坑。只写了每人每天最多两个班次,求解器给出一个很“合理”的解:某人同一天排了早班和晚班,中间空了 4 个小时。从工时看,8 小时完全合法;从体验看,这人等于一整天耗在展馆里,下午还得自己找地方待着。
原因是约束只限制了“班次数量”,没有限制“班次的跨度”。解决方式就是在 3.2 的约束 3 里加一条早班和晚班互斥:
if (d, 0) in availability[i] and (d, 2) in availability[i]: model.Add(x[(i, d, 0)] + x[(i, d, 2)] <= 1)这条约束的本质是把“早+晚”这种物理上不可接受的时间组合提前排除。如果你面对的现场营业时间更长,比如 8 点到 22 点分了 6 个班次,那就需要把“首班+末班”互斥写成 (x_{i,d,0} + x_{i,d,last} \le 1)。
4.3 现象三:打印名单发现有人没被录用却在上班
这个现象听起来很离谱,但确实会发生。当模型规模变大、变量多到靠手写循环不易检查时,偶尔会把x和y的关系遗漏,于是解出“未录用的临时工被排了班”或者“录用了却一个班都没有”的结果。
原因在于录用状态和排班状态没有建立逻辑关联。解决办法是约束 2 的x <= y。如果想进一步强制“录用了的人至少要上 N 个班”,可以加一条求和约束:
model.Add( sum(x[(i, d, s)] for (d, s) in availability[i]) >= y[i] )但这条要谨慎使用。如果某个人的可用班次太少,加上这条会导致无解;而且现实中主办方本来就会留少量备用人员,录用后不上班是合理的。所以默认我只写x <= y,不写反向约束。
4.4 现象四:变量一多,求解器像卡死一样几分钟不出解
把 PEOPLE 从 14 改成 300、DAYS 改成 15 天之后,模型规模会涨到数千个布尔变量。CP-SAT 依然能处理,但如果直接裸跑,20 秒可能连第一个可行解都找不到。
原因一是约束太多导致传播变慢,二是缺少一个好的上界来剪枝。解决办法是先跑一个不考虑连续休息约束的简化版,得到一个可行解;把这个解的成本作为上界传回完整模型:
# upper_bound 是简化版求出的目标值 model.Add(sum(obj_terms) <= int(upper_bound))Add(sum(...) <= upper_bound)告诉求解器“我已经有一个成本这么低的方案了,你只需要在这个范围里找更好的”。这个约束能砍掉大量劣质分支,效果比单纯拉长时间好得多。注意upper_bound必须是整数,因为 CP-SAT 的目标值默认是整数。
4.5 现象五:输出排班表和解对象里的值对不上
有一次我把方案交付给运营同事,对方照着打印出的班表排人,结果发现两个人名重复了一天两个班次。我重新跑了一遍代码,打印出来的逻辑明明是对的啊。后来发现是输出时直接遍历了x.items()字典。
Python 字典的遍历顺序虽然稳定,但当班次编号作为字符串排序时,会出现“第 10 天”排在“第 2 天”前面这类错位;更麻烦的是字典里的键是 (i, d, s) 三元组,排序规则一不一致,输出就跟着乱。解决方式是用三重循环定位输出,而不是遍历字典:
for d in range(DAYS): for s in range(SHIFTS): names = [i for i in range(PEOPLE) if (i, d, s) in x and solver.Value(x[(i, d, s)]) == 1] names.sort() print(d, s, names)另外,在交付前最好写一个自检函数,对每个临时工每天统计班次数,超过 2 就直接报错。这个函数很小,但能挡住一半以上的“黑匣子”式误排。
5. 从“能跑”到“能答辩”:快速重算与方案评估
5.1 把硬需求改成软约束,给模型留一条缺勤活路
比赛评分和实际运营不一样:评分看的是模型是否合理、结果是否可解释,而不是拿着一个“必须完全满足需求”的硬约束卡死。所以我通常会在正式模型里把需求约束改写成软约束,引入缺口变量shortage,并在目标函数里给它一个高惩罚。
shortage = {} PENALTY = 300 # 缺1人/班次的惩罚,约等于单班工资的3倍 for d in range(DAYS): for s in range(SHIFTS): shortage[(d, s)] = model.NewIntVar( 0, demand[d][s], f"short_{d}_{s}") vars_ds = [x[(i, d, s)] for i in range(PEOPLE) if (d, s) in availability[i]] model.Add(sum(vars_ds) + shortage[(d, s)] >= demand[d][s]) obj_terms.append(PENALTY * shortage[(d, s)])注意这个写法要替换掉原来需求覆盖处的硬约束,否则同一个班次既有硬约束又有软约束,shortage永远是 0,软约束形同虚设。惩罚值取 300 的含义是:宁可多花 300 元临时加人,也不让该时段缺岗,但极端情况下确实招不到人时,模型会选择缺勤而不是无解。
5.2 需求或时薪一变,用参数扫描代替手工重排
展会排班有个烦人的特点:需求表每天都会变。昨天还说第三天下午 6 个人,今天主办方改成 7 个人。每次手工重排非常费时间。更合理的做法是把建模过程封装成一个函数build_model(demand),然后对不同的参数跑批量求解。
def build_model(demand): # 这里放 3.2 的全部建模代码 # 返回 model, x, y, obj_terms pass results = {} for delta in range(-2, 3): demand_tmp = [row[:] for row in demand] demand_tmp[2][1] += delta # 只改第三天午班的需求 model, x, y, _ = build_model(demand_tmp) solver = cp_model.CpSolver() solver.parameters.max_time_in_seconds = 10 status = solver.Solve(model) results[delta] = (solver.StatusName(status), solver.ObjectiveValue())这样一次循环就能得出需求变化 2 人时总成本的变化曲线。同理,如果时薪整体上浮 10%,把wage的生成函数改成传入一个wage_scale参数即可。论文里这张“需求-成本”敏感性表格非常加分,它证明你的模型不是只在某组数据下能跑,而是对变化有稳定的响应规律。
5.3 三张表衡量方案好坏:覆盖率、人时成本与公平性
很多人跑出最优解就直接写进论文,其实缺少了“方案评估”环节。答辩时评委最爱问的就是:你这个方案和随便排排相比好在哪里?我一般会准备三张表。
需求覆盖率等于实际排班人次除以总需求人次,反映模型在多大程度上满足了现场需要;平均人时成本等于总成本除以总工时,单位元/小时,用来和手工排班对比;公平性用每个人总班次数的方差衡量,方差太大说明有人累死有人闲死。计算方式如下:
total_shift = sum(solver.Value(v) for v in x.values()) total_hours = total_shift * 4 per_hour_cost = solver.ObjectiveValue() / total_hours # 每个人的总班次分布 per_person = [sum(solver.Value(x[(i, d, s)]) for (d, s) in availability[i]) for i in range(PEOPLE)] var = sum((v - total_shift / PEOPLE) ** 2 for v in per_person) / PEOPLE| 指标 | 计算方式 | 意义 |
|---|---|---|
| 需求覆盖率 | 实际排班人次 / 总需求人次 | 是否满足现场需求 |
| 平均人时成本 | 总成本 / 总工时 | 和手工排班对比的核心指标 |
| 人均班次方差 | 各人总班次数的方差 | 方案公平性 |
这套评估在论文里就是以三张表出现,配合灵敏度分析,基本能把“这是一个可落地的排班方案”这个结论立住。
6. 一个让排班秒出的技巧:热启动 + 结果自检
6.1 把已跑出来的可行解当 Hint 喂给 CP-SAT
上过规模的题都会遇到一个尴尬:完整模型加满连续休息约束后,求解器前 30 秒都在找第一个可行解,而不是在优化。这时候热启动能救命。先跑一个去掉work_day连续休息约束的简化模型,得到一个可行解;再把这个解作为 Hint 传给完整模型,CP-SAT 会从这组解附近开始搜索,而不是从头试错。
# warm_start 是简化模型求出的解字典:{(i,d,s): 0或1} for key, val in warm_start.items(): if key in x: solver.AddHint(x[key], val)AddHint不是硬约束,求解器可以推翻它,它只是一个搜索起点。但一个好起点带来的剪枝效果极其明显,我处理过的一个三百人规模模型,加了 Hint 之后从“20 秒找不到可行解”变成“3 秒内找到可行解、15 秒出最优”。这个技巧在答辩时讲出来也很亮眼,因为大部分队伍都在裸跑求解器。
6.2 输出前跑一个自检函数,把黑匣子变成可解释的交付物
热启动让求解变快,但输出之前还得过一道自检。我每次交付前都会跑同一个校验函数,检查每个临时工每天排班次数不超过 2,并且同一天没有早班晚班同时出现。这样既能挡住 4.2 和 4.5 里的坑,也让我敢直接把排班表发给运营同事。
def audit(result): per = {} for (i, d, s), v in result.items(): if v == 1: per[(i, d)] = per.get((i, d), 0) + 1 for (i, d), cnt in per.items(): assert cnt <= 2, f"人员{i}第{d}天排了{cnt}个班" print("自检通过")我自己最离谱的一次翻车,就是忘了加早班晚班互斥约束,结果模型跑完一个“漂亮”的最优解,成本比次优解还低,现场的人却坚决反对执行。从那以后,所有排班结果必须先过自检函数再发出去。临时工排班这件事说到底还是有一些玄学成分——规则再细,也敌不过现场一个“这周已经连上六天”的突发请假。但把约束和检查前置到模型里,至少能把突发状况从“常态”变成“异常”。希望帮到你。
本文还有配套的精品资源,点击获取