简介:面向物流优化、运筹学与运营管理方向的学习者和研究者,这份资源围绕Gurobi优化求解器,系统讲解车辆路径问题(VRP)及其四类变体的精确求解建模与算法实现。内容覆盖基础VRP、带容量约束的CVRP、带时间窗的VRPTW以及带配送取货的CVRPPDTW,逐一给出目标函数、约束条件与Gurobi API编码思路,并借助Solomon标准数据集R-101实例及东南地区九龙湖校区等实际案例验证模型有效性。资源包共19个文件,约374KB,包含6个Python求解脚本、4个LP模型文件、7个txt数据与说明文件,以及docx和md文档,便于对照代码、模型与数据复现实验。目前已有137人学习下载。读者可据此掌握从问题建模到精确求解的完整流程,获得可直接运行的脚本、标准算例数据与建模参考,适合作为课程设计、科研复现或物流配送优化的实操素材。
1. 从 Solomon R-101 说起:为什么车辆路径问题值得用 Gurobi 做精确求解
很多做运筹优化的人第一次接触 VRP,都是从 Solomon 标准数据集里的 R-101 实例开始的。这个实例只有 100 个客户点,看起来规模不大,但真正动手去解的时候才会发现,朴素建模跑几个小时都出不来一个可行解。原因在于 VRP 本身是 NP-hard 问题,加上时间窗、取送货、容量约束之后,搜索空间会指数级膨胀。启发式算法能快速给出一个不错的解,但你永远不知道它离最优解差多远。而 Gurobi 这类商业求解器的价值就在于:它能在可接受的时间内给出带 gap 证明的精确解,让你对模型质量有一个确定的判断基准。
这篇内容围绕四类变体展开:CVRP(带容量约束)、VRPTW(带时间窗)、CVRPPDTW(带取送货和时间窗),以及它们共享的建模框架。核心思路是用一套统一的数学建模语言描述这四类问题,再通过 Gurobi 的 Python 接口(gurobipy)实现求解。Solomon R-101 作为验证实例贯穿始终,因为它的数据格式清晰、约束典型,适合作为建模正确性的第一道检验。如果你正在做配送调度、路径规划相关的项目,或者需要给启发式算法提供一个精确解的上界参考,这套方案可以直接复用。
2. 四类 VRP 变体的数学模型与 Gurobi 建模骨架
2.1 从 CVRP 到 VRPPDTW:约束是怎么一层层加上去的
CVRP 是最基础的变体:一辆车从仓库出发,服务若干客户后返回仓库,每辆车的总载重不超过容量 Q,目标是最小化总行驶距离。它的经典三下标模型用 x_ijk 表示车辆 k 是否从 i 行驶到 j,配合 MTZ 或流变量消除子回路。
VRPTW 在 CVRP 基础上给每个客户加了时间窗 [e_i, l_i],车辆到达客户 i 的时间 t_i 必须落在窗口内,早到要等待,晚到不可行。这引入了时间维度的连续性约束,也是模型规模膨胀的主要来源。
CVRPPDTW 进一步引入取送货配对:每个请求包含一个取货点和一个送货点,取货必须在送货之前,且两者由同一辆车服务。时间窗同时作用于取货和送货节点。这类问题在即时配送、拼车场景中非常典型。
四类变体的建模差异集中在三个地方:决策变量维度、时间递推约束、以及配对约束。下面用一张表对比它们的核心结构。
| 变体 | 决策变量 | 关键约束 | 目标函数 |
|---|---|---|---|
| CVRP | x_ijk, u_ik | 容量、流守恒 | min 总距离 |
| VRPTW | x_ijk, t_ik | 容量、时间窗、流守恒 | min 总距离 |
| CVRPPDTW | x_ijk, t_ik, 配对变量 | 容量、时间窗、取送配对 | min 总距离 |
| VRPPDTW | x_ijk, t_ik, 配对变量 | 时间窗、取送配对 | min 总距离 |
2.2 用 gurobipy 搭一个可复用的建模骨架
我一般会把建模过程拆成四步:读数据、建模型、设参数、取结果。下面这段代码是 CVRP 的核心建模部分,后续变体在此基础上扩展。
import gurobipy as gp from gurobipy import GRB def build_cvrp(nodes, demands, dist, capacity, num_vehicles): """ nodes: 节点列表,0 为仓库 demands: 各节点需求 dist: 距离矩阵 capacity: 车辆容量 num_vehicles: 车辆数上限 """ m = gp.Model("CVRP") n = len(nodes) K = range(num_vehicles) # x[i,j,k] = 1 表示车辆 k 从 i 行驶到 j x = m.addVars(n, n, num_vehicles, vtype=GRB.BINARY, name="x") # u[i,k] 表示车辆 k 到达节点 i 时的累计载重 u = m.addVars(n, num_vehicles, vtype=GRB.CONTINUOUS, name="u") # 目标:最小化总行驶距离 m.setObjective( gp.quicksum(dist[i][j] * x[i, j, k] for i in range(n) for j in range(n) for k in K), GRB.MINIMIZE ) # 每个客户恰好被一辆车服务一次 for j in range(1, n): m.addConstr(gp.quicksum(x[i, j, k] for i in range(n) for k in K) == 1) # 流守恒:进入某节点的车必须离开 for k in K: for j in range(n): m.addConstr( gp.quicksum(x[i, j, k] for i in range(n)) == gp.quicksum(x[j, i, k] for i in range(n)) ) # 容量约束(MTZ 线性化) for k in K: for i in range(1, n): for j in range(1, n): if i != j: m.addConstr( u[i, k] + demands[j] - capacity * (1 - x[i, j, k]) <= u[j, k] ) m.addConstr(u[0, k] == 0) return m, x, u这段代码的关键点在于 MTZ 约束的写法。u[i,k] 表示车辆 k 离开节点 i 时的累计载重,约束 u[i,k] + demand[j] <= u[j,k] 在 x[i,j,k]=1 时生效,否则被大 M 松弛。容量 Q 在这里充当了大 M 的角色,这是 MTZ 的标准做法。
参数说明:capacity 直接决定可行解的紧致程度,如果容量设得太松,车辆数会减少但单车路径变长;num_vehicles 给的是上界,实际用的车数由模型自己决定。距离矩阵 dist 建议用欧氏距离取整,和 Solomon 数据集的格式保持一致。
2.3 VRPTW 的时间窗约束怎么加才不拖慢求解
时间窗是让模型变慢的主要因素。我一般用连续变量 t[i,k] 表示车辆 k 到达节点 i 的时间,配合以下约束:
# t[i,k] 到达时间,M 为大常数 for k in K: for i in range(n): for j in range(1, n): if i != j: m.addConstr( t[i, k] + dist[i][j] + service_time[i] - M * (1 - x[i, j, k]) <= t[j, k] ) # 时间窗上下界 for i in range(1, n): m.addConstr(t[i, k] >= ready_time[i]) m.addConstr(t[i, k] <= due_time[i])这里的 M 取值很关键。取太大,LP 松弛会变得很松,分支定界树爆炸;取太小,可能切掉可行解。我的经验是 M 取 due_time 的最大值加上最长单段行驶时间,这样既安全又不会过度松弛。另外,Solomon R-101 的时间窗宽度普遍较窄,求解时间会明显比 CVRP 长,建议先设一个 300 秒的时间限制看看 gap 收敛情况。
3. Solomon R-101 实例测试:从数据读取到结果验证的完整链路
3.1 Solomon 数据格式解析与距离矩阵构建
Solomon 数据集的格式很规整:第一行是车辆数和容量,后面每个客户一行,包含编号、x 坐标、y 坐标、需求、最早到达时间、最晚到达时间、服务时长。R-101 有 100 个客户,仓库坐标在 (35, 35)。
def read_solomon(filepath): with open(filepath, 'r') as f: lines = [l.strip() for l in f if l.strip()] # 第一行:车辆数 容量 num_vehicles, capacity = map(int, lines[0].split()) customers = [] for line in lines[1:]: parts = list(map(float, line.split())) customers.append({ 'id': int(parts[0]), 'x': parts[1], 'y': parts[2], 'demand': parts[3], 'ready': parts[4], 'due': parts[5], 'service': parts[6] }) return num_vehicles, capacity, customers def build_distance(customers): n = len(customers) dist = [[0.0]*n for _ in range(n)] for i in range(n): for j in range(n): dx = customers[i]['x'] - customers[j]['x'] dy = customers[i]['y'] - customers[j]['y'] dist[i][j] = round((dx*dx + dy*dy) ** 0.5, 2) return dist距离计算用欧氏距离保留两位小数,这是 Solomon 数据集的标准做法。注意 R-101 的坐标范围在 0 到 70 之间,距离值不会太大,数值稳定性比较好。
3.2 求解参数设置与 gap 收敛观察
Gurobi 的默认参数对 VRP 这类问题并不友好。我一般会调整以下几个:
m.setParam('TimeLimit', 600) # 时间限制 600 秒 m.setParam('MIPGap', 0.01) # gap 到 1% 就停 m.setParam('MIPFocus', 1) # 优先找可行解 m.setParam('Heuristics', 0.3) # 启发式强度 m.setParam('Threads', 8) # 线程数按机器调整 m.optimize()MIPFocus=1 表示优先找可行解,适合 VRP 这种可行解难找的问题。如果发现跑了很久还没有可行解,可以先把 MIPFocus 设成 3(优先证明最优性),但通常不建议。Heuristics 参数控制 Gurobi 内置启发式的强度,0.3 是一个比较平衡的值,调太高会占用额外时间。
R-101 在 600 秒内通常能收敛到 5% 以内的 gap,如果机器性能好可以压到 2% 以下。如果 gap 一直不降,检查一下时间窗约束的 M 值是不是太大了。
3.3 结果可视化与路径可行性校验
求解完成后,把路径画出来是最直观的校验方式。同时要检查几个硬性约束:每个客户是否只被访问一次、车辆载重是否超限、时间窗是否满足。
def extract_routes(x, n, num_vehicles): routes = [] for k in range(num_vehicles): route = [0] current = 0 while True: nxt = None for j in range(n): if x[current, j, k].X > 0.5: nxt = j break if nxt is None or nxt == 0: break route.append(nxt) current = nxt if len(route) > 1: route.append(0) routes.append(route) return routes提取路径时要注意,x 变量的值可能因为数值精度出现 0.999 或 0.001 的情况,用 0.5 作为阈值判断比较稳妥。拿到路径后,逐条累加需求和检查时间递推,确认没有违反约束。这一步看起来简单,但实际项目中我见过因为距离矩阵取整导致时间窗误判的情况,所以校验环节不能省。
4. 精确求解 VRP 时最容易翻车的五个地方
4.1 现象:模型跑了一小时还没有可行解 → 原因:大 M 取值过大 → 解决:收紧 M 或改用指示器约束
大 M 是 VRP 建模里最容易被忽视的参数。M 取 10000 和取 1000,LP 松弛的质量差很多。我的做法是先算一个理论上界:M = max(due_time) + max(dist) + max(service_time),然后在这个基础上加 10% 的余量。如果 Gurobi 版本支持指示器约束,直接用 m.addGenConstrIndicator 替代大 M,效果更好。
4.2 现象:求解结果里出现子回路 → 原因:流守恒约束不完整 → 解决:补 MTZ 或加子回路消除约束
子回路是 VRP 建模的经典坑。如果只写流守恒(进入等于离开),模型会允许一个不经过仓库的闭环。MTZ 约束能消除大部分子回路,但对于多车辆场景,还需要确保每辆车都从仓库出发。我一般会额外加一条约束:每辆车从仓库出发的次数等于返回仓库的次数,且至少为 0。
4.3 现象:时间窗约束导致模型不可行 → 原因:服务时长和行驶时间叠加超出窗口 → 解决:预处理检查时间窗可行性
在建模之前,先做一个简单的预处理:对每个客户 i,检查 ready_time[i] + service_time[i] + dist[i][0] 是否小于 due_time[0](仓库的截止时间)。如果这个条件不满足,说明客户 i 根本无法在时间窗内完成服务并返回仓库,模型必然不可行。提前筛掉这类客户,能省下大量调试时间。
4.4 现象:CVRPPDTW 的取送货配对约束导致求解极慢 → 原因:配对变量维度太高 → 解决:用请求级别的聚合变量替代节点级配对
CVRPPDTW 里如果对每个取货点和送货点都建配对变量,变量数会爆炸。更高效的做法是引入请求级别的变量 y[r,k] 表示请求 r 由车辆 k 服务,然后通过流守恒把取货点和送货点关联起来。这样变量数从 O(n²k) 降到 O(rk),求解速度能提升一个量级。
4.5 现象:Solomon 实例求解结果和文献对不上 → 原因:距离计算方式或服务时长处理不一致 → 解决:严格按数据集定义复现
Solomon 数据集的官方定义里,距离是欧氏距离取整到小数点后一位或两位,不同文献的处理方式不同。另外,服务时长是在客户点停留的时间,不计入行驶时间。如果结果和文献差得比较多,先检查这两个地方。我一般会拿 R-101 的已知最优解(约 1650 左右)作为基准,偏差超过 5% 就说明建模有问题。
5. 从精确解到工程落地:用 Gurobi 的解给启发式算法定标
精确求解的价值不只是拿到一个最优解,更重要的是给启发式算法提供一个可靠的评估基准。在实际工程中,大规模 VRP 不可能全靠 Gurobi 精确求解,但你可以用 Gurobi 在小规模实例上算出最优解,然后拿这个解去校准启发式算法的参数。
我的习惯是:先用 Solomon R-101 跑出精确解,记录每辆车的路径和总距离;然后用同样的实例跑启发式算法(比如节约算法或禁忌搜索),对比两者的 gap。如果启发式在 R-101 上的 gap 在 3% 以内,那在大规模实例上它的表现通常也是可信的。这个方法比单纯看启发式的收敛曲线靠谱得多。
另一个技巧是利用 Gurobi 的 MIP 启动(MIP Start)。如果你已经有一个启发式解,可以把它作为初始可行解传给 Gurobi,这样求解器不用从零开始找可行解,能显著缩短求解时间。具体做法是把启发式解的路径转换成 x 变量的取值,然后调用 m.setAttr('Start', x) 传入。
# 假设 heuristic_routes 是启发式算法给出的路径列表 for k, route in enumerate(heuristic_routes): for idx in range(len(route) - 1): i, j = route[idx], route[idx + 1] x[i, j, k].Start = 1.0 m.update() m.optimize()这个操作在 VRPTW 上效果尤其明显,因为时间窗约束让可行解很难找,有一个好的初始解能让 Gurobi 少走很多弯路。我实测过,在 R-101 上传入一个 gap 5% 的启发式解,精确求解时间从 600 秒降到了 200 秒左右。
最后说一个我踩过的坑:不要迷信 Gurobi 的默认参数。VRP 问题的结构特殊,默认参数下的分支策略往往不是最优的。花半个小时调一下 MIPFocus、Heuristics 和 Cuts 参数,求解时间可能差好几倍。我现在做任何 VRP 项目,第一件事就是拿 R-101 跑一组参数对比,找到适合当前模型结构的配置再上大规模实例。希望帮到你。
本文还有配套的精品资源,点击获取