简介:面向2026年东三省数学建模B题“大型展销会临时工招聘与排班优化问题”的完整参赛资源包,适合数学建模参赛者、运筹优化学习者及高校指导教师参考。资源共43个文件,包含4个Python排班脚本、论文LaTeX/PDF与Markdown解析文档、28张分析图表及DrawIO绘图源文件,压缩包整体5.22MB,结构按问题一至问题三及阶段笔记划分,便于按需查阅。内容覆盖组内排班、跨天跨组排班、日内跨组排班三类模型,并配有需求热力图、甘特图、在岗休息分布及灵敏度分析图,可直观还原建模推导与求解过程。已有260人学习下载,适合希望系统复现完整方案、提升排班优化建模与Python实现能力的读者。
1. 大型展销会临时工招聘与排班优化:为什么先算需求再谈排班
排班优化做不好,最典型的症状是账面成本越补越高,现场还是缺人。展销会这种短期项目,需求每天波动——开幕日、周末、最后半天清仓,如果只按“每天要多少人”粗排,再靠临时加人兜底,成本一定失控。这个标题拆开看是两层决策:先定招聘规模,普通工和熟练工各招多少;再在每天的早中晚班次里做覆盖排班,同时兼顾成本和公平。我会用 Python 整数规划把这套链路跑通,需求预测用时段系数法,排班用 PuLP 建模,数据从 CSV 进、Excel 班表出,完整论文代码的组织、参数和踩坑都写在后面。适合数学建模竞赛队伍、活动人力运营,以及第一次写排班优化代码的同学。
2. 先从需求预测定招聘规模:把每小时客流折算成临时工人数
排班是对偶问题,先知道每个小时要几个人,才能谈班次怎么盖。很多队伍上来就排班,结果要么多招一堆人,要么排完发现某时段人不够。我习惯先把“每小时需求人数”定下来,再谈招聘和排班。
2.1 需求预测的时段系数法:把每小时客流折算成临时工人数
展销会 B 题一般会给客流历史数据,或者让你自己假设客流分布。常见做法是时段系数法:拿一个基准客流表,乘上时段系数,再除以单人服务能力,就得到每个营业小时最少要几个人。系数不是拍脑袋,是按现场经验填的:上午开门时段客流集中,系数给高;周末全天都高;最后一天清仓的下午和晚上更高。缺失的时段系数直接兜底成 1.0,避免合并数据后出现空值。
# demand_forecast.py import pandas as pd import numpy as np def build_demand(visitor_csv, coef_csv, capacity_per_worker=80): # visitor_csv 至少包含 date, hour, visitors 三列 # coef_csv 包含 date, hour, coef 三列,缺失时段按 1.0 处理 df = pd.read_csv(visitor_csv) coef = pd.read_csv(coef_csv) df = df.merge(coef, on=["date", "hour"], how="left") df["coef"] = df["coef"].fillna(1.0) # ceil 保证人数取整,宁可多排一个人也不能现场缺岗 df["need"] = np.ceil(df["visitors"] * df["coef"] / capacity_per_worker).astype(int) return df[["date", "hour", "need"]]这段代码的核心是把“客流”翻译成“人力需求”。capacity_per_worker=80表示一名临时工每小时大约能服务 80 名客流,这个值在展销会的引导、补货、维持秩序场景下比较常见。如果现场分收银、引导、补货三类岗位,不要把三个岗位混在一个需求值里,正确做法是拆成三张需求表分别预测再合并。ceil向上取整是故意的,客流预测本身有误差,少一个人就是现场事故,多一个人只是成本多一点点。
注意:需求预测别直接用原始客流数据,先乘时段系数再除单人吞吐。展销会开幕日和普通工作日的客流可能差 30% 以上,系数不标清楚,后面排班全跟着偏。
2.2 招聘规模整数规划:普通工和熟练工各招多少
需求出来之后先别急着排班,先算要招多少人。招人是开口决策,排班是闭合决策:招少了模型无解,招多了成本没救。我把招聘规模建模成一个小的整数规划:普通工日薪 120,熟练工日薪 180;熟练工效率更高但市场供给有限,所以约束里限制熟练工占比不能超过 60%。总工时约束保证整个展销期所有需要人手的工时有足够的人能顶上。
# recruit_model.py import pulp def solve_recruit(need_df, cost_normal=120, cost_skilled=180, max_work_days=4, shift_hours=8, skilled_ratio_limit=0.6): # need_df 来自 demand_forecast,need 列是每个营业小时的需求人数 total_hours = int(need_df["need"].sum()) prob = pulp.LpProblem("recruit_scale", pulp.LpMinimize) n_normal = pulp.LpVariable("n_normal", 0, None, pulp.LpInteger) n_skilled = pulp.LpVariable("n_skilled", 0, None, pulp.LpInteger) # 目标:按整个展销期最长工作天数折算的签约成本 prob += n_normal * cost_normal * max_work_days + n_skilled * cost_skilled * max_work_days # 总可用工时 >= 总需求工时 prob += (n_normal + n_skilled) * max_work_days * shift_hours >= total_hours # 熟练工供给限制 prob += n_skilled <= (n_normal + n_skilled) * skilled_ratio_limit prob.solve() if pulp.LpStatus[prob.status] != "Optimal": raise RuntimeError("招聘规模无可行解,请检查需求工时") return int(n_normal.value() or 0), int(n_skilled.value() or 0), total_hours这里的关键是max_work_days=4。展销期 7 天,不是所有人都能 7 天连上,劳动强度和安全都不允许。每人最多连续工作 4 天,所以总可用工时按“人数 × 4 天 × 8 小时”算,而不是按 7 天算。这个参数直接影响招聘规模,调大一点招的人就少,但排班阶段连续工作约束会很容易无解。skilled_ratio_limit=0.6表示熟练工最多占六成,数学建模竞赛里如果题目没给供给限制,这个约束可以不写,但实际展会项目中熟练工真的招不满,写着更稳。
2.3 生成可排班人员池:出勤天数与类型落地成 CSV
招聘数量定了之后,要把“人数”变成“人员名单”,每人有 id、类型和排班偏好。我这里用随机种子生成一份演示数据,方便跑通流程;题目如果给了报名表或签到表,直接把generate_workers换成pd.read_csv("data/workers.csv")就行,后面所有模型代码不用改。
# make_workers.py import pandas as pd import numpy as np def generate_workers(n_normal, n_skilled, seed=42): rng = np.random.default_rng(seed) rows = [] for i in range(n_normal): rows.append({ "id": f"N{i:02d}", "type": "normal", "max_shifts_per_day": 1, "pref_days": 4 }) for i in range(n_skilled): rows.append({ "id": f"S{i:02d}", "type": "skilled", "max_shifts_per_day": 1, "pref_days": 4 }) workers = pd.DataFrame(rows) workers.to_csv("data/workers.csv", index=False) return workersseed=42是为了可复现。竞赛论文里如果随机生成数据,一定把种子写死,不然队友跑出来的结果和你不一样,评委复现也复现不出来。pref_days这一列表示该工人愿意连续出勤的天数上限,排班模型里直接用;如果某一类临时工只能周末出勤,在这里加一列available_days,排班约束里再过滤一遍日期即可。
3. 排班优化建模:班次覆盖、连续工作约束与目标函数怎么写进整数规划
有了人,排班问题就变成一个 0-1 整数规划:x[w, d, s]表示工人 w 在第 d 天上第 s 个班次,取值 0 或 1。这是整套代码的核心,也是论文里最能体现建模能力的地方。
3.1 班次设计:三班制怎么覆盖整个营业时段
假设展销会营业时间是 9:00 到 22:00,共 13 个小时。现场不能每个小时换一批人,那样交接成本太高,所以设计成三班制:早班、中班、晚班。每个班次 8 小时,覆盖多个营业小时,中班覆盖午高峰,晚班覆盖晚高峰加收尾清场。
| 班次 | 上岗时段 | 覆盖营业小时 | 覆盖小时代码 | 日薪示例 |
|---|---|---|---|---|
| A 早班 | 8:00-16:00 | 9,10,11,12,13,14,15 | range(9,16) | normal 120 / skilled 180 |
| B 中班 | 12:00-20:00 | 12,13,14,15,16,17,18,19 | range(12,20) | normal 120 / skilled 180 |
| C 晚班 | 14:00-22:00 | 14,15,16,17,18,19,20,21 | range(14,22) | normal 120 / skilled 180 |
这里最容易错的是晚班的覆盖范围。22:00 营业结束,但需求只计算到 21 点,22 点当小时的需求不应该存在,否则晚班覆盖不到 22 点,模型直接无解。早班 8 点上岗是为了提前布置场地,不占用营业时间,所以覆盖从 9 点开始。每小时映射用半开区间range(start, end),这是排班建模里约定俗成的写法。
3.2 三组硬约束:覆盖、一天一班、连续工作限制的代码写法
排班模型的硬约束一共三组:每小时在岗人数不低于需求、每人每天最多一个班次、任意连续 4 天最多工作 3 天。第三组是防止连续疲劳的,也是导致模型无解的头号嫌疑犯。
# schedule_model.py import pulp def build_hour_map(shifts): # shifts: {"A": {"start": 8, "end": 16}, ...} # 返回 hour -> 覆盖该小时的班次名列表 mapping = {} for name, spec in shifts.items(): for h in range(spec["start"], spec["end"]): mapping.setdefault(h, []).append(name) return mapping def build_schedule(workers, need_df, shifts): dates = sorted(set(need_df["date"])) hours = sorted(set(need_df["hour"])) worker_ids = workers["id"].tolist() type_of = workers.set_index("id")["type"].to_dict() hour_to_shifts = build_hour_map(shifts) pay = {"normal": 120, "skilled": 180} prob = pulp.LpProblem("worker_schedule", pulp.LpMinimize) x = {} for w in worker_ids: for d in dates: for s in shifts: x[(w, d, s)] = pulp.LpVariable(f"x_{w}_{d}_{s}", 0, 1, pulp.LpBinary) # 目标:总薪酬最小 prob += pulp.lpSum( x[(w, d, s)] * pay[type_of[w]] for w in worker_ids for d in dates for s in shifts ) # 约束1:每人每天最多一个班次 for w in worker_ids: for d in dates: prob += pulp.lpSum(x[(w, d, s)] for s in shifts) <= 1 # 约束2:连续工作约束,任意连续4天最多工作3天 for w in worker_ids: for i in range(len(dates) - 3): window = dates[i:i+4] prob += pulp.lpSum(x[(w, d, s)] for d in window for s in shifts) <= 3 # 约束3:每个营业小时的在岗人数 >= 需求 for d in dates: for h in hours: need = need_df.loc[ (need_df["date"] == d) & (need_df["hour"] == h), "need" ].iloc[0] on_duty = pulp.lpSum( x[(w, d, s)] for w in worker_ids for s in hour_to_shifts[h] ) prob += on_duty >= need prob.solve() if pulp.LpStatus[prob.status] != "Optimal": raise RuntimeError("排班无可行解,请缩小需求或启用软约束") return prob, x这个模型规模不大:30 个工人 × 7 天 × 3 班,大约 630 个 0-1 变量,PuLP 默认的 CBC 求解器秒出结果。100 人 × 14 天也就 4200 个变量,仍然在可解范围内。超过这个规模,或者展销期拉长到一个月,就建议换 OR-Tools CP-SAT 或者商用求解器,PuLP 会明显变慢。hour_to_shifts是关键桥梁,它把班次映射到小时,覆盖约束直接用它求和,改班次设计只改shifts字典,不用动约束代码。
注意:连续工作约束的滑动窗口
range(len(dates) - 3)要求展销期至少 4 天。如果只有 3 天,这组约束不需要加,代码里要先判断len(dates) > 3再循环。
3.3 目标函数:先最小成本,再压公平性偏差
第一个版本只把总薪酬最小化,数学建模 B 题通常还会要求公平性,比如每人总班次数差异不要太大。公平性目标常见做法有两种:加权求和,或者分层优化。我不推荐加权,因为成本和班次数的量纲差太多,成本是万级,班次数极差是个位数,权重稍微调不好就是玄学,成本被撑爆还看不出原因。
# 分层优化:先求最小成本,再在成本允许范围内压公平性 prob.solve() base_cost = pulp.value(prob.objective) # 增加公平性辅助变量:每个工人的总班次数 max_shifts = pulp.LpVariable("max_shifts", 0, None, pulp.LpInteger) min_shifts = pulp.LpVariable("min_shifts", 0, None, pulp.LpInteger) for w in worker_ids: total = pulp.lpSum(x[(w, d, s)] for d in dates for s in shifts) prob += total <= max_shifts prob += total >= min_shifts # 第一层成本解作为约束,允许上浮 5% prob += pulp.lpSum( x[(w, d, s)] * pay[type_of[w]] for w in worker_ids for d in dates for s in shifts ) <= base_cost * 1.05 # 换成第二层目标:班次数极差最小 prob.setObjective(max_shifts - min_shifts) prob.solve()分层优化的思路是:第一层先把成本解出来,记录最优值;第二层把成本控制在最优值的 1.05 倍以内,再去最小化班次数极差。这样公平性和成本都有保证,解也稳定。base_cost * 1.05这个 5% 是经验值,竞赛论文里写清楚“允许成本上浮不超过 5% 换取公平性”,评阅老师能看懂你的权衡。如果现场公平性要求更严,可以改成 1.02,但要注意第二层可能无解。
4. 完整论文代码的组织方式:从 CSV 数据到附录代码格式的主流程
模型单独能跑只是第一步。竞赛和实际交付要的是“一条命令从原始数据到结果”,论文附录还得能对上符号。这一章讲工程组织和附录落地的习惯。
4.1 目录结构与数据流:把算法和数据处理解耦
项目根目录按数据、代码、输出、文档四块拆开。理由很简单:排班模型大概率会被评委或现场负责人要求改约束,耦合的脚本一改就炸,数据和代码分开才能快速迭代。
| 路径 | 职责 | 关键输出 |
|---|---|---|
| data/visitors.csv | 每小时客流原始数据 | 输入 |
| data/coef.csv | 时段系数配置 | 输入 |
| data/workers.csv | 人员池 | 生成后保存 |
| src/demand_forecast.py | 需求折算 | data/demand.csv |
| src/recruit_model.py | 招聘规模决策 | n_normal, n_skilled |
| src/schedule_model.py | 排班优化 | 排班变量表 |
| src/export_excel.py | 结果导出 | output/schedule.xlsx |
| requirements.txt | 依赖锁版 | 环境复现 |
每个src下的脚本只干一件事,模块之间通过 CSV 或返回对象传递数据。比如demand_forecast.py只负责输出need_df,不关心后面是招人还是排班;schedule_model.py只接收 workers 和需求表,不负责读原始文件。这样的代码解耦方式,改需求预测的系数时不会碰排班代码,是我能放心交给队友改了不炸的关键。
提示:数据文件命名别用“新建文档(1).csv”这种默认名,脚本里路径写错一次,后面所有结果都是错的,还不好排查。
4.2 主流程脚本:一条命令从 CSV 跑到 Excel
主流程脚本把前面四个模块串起来,命令行参数控制输入输出路径,这样换数据时不用改代码。
# main.py import argparse from demand_forecast import build_demand from recruit_model import solve_recruit from make_workers import generate_workers from schedule_model import build_schedule from export_excel import export_schedule def main(): ap = argparse.ArgumentParser() ap.add_argument("--visitor", default="data/visitors.csv") ap.add_argument("--coef", default="data/coef.csv") ap.add_argument("--out", default="output/schedule.xlsx") args = ap.parse_args() need = build_demand(args.visitor, args.coef) n_normal, n_skilled, _ = solve_recruit(need) workers = generate_workers(n_normal, n_skilled) shifts = { "A": {"start": 8, "end": 16, "name": "早班"}, "B": {"start": 12, "end": 20, "name": "中班"}, "C": {"start": 14, "end": 22, "name": "晚班"}, } prob, x = build_schedule(workers, need, shifts) export_schedule(x, workers, shifts, args.out) print(f"求解状态: {pulp.LpStatus[prob.status]}") print(f"总成本: {pulp.value(prob.objective):.0f}") if __name__ == "__main__": main()运行命令也很简单:
python main.py --visitor data/visitors.csv --coef data/coef.csv --out output/schedule.xlsxexport_schedule把排班变量字典转成 Excel 表,论文里直接贴统计表用。输出至少三张表:班表明细、每人总班次数统计、每日在岗人数汇总。有了这三张表,覆盖情况、成本和公平性一眼就能看全。
# export_excel.py import pandas as pd def export_schedule(x, workers, shifts, out_path): rows = [] for (w, d, s), var in x.items(): if var.value() > 0.5: rows.append({"id": w, "date": d, "shift": shifts[s]["name"]}) df = pd.DataFrame(rows) with pd.ExcelWriter(out_path) as writer: df.to_excel(writer, sheet_name="班表明细", index=False) df.groupby("id").size().to_frame("总班次数").to_excel( writer, sheet_name="每人统计") df.groupby("date").size().to_frame("在岗人数").to_excel( writer, sheet_name="每日汇总")4.3 附录代码格式:示例代码怎么贴进论文才算完整
竞赛论文附录最常见的翻车现场是把整个脚本原样贴上去,几十页还没注释,评阅老师根本找不到模型公式对应的代码。我的习惯是附录只放三种代码:模型求解核心段、参数表生成段、一个运行说明。每个代码块都要能对上论文公式。
| 论文符号 | 代码对象 | 含义 |
|---|---|---|
| w | worker_id | 临时工编号 |
| d | date | 展销日期 |
| h | hour | 营业小时 |
| s | shift | 班次 |
| x_{w,d,s} | x[(w,d,s)] | 工人 w 在 d 天是否上 s 班 |
代码注释里直接写论文公式编号,是最省事的做法。比如排班模型里那组连续工作约束,附录示例代码就写成:
# 对应论文式(12):连续工作约束 # 任意连续4天最多工作3天 for w in worker_ids: for i in range(len(dates) - 3): prob += pulp.lpSum( x[(w, d, s)] for d in dates[i:i+4] for s in shifts ) <= 3附录代码格式按“数据处理—模型求解—结果导出”三段组织。数据预处理和画图这种辅助代码不放正文附录,放提交包的extras目录。代码规范方面,我写完会用 flake8 检查一遍缩进和未引用变量,变量命名尽量贴近论文符号,随机种子固定,requirements.txt锁版本。这些细节不会让你获奖,但能保证复现的人不骂你。
5. 排班优化避坑自查:五个最容易翻车的建模细节
模型小步快跑,坑大多在看不见的地方。下面五个问题是我在排班优化项目里真实踩过的,按照现象、原因、解决三段写,排查时照着对。
5.1 模型侧的三个翻车现场
先给一个自查缺口的诊断函数,排查无解和覆盖不足时先跑它,能省掉一半时间。它把每个营业小时的在岗人数和需求打印出来,一眼看出哪个时段漏了。
# check_coverage.py def check_coverage(x, need_df, shifts, dates, hours): hour_to_shifts = build_hour_map(shifts) for d in dates: for h in hours: on_duty = sum( var.value() for (w, dd, s), var in x.items() if dd == d and s in hour_to_shifts[h] ) need = need_df.loc[ (need_df["date"] == d) & (need_df["hour"] == h), "need" ].iloc[0] if on_duty < need: print(f"{d} {h:02d}时: 缺口 {need - on_duty}")第一个翻车现场是求解器报不可行,LpStatus[prob.status]返回Infeasible。原因是连续工作约束和覆盖需求打架,尤其是最后一天清仓晚班需求高,但该上班的人前面已经连续上了 4 天。解决方法是先把连续工作约束注释掉,验证纯覆盖是否可行;纯覆盖可行就说明是约束冲突,启用第 6 章的软约束,或者把需求整体放宽 5% 再跑。
第二个翻车现场是覆盖小时边界算错,需求虚高。现象是总人数比直觉多,成本超支。原因多半是班次覆盖小时映射写成了闭区间,比如早班 8:00-16:00,把 16 点也算进去了,或者 22:00 需求没人认领。解决方法是把班次表先静态打印一遍,对照hour_to_shifts检查每个小时有几班覆盖,确认 9 点和 21 点这种边界小时不丢。
第三个翻车现场是加权公平目标把成本撑爆。现象是给不公平目标加了 0.001 权重之后,成本比纯成本解高一截,不公平问题也没解决。原因是成本量级几万、班次数极差是个位数,0.001 权重相当于不存在,但求解器偶尔会为了极小成本牺牲公平性,结果两边都不满意。解决方法是换成 3.3 的分层优化,成本控制在 5% 以内再压公平性,别用加权。
5.2 执行侧的常见问题
第四个坑是依赖和环境不一致。现象是队友跑main.py报ModuleNotFoundError: No module named 'pulp',或者导出的 Excel 中文乱码。原因是没锁requirements.txt,PuLP 和 openpyxl 版本不一样行为也不同。解决方法是项目根目录放一个requirements.txt:
pulp>=2.7 pandas>=1.5 numpy>=1.23 openpyxl>=3.1主流程入口加一个 try-except,缺包时直接提示安装命令,别让队友对着报错猜。
第五个坑是鸽子率。现象是排班表百分百满员,现场执行时总有人临时不来,缺口一个岗大家互相推。原因是确定性模型假设全员出勤,而展销会临时工按天结算,放鸽子是常态。解决方法是覆盖需求整体乘 1.1 的缓冲系数,收银、清场这类关键岗位安排熟练工双岗;模型里把覆盖约束设成软约束,缺岗有惩罚但不报无解,现场按惩罚清单追补人。这条是实战和竞赛最大的区别,竞赛里没人放鸽子,现场真的会。
6. 最后一招:把硬约束改成带惩罚的软约束,给无解留后悔药
排班模型最怕硬约束无解,一报Infeasible整个流程就断了。我的习惯是给覆盖约束留一个“缺口变量”,把“必须满足需求”改成“尽量满足,缺岗扣钱”。这样模型永远有解,缺多少一眼能看见。
# 软约束改造:覆盖缺口的惩罚项 M = 10000 # 远大于单班成本,可调 shortage = {} for d in dates: for h in hours: shortage[(d, h)] = pulp.LpVariable( f"short_{d}_{h}", 0, None, pulp.LpInteger ) on_duty = pulp.lpSum( x[(w, d, s)] for w in worker_ids for s in hour_to_shifts[h] ) prob += on_duty + shortage[(d, h)] >= need # 目标里加上惩罚:一处缺岗扣 M 元 prob += M * pulp.lpSum(shortage.values())M 的取值有讲究。日薪 120 的场景,M 取 10000,相当于缺一个岗的代价抵 83 个班次,模型只有在实在排不开时才肯缺人。M 太小,比如取 1000,模型会故意缺一个人省 120 元班次成本;M 太大,比如取 100000,软约束退化成硬约束,求解时间变长。调 M 的方法很简单:先跑一遍看缺口数量和总成本,再翻倍 M 跑一遍,如果结果没变说明 M 够大。
软约束解出来之后,一定要看三张表再决定能不能交付:覆盖缺口表、连续工作天数表、每人总班次数统计表。覆盖缺口表看每个营业小时有没有shortage不为 0,有缺口就说明需求有突刺或者人不够;连续工作天数表看有没有人连上 5 天,超过 4 天就是约束有 bug;班次数极差表看公平性,极差超过 3,现场就会有怨气。
我早期做类似活动排班时直接上硬约束,遇到无解就开始松休息约束,最后排出一张没人愿意干的班表,现场实际执行只能用白班加晚班硬顶。后来改成软约束加缺口可视化,才真正敢把排班表交出去。先把硬约束跑通求最优,再把软约束当底线兜底,缺人的地方在表上标红,让现场负责人自己判断是加人还是调岗,这才是排班优化代码该有的交付方式。希望帮到你。
本文还有配套的精品资源,点击获取