
简介一套面向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) 的变量集合来实现而不是额外写约束。比如某个人周二全天不便那么所有 ((day1, 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(fx_{i}_{d}_{s}) # 录用变量 y {} for i in range(PEOPLE): y[i] model.NewBoolVar(fy_{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(fwork_{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第{d1}天 班次{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第{d1}天班次{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], fshort_{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(自检通过)我自己最离谱的一次翻车就是忘了加早班晚班互斥约束结果模型跑完一个“漂亮”的最优解成本比次优解还低现场的人却坚决反对执行。从那以后所有排班结果必须先过自检函数再发出去。临时工排班这件事说到底还是有一些玄学成分——规则再细也敌不过现场一个“这周已经连上六天”的突发请假。但把约束和检查前置到模型里至少能把突发状况从“常态”变成“异常”。希望帮到你。本文还有配套的精品资源点击获取