简介:2022年MathorCup数学建模竞赛D题题解包,完整包含解题思路与论文代码,适合备赛学生以及将数学建模用于毕业设计的学生。压缩包为zip格式,共35个文件,涉及10个Python脚本、8个Excel数据表、5张可视化结果图、3份Word题解说明和PDF赛题原文,整体约11.65MB;脚本围绕数据预处理、肘方法确定K值、业务量计算、基站类型选择、未覆盖点识别及结果绘图等关键步骤组织,每个环节都配有对应表格输出,方便直接验证与复盘。已有498人学习下载。题解包按问题求解链路展开,既提供分步可运行的建模代码,也配有详细的文字题解和数据分析说明,既可完整复现D题结论,也可作为同类选址覆盖优化问题的参考模板,帮助读者掌握从模型构建、算法实现到结果分析的一整套实践方法,对备赛和毕业设计都有实际帮助。
1. 2022年mathercup数学建模比赛D题题解:这道配送优化题到底在考什么
搜过2022年mathercup数学建模比赛D题题解的人,大概率是同一个状态:题目看明白了,模型想出来了,但一到写论文和调代码就卡住。当年D题做的是疫情封控下社区生活物资的应急配送优化,本质是带容量约束的车辆路径问题(CVRP),难不在算法有多前沿,而在怎么把题面里的“车、货、点、时间”变成既能写进论文、又能跑出结果的数学表达。这篇题解把我当时的做法完整讲一遍:思路怎么拆、代码怎么落地、参数怎么调、论文怎么排,顺带把那些让人怀疑人生的坑一次说清。适合准备MathorCup、国赛或调度类题目的同学,新手能照着跑通,熟手可以对照模型边界与参数选择。
2. 拆思路:先分清三个决策层面,再写数学表达
2.1 路径、车辆分配和装载量:为什么三个层面不能混在一起
D题这类物资配送题目,本质上要回答三个问题:哪辆车去送、这辆车先去谁家后去谁家、每辆车装多少不会超载。这三个问题对应路径、车辆分配、装载量三个决策层面。常见误区是把它们塞进一个序列里,比如直接套TSP,只求一条经过所有需求点的最短环线。一旦题目里出现“多辆车”“每辆车容量有限”“每个点的需求量不同”,TSP单回路模型就装不下,因为它没有“运力”概念,也不拒绝一辆车跑完全部。
所以我一般把决策变量拆成两层:定义 x_ijk 表示第 k 辆车从 i 点开到 j 点;定义 y_ik 表示第 k 辆车服务 i 点。路径层由 x 描述,分配层由 y 描述,装载量则通过需求 d_i 和容量 Q 在约束里体现。虽然从逻辑上 x 可以推出 y,但分开写更利于检查约束和写论文。这是解这类题最基本的建模判断,也是后面所有代码的起点。
2.2 用数学符号把约束写清楚:一个最小可行的D题模型
先把记号列出来,写论文时也可以直接套用:
| 记号 | 含义 |
|---|---|
| V={0,1,2,...,n} | 节点集合,0 为配送中心 |
| K | 可用车辆集合 |
| Q | 单辆车容量(题面若给不同容量则按车辆分开列) |
| d_i | 节点 i 的物资需求量,d_0=0 |
| c_ij | 从 i 到 j 的行驶距离或时间成本 |
决策变量:
- x_ijk = 1 表示第 k 辆车从 i 驶向 j
- y_ik = 1 表示第 k 辆车服务节点 i
目标函数是最小化总配送距离:
min Σ_{k∈K} Σ_{i∈V} Σ_{j∈V} c_ij · x_ijk
约束至少要有这么几条。第一,每个需求点必须被恰好服务一次:对任意 i≥1,Σ_k y_ik = 1。第二,流量守恒:如果车辆 k 进入节点 j,它也必须从 j 离开,否则车辆会“消失”。第三,容量约束:车辆 k 服务的所有需求点需求量之和不超过 Q,即 Σ_{i≥1} d_i · y_ik ≤ Q。第四,每辆车从配送中心出发并最终返回配送中心,所以对每辆车要强制起终点都是 0 号点。最后还要消除子回路,否则求解器会给出一个不包含配送中心的独立环路。
如果题面里有时间窗,比如每个需求点有最早/最晚服务时间 [e_i, l_i],还要加时间变量和到达时间递推约束:t_j ≥ t_i + s_i + t_ij 且 e_i ≤ t_i ≤ l_i,其中 s_i 是服务时长。2022年这道题主要卡在容量和车辆数上,但很多变体题会加时间窗,建模时先把时间字段读清楚再做决定。
2.3 选型:为什么先用精确求解器验证小样例,而不是直接上遗传算法
很多队伍一看到车辆路径就写遗传算法,理由往往是“VRP经典解法”。但我建议先别急着上GA。遗传算法有三个先天问题:一是解的质量依赖种群规模和迭代次数,调参像玄学;二是它很难证明当前解是最优;三是所有硬约束都要通过惩罚项塞进适应度函数,罚太轻不满足约束,罚太重解明显畸形。对D题这种几十个点的小规模问题,完全可以用精确或约束规划方法先拿到一个可证明接近最优的解。
我习惯的路线是:先用 ortools 的 routing 库直接求解,它内部是约束规划加局部搜索,对“若干辆车、容量约束、配送中心”这类模型支持得很好。如果数据量特别大(几百个点),再考虑改成自适应大邻域搜索或遗传算法,并保留 ortools 的解做初值。还有一个很实际的原因:写论文时“ortools求解加上结果表”很实在,评审能复现;写“遗传算法+调参调了两周”容易变成黑匣子。模型先立住,后面做灵敏度分析才有底气。
为了确认模型本身没错,我会先用三个需求点、一辆车、总需求小于容量的小样例手算一遍:最优解显然是配送中心绕这三个点一圈回来。把这个结果和求解器输出对一下,如果一致,说明约束没有写歪;如果不一致,先用这个mini样例定位问题。这一步看起来简单,却是我在比赛里用过最有效的debug方式。接下来进入代码。
3. 代码落地:用ortools在本地跑通D题的最小复现流程
3.1 数据准备:把题面里的配送表转成距离矩阵
赛题一般会给一张配送点表,包含编号、坐标和需求量,配送中心通常编号为0。先把表存成CSV,格式如下:
| id | x | y | demand |
|---|---|---|---|
| 0 | 45 | 32 | 0 |
| 1 | 23 | 12 | 8 |
| 2 | 60 | 8 | 5 |
| 3 | 12 | 45 | 4 |
| 4 | 78 | 30 | 6 |
| 5 | 35 | 55 | 7 |
导入pandas并构建距离矩阵:
import pandas as pd import numpy as np df = pd.read_csv("delivery_points.csv") print(df.head()) coords = df[["x", "y"]].values num_nodes = len(df) # 欧氏距离矩阵,dist[i][j] 表示点 i 到点 j 的距离 dist = np.sqrt(((coords[:, None, :] - coords[None, :, :]) ** 2).sum(axis=2)) np.fill_diagonal(dist, 0)这段代码把DataFrame里的坐标取出来,用广播计算所有点对之间的欧氏距离,得到一个n乘n矩阵。第0行是配送中心到各需求点的距离。需要注意两点:如果赛题给的是经纬度坐标,直接用欧氏距离会有误差,需要改成Haversine公式;如果坐标是平面直角坐标,欧氏距离没问题,但计算前最好确认横纵轴单位一致。距离矩阵是后面所有模型和可视化的公共输入,建议存成npy文件,免得不同模块重复算。
np.save("distance_matrix.npy", dist) demands = df["demand"].values3.2 建模代码:容量约束、车辆数量和求解参数一次说清
我用ortools的routing库写一个最小可运行模型,包含容量约束和车辆数量。注意ortools对arc cost要求整数,如果距离有小数,可以先放大取整。
from ortools.constraint_solver import routing_enums_pb2, pywrapcp def solve_vrp(dist, demands, num_vehicles=2, capacity=100, time_limit_sec=60): num_nodes = len(dist) manager = pywrapcp.RoutingIndexManager(num_nodes, num_vehicles, 0) routing = pywrapcp.RoutingModel(manager) # 距离回调:ortools 要求返回整数,dist 是 numpy 浮点数要显式转换 def dist_callback(from_idx, to_idx): node_from = manager.IndexToNode(from_idx) node_to = manager.IndexToNode(to_idx) return int(dist[node_from][node_to]) transit_cb = routing.RegisterTransitCallback(dist_callback) routing.SetArcCostEvaluatorOfAllVehicles(transit_cb) # 需求回调:配送中心 0 号点需求记为 0,避免占用容量 def demand_callback(from_idx): node = manager.IndexToNode(from_idx) return demands[node] if node != 0 else 0 demand_cb = routing.RegisterUnaryTransitCallback(demand_callback) routing.AddDimensionWithVehicleCapacity( demand_cb, 0, # slack 上限 [capacity] * num_vehicles, True, # 车辆在起点累计装载为 0 "Capacity" ) search_params = pywrapcp.DefaultRoutingSearchParameters() search_params.first_solution_strategy = ( routing_enums_pb2.FirstSolutionStrategy.PATH_CHEAPEST_ARC ) search_params.time_limit.seconds = time_limit_sec solution = routing.SolveWithParameters(search_params) return manager, routing, solution这里最关键的是两个回调。距离回调把弧段成本交给求解器,所有后续目标值都由它算;需求回调告诉模型每个点的物资需求。注意 AddDimensionWithVehicleCapacity 的第二个参数是 slack 上限,0 表示不允许在点之间累积多余余量;第三个参数传入列表,长度必须等于车辆数,如果各车载重不同,就改成 [c1, c2, ...] 而不是 [capacity] * num_vehicles。第四个参数传 True,表示车辆从配送中心出发时装载量为 0。起点 0 号点由 RoutingIndexManager 的第三个参数指定,所有车辆默认从它出发并回到它。
如果 dist 里的数值大量落在 0 到 1 之间,直接 int() 会把很多弧段成本变成 0,导致求解器看不到距离差异。这种情况我一般先乘 100 再取整:
return int(dist[node_from][node_to] * 100)后面的 ObjectiveValue 也相应放大了 100 倍,结果展示时再除以 100 还原,论文里注明单位换算即可。
3.3 结果解析:从求解对象里抽出路线和目标值
求解器返回的是一个Solution对象,直接打印看不出哪条路。需要遍历每辆车的路径:
def extract_routes(manager, routing, solution): routes = [] for vehicle_idx in range(manager.GetNumberOfVehicles()): index = routing.Start(vehicle_idx) route = [] while not routing.IsEnd(index): route.append(manager.IndexToNode(index)) index = solution.Value(routing.NextVar(index)) route.append(manager.IndexToNode(index)) routes.append(route) total_cost = solution.ObjectiveValue() return routes, total_cost这段代码从每辆车的起点出发,沿着 NextVar 不停走到终点,记录下一串节点编号。注意每条 route 的首尾都是 0,表示配送中心。ObjectiveValue 返回所有弧段成本之和,如果在距离回调里乘了 100,这里要除以 100 才是真实总里程。打印出来大概是:
Route 0: 0 -> 3 -> 1 -> 0, cost 68 Route 1: 0 -> 2 -> 4 -> 5 -> 0, cost 45 Total cost: 113看到这样的输出,就说明求解器已经找到了一个合法解。如果某辆车只有 0 -> 0,说明这辆车没被用到,这在车辆数大于需求数时很正常,论文里可以写明“实际启用车辆数为多少”。
3.4 参数调整:车辆数、容量和time_limit的联动关系
参数调整直接影响解质量和论文结果表,列出我常用的对照:
| 参数 | 位置 | 影响 | 建议 |
|---|---|---|---|
| num_vehicles | manager 参数 | 车辆多会绕路,车辆少会无解 | 用 ceil(总需求/单车载重) 作下界 |
| capacity | AddDimensionWithVehicleCapacity | 容量约束的松紧 | 以题面为准,不要自己改 |
| time_limit_sec | search_params | 求解时间上限 | 比赛建议 60~120 秒 |
| first_solution_strategy | search_params | 初始解构建方式 | PATH_CHEAPEST_ARC 通用,SAVINGS 更快 |
如果题目没给车辆数,先用math.ceil(sum(demands) / capacity)算最少车辆数,再往上加一辆观察总里程是否下降。每次调整参数后要重新提取 routes 并保存,因为后面论文里所有结果表、路线图、灵敏度分析都以这份 routes 为准。
提示:实际比赛中 time_limit 不建议低于 30 秒,否则路线图会显得很碎,而且论文里的“求解时间”字段写不出好看的数字。
4. 论文写作:把题解从“能跑”包装成“能交”
4.1 论文骨架:摘要、假设、符号表、模型、评价的排布
D题评阅很看重“问题分析-模型建立-求解-验证”的闭环,不建议按代码顺序写论文。我习惯用下面这个结构:
- 摘要
- 问题重述与假设
- 符号说明
- 模型建立(目标函数与约束)
- 求解方法(ortools/GA 的参数)
- 求解结果(路线图、结果表)
- 灵敏度分析
- 模型评价
摘要别写“本文通过……”这种空话,要直接给结论。举例:针对 MathorCup D 题物资配送问题,建立以总配送里程最小为目标的 0-1 整数规划模型,用 ortools 求解器在 60 秒内求得总里程 113 的配送方案,并分析车辆数、容量对总里程的敏感性。这样一段话,评阅人能在十秒内知道你做了什么、用什么方法、结果怎样。
问题假设也很重要,不要写“假设所有车辆速度相同”这种一句带过的东西。要写清楚:坐标是平面直角坐标还是经纬度、距离成本是否等于时间成本、车辆是否限定了每辆车最大行驶里程。这些假设直接决定你的模型适用范围,也是灵敏度分析的前提。符号说明表放到模型前面,能让后面的公式看起来干净很多。
4.2 用matplotlib画可放论文的路线图与收敛图
路线图是D题论文里最直观的结果展示,评委基本都会先看图。画图时不同车辆用不同颜色,配送中心用醒目标记,需求点标上编号:
import matplotlib.pyplot as plt def plot_routes(coords, routes, save_path="routes.png"): plt.figure(figsize=(8, 6)) colors = ["#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd"] for veh_idx, route in enumerate(routes): color = colors[veh_idx % len(colors)] xs = [coords[node, 0] for node in route] ys = [coords[node, 1] for node in route] plt.plot(xs, ys, marker="o", color=color, linewidth=1.5, label=f"vehicle {veh_idx + 1}") for node in route: if node != 0: plt.text(coords[node, 0] + 0.5, coords[node, 1] + 0.5, str(node), fontsize=9) plt.scatter(coords[0, 0], coords[0, 1], c="red", s=90, marker="s", label="center", zorder=5) plt.xlabel("x") plt.ylabel("y") plt.legend() plt.grid(alpha=0.3) plt.savefig(save_path, dpi=300, bbox_inches="tight")这里 coords 是输入的 n×2 数组,routes 是 extract_routes 的输出,每条路线都是节点编号数组。画图时要注意 route 首尾的 0 号点会被重复画两次,但对连线没有影响。如果有车辆路线是空的,画出来会是中心点上的一堆点,建议在图例里标注“实际启用车辆数”,不要把所有空车都画进去,否则图会显得很乱。保存时用 dpi=300,论文插图基本够用。
4.3 灵敏度分析:一张参数表让评审快速建立信任
灵敏度分析不是赛后补的装饰,而是证明你理解模型参数作用的关键。D题最常见的分析对象就是车辆数和容量,二者都会影响总里程和实际启用车辆数。做法很简单:固定一个参数,扫描另一个参数,记录求解结果。
for cap in [80, 100, 120]: manager, routing, res = solve_vrp(dist, demands, num_vehicles=2, capacity=cap) routes, total = extract_routes(manager, routing, res) actual = sum(1 for r in routes if len(r) > 2) print(cap, total, actual)把输出整理成一张表,例如:
| 车辆数 | 容量 | 总里程(演示数据) | 实际启用车辆 |
|---|---|---|---|
| 2 | 100 | 113 | 2 |
| 3 | 100 | 98 | 3 |
| 2 | 80 | 无可行解 | - |
这张表不用大,但一定要配上文字分析。比如“容量从 100 降到 80 后出现无可行解,说明方案对运力非常敏感;车辆数从 2 增加到 3,总里程下降 13%,但更多车辆意味着更多出车成本”。这种分析能让论文从“跑代码交差”变成“有工程判断”。
5. 避坑:D题从数据到论文最容易翻车的五个现场
D题从数据到论文,翻车点其实很集中。我整理了五个最常见场景,每个按现象、原因、解决写,你在比赛里遇到任何一个都能少走弯路。
5.1 现象:把“服务时间”误当“时间窗”,模型直接无解
现象:导入数据后看到一列 time,想都没想就加了时间窗约束,求解器返回“无解”或持续搜索。 原因:有些题目里的时间是每个需求点的“卸货/服务时长”,不是“可到达的时间窗口”。服务时长只是把到达时间往后推,并不限制车辆必须在一个区间内到达。 解决:先做字段清单,把“服务时间”“时间窗”“最早/最晚到达”“车速”分开。确认题目原话是“最晚送达不超过 X 点”才加硬时间窗;如果只是服务时长,在约束里加一个 t_j ≥ t_i + s_i + t_ij 即可,不需要限制上下界。
5.2 现象:求解器长时间不返回,原因却在容量维度
现象:数据只有 20 个点,运行却好几分钟不出结果;改成更小的车辆数反而直接无解。 原因:最常见的是需求回调里把配送中心的需求算进去了,导致每辆车都被白白占掉一份容量;其次是容量列表长度和车辆数不一致,求解器把一部分车辆视为 0 容量。 解决:在 demand_callback 里显式判断 node == 0 时返回 0;用assert len(capacity_list) == num_vehicles检查列表长度。还有个排查技巧:先把容量设成极大值跑一遍,如果能快速出解,说明瓶颈就在容量约束上。
5.3 现象:距离矩阵有小数,解出来的路线反而绕远
现象:坐标算出来 dist 是小数,打印 route 看起来方向合理,但总里程比手算高很多。 原因:ortools 要求 arc cost 是整数,小数距离被直接 int() 截断,比如 0.6 变成 0,很多弧段成本失真,求解器“看不见”绕路成本。 解决:距离先乘以倍数再取整,例如int(dist[i][j] * 100),求解后再除以 100 还原。在论文中注明“距离单位统一到 0.01 km”。如果坐标是经纬度,用 Haversine 公式算公里数,再同样缩放取整。
5.4 现象:论文摘要写完了,模型一验算就露馅
现象:摘要里写了“求得总里程最小值为 113”,画好路线图,再跑一天数据却得到不同结果,只能临时改数字。 原因:摘要写得太早,结果没经过小样例验证;或者求解器每次跑的 time_limit 不同导致解不同。 解决:写摘要前先用 3~5 个点的极小数据手算一遍,确认模型正确的那个值;再固定 time_limit 和随机种子跑正式数据,把求解器输出、路线图、结果表一次性生成,最后再动笔写摘要。这样摘要里的每个数字都有出处。
5.5 现象:队友电脑复现不出你的结果
现象:同一个脚本,你的机器出解,队友机器要么报 IndexError 要么解完全不同。 原因:数据文件用了相对路径、Python 版本或 ortools 版本不一致、脚本中某些操作在不同架构上行为不同。 解决:脚本启动时先校验数据文件存在性,路径用Path(__file__).parent定位;在脚本头部打印 ortools 版本和 Python 版本;如果目标函数依赖浮点精度,固定放大倍数。把运行环境和命令行写进论文附录,对“可复现性”反而是加分项。
6. 交稿前最后一招:用check脚本把隐藏炸弹拆掉
6.1 check脚本要查哪几个不变量
前面所有步骤都可能出错,特别是手改车辆数、容量后,routes 可能悄悄不合法。我习惯在交稿前跑一遍校验脚本,不通过就绝不动笔写最终摘要。校验逻辑固定查四件事:每辆车是否从中心出发并回到中心、每条路线需求量是否超容量、每个需求点是否被恰好服务一次、总里程是否和求解器 ObjectiveValue 一致。
def check_solution(routes, demands, capacity, dist, objective_value=None): served = [] errors = [] total_by_routes = 0 for veh_idx, route in enumerate(routes): if route[0] != 0 or route[-1] != 0: errors.append(f"vehicle {veh_idx}: 起点或终点不是配送中心") load = sum(demands[node] for node in route if node != 0) if load > capacity: errors.append(f"vehicle {veh_idx}: 超载 {load}/{capacity}") served += route[1:-1] total_by_routes += sum(dist[route[i]][route[i + 1]] for i in range(len(route) - 1)) expected = [i for i in range(1, len(demands))] if sorted(served) != expected: errors.append("需求点有重复或遗漏") if objective_value is not None and abs(total_by_routes - objective_value) > 1e-6: errors.append("总里程与求解器目标值不一致") return errors这段代码不依赖 ortools,只吃 routes、demands、capacity、dist,纯算数就能找出问题。served 列表把每条 route 的中间节点全部收集,再排序和期望集合比对,重复和遗漏都会暴露。容量检查里route if node != 0是为了不把中心点的 0 需求重复累加,其实加上也不影响,但写成这样更明确。
我自己的血泪经验是,去年解 D 题时 route 解析脚本里不小心把配送中心编号写成了 1,导致每条 route 都在一个错误节点上,但路线图看起来很规律,摘要也写了“总里程 113”——直到跑 check 脚本才发现需求点编号错位,超载没被真正检查。从那以后,check 脚本成了交稿前的固定动作,哪怕只改了 time_limit 也要重跑一遍。希望帮到你。
本文还有配套的精品资源,点击获取