ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

基于Python的CVRP车辆路径问题求解:节约算法与2-opt优化实战

基于Python的CVRP车辆路径问题求解:节约算法与2-opt优化实战 我们做物流调度系统那会儿接手过不少这类需求几十个客户点几台载重不同的车老板只给一句把货送完油钱最少剩下全靠自己折腾。这就是典型的带容量约束的车辆路径问题CVRP——所有车辆从配送中心出发每个客户只服务一次每条路径上的总需求量不能超过车辆载重限制目标是让总行驶里程最短。这类问题说起来不难做起来全是坑今天就把我们实际项目里用的求解思路、Python实现和调试经验完整拆一遍。这篇文章适合三类人刚接触路径规划的算法工程师、物流配送系统的后端开发、以及正在做课程设计或论文复现的学生。内容从问题建模讲到启发式算法实现最后附上可视化验证和工程排查手册保证你能照着写出一套可运行的CVRP求解器。1. CVRP问题建模与核心难点拆解1.1 从VRP到CVRP容量约束到底约束了什么先理清基本概念。经典的旅行商问题TSP是一个旅行商从起点出发遍历所有客户点后返回找最短回路。VRP把场景扩展成多辆车现在有多个旅行商每辆车都从配送中心出发各自负责一部分客户点最后返回配送中心所有客户点被覆盖且只被覆盖一次。而CVRP在VRP基础上加了一条硬约束每辆车的载重是有限的。用生活化类比来说TSP是一个人逛完所有摊位VRP是几个人分工逛摊位CVRP则是几个人分工逛摊位但每个人最多只能提20斤东西。这条约束直接改变了问题的结构——你不能把所有客户塞进一条路线哪怕这样总距离最短因为一辆车装不下。数学模型上假设有 n 个客户每个客户 i 的需求量为 d_i车辆容量为 Q配送中心编号为0。一条可行路线上的客户集合 S 必须满足[ \sum_{i \in S} d_i \le Q ]目标函数是最小化所有车辆的行驶总距离[ \min \sum_{k \in K} \sum_{(i,j) \in A} c_{ij} x_{ijk} ]其中 c_{ij} 是从客户 i 到客户 j 的行驶距离x_{ijk} 是0-1决策变量表示车辆 k 是否经过弧 (i,j)。这里要特别注意容量约束不是唯一可能的约束但它是最基础的。实际项目里还会叠加时间窗客户要求几点前送到、最大行驶时长司机不能连续开太久、车辆数固定等但CVRP是所有这些变体的地基。地基打不好后面加再多约束也是白搭。1.2 组合爆炸与求解复杂度的现实意义很多人第一次写CVRP求解器时最直观的想法是穷举所有路线组合挑最优的。这个想法在 n5 时完全没问题在 n10 时勉强能忍到了 n50 就彻底歇菜。CVRP是个NP-hard问题。什么意思呢随着客户点数量增加可行解的规模呈指数级暴增。粗略估算一下假设有 20 个客户和 4 辆车一辆车的路线本质上是一个客户序列的子集排列所有可能的路径划分方式数量级在 (O(n!)) 附近。20的阶乘是 2.43×10^18你就算每秒评估一百万条路线也得跑七万多年。这就是为什么工程上几乎不做精确求解。精确算法分支定界、割平面、列生成确实能找到理论最优解但它们的时间成本太高通常只适用于 n≤50 的小规模场景而且对代码实现的要求极其苛刻。实际业务里一个三线城市的配送站一天要送 200 个点老板要的是半小时内给我一套能用的路线不是三天后我告诉你最优解。所以工程落地的核心思路是在解质量和计算时间之间找平衡。我们不需要证明某个解是最优的只需要保证它在可接受的时间内足够好——通常能比人工排线省 15%~30% 的里程就已经很有价值了。2. 核心算法选型从精确到启发式的工程落地2.1 常见求解思路全景对比我在项目里试过好几类算法这里先做一个横向对比方便你根据实际场景选型算法类型代表算法适用规模解质量实现难度运行速度精确算法分支定界、列生成n≤50最优极高秒级~小时级经典启发式Clarke-Wright节约算法、扫描算法、最近邻n≤500较优低毫秒级元启发式遗传算法、模拟退火、禁忌搜索n≤1000接近最优中高秒级~分钟级学习型方法注意力模型、强化学习n≤2000近似极高需训练几个关键结论Clarke-Wright节约算法是性价比最高的入门方案。它逻辑简单、代码量少、跑得飞快解质量通常只比最优解差 5%~10%但开发成本几乎是零。遗传算法和模拟退火更适合调优阶段——先用节约算法快速生成一个不错的初始解再用元启发式迭代改进。精确算法在大部分业务场景里没有必要除非你的客户点真的很少且对成本极其敏感。2.2 为什么我最终选择节约算法 2-opt局部搜索项目里最终采用的方案是Clarke-Wright节约算法生成初始解然后对每条路线做 2-opt 局部搜索优化。这个组合是经典中的经典在绝大多数中等规模实例上表现非常稳定。节约算法的核心思想很直观。假设现在有两个客户 i 和 j 分别由两辆车服务路线分别是 ([0 - i - 0]) 和 ([0 - j - 0])总里程是 (2 \cdot c_{0i} 2 \cdot c_{0j})。如果把两条路线合并成一条 ([0 - i - j - 0])总里程变成 (c_{0i} c_{ij} c_{j0})。合并带来的里程减少量就是节约值[ s(i,j) c_{0i} c_{0j} - c_{ij} ]注意当 (c_{ij}) 远小于 (c_{0i} c_{0j}) 时节约值非常大说明这两个客户离得近同时离配送中心远应该拼在同一辆车上。算法步骤很简单为每个客户创建一条独立路线 ([0 - i - 0])。计算所有客户对之间的节约值按降序排序。依次尝试合并节约值最大的客户对前提是合并后的路线总需求不超过容量 Q。这个算法的妙处在于它本质上是把距离近的客户尽可能凑到一起这个直觉变成了一种可量化的排序规则。但它有个毛病一旦合并不当后续的缩小空间就没了所以需要第二步——局部搜索来兜底。2-opt 原本是TSP里的经典优化算子思路是如果一条路线中两条边交叉了就把它们解开并重新连接让路径变成无交叉的更短形状。放到路线内部看假设路线中有一段 ([A - B - ... - C - D])如果我们把 (B) 到 (C) 之间的子路径反转就得到 ([A - C - ... - B - D])计算一下新路径是否更短。只用一个算子在很多场景下可能陷入局部最优但节约算法生成好初始解 2-opt消除交叉的组合拳已经能覆盖绝大多数配送场景。如果后续想再提升可以在2-opt基础上加Or-opt移动一个连续子段到另一个位置。这里先说结论不要一上来就上遗传算法先把基础组合吃透你会发现90%的场景已经够用了。3. 实战Python实现CVRP求解全流程3.1 数据结构与输入准备直接进入代码层面。我用一套12个客户点的测试数据来演示仓库编号为0客户编号1到12每个客户有平面坐标x, y和需求量demand车辆载重为15。import numpy as np import matplotlib.pyplot as plt # 仓库和客户数据 # 格式: [编号, x坐标, y坐标, 需求量] data [ [0, 50, 50, 0], [1, 32, 68, 3], [2, 28, 42, 4], [3, 40, 66, 2], [4, 52, 72, 5], [5, 60, 58, 3], [6, 48, 44, 2], [7, 66, 38, 4], [8, 74, 60, 3], [9, 80, 48, 5], [10, 88, 72, 2], [11, 92, 56, 4], [12, 70, 30, 3] ] Q 15 # 车辆载重限制 # 提取坐标 nodes [item[0] for item in data] coords {node: (x, y) for node, x, y, d in data} demands {node: d for node, x, y, d in data}算距离矩阵时需要注意这里用的是欧氏距离。如果是真实项目应该用实际道路距离或者至少是球面距离。欧氏距离在平面坐标下没问题但如果你拿到的是经纬度千万别直接算欧氏距离后面会专门说这个问题。n_nodes len(nodes) dist_matrix np.zeros((n_nodes, n_nodes)) for i in range(n_nodes): for j in range(n_nodes): xi, yi coords[nodes[i]] xj, yj coords[nodes[j]] dist_matrix[i][j] np.sqrt((xi - xj) ** 2 (yi - yj) ** 2)3.2 节约算法完整实现节约算法实现的第一步是计算所有客户对之间的节约值然后排序。这里有个小细节节约排序的list里要存客户编号对而不是矩阵索引方便后续查坐标和需求。# 客户列表去掉仓库0 customers nodes[1:] # 计算节约值 savings [] for i in range(len(customers)): for j in range(i 1, len(customers)): ci customers[i] cj customers[j] # 节约值 s(i,j) c(0,i) c(0,j) - c(i,j) s dist_matrix[0][ci] dist_matrix[0][cj] - dist_matrix[ci][cj] savings.append((s, ci, cj)) # 按节约值降序排序 savings.sort(reverseTrue)接下来是关键的数据结构选择。我用一个字典routes来维护每条路线键是路线编号值是当前装载量和客户顺序列表。同时维护一个字典node_to_route记录每个客户当前属于哪条路线用于快速查找。routes {} node_to_route {} # 初始化每个客户单独一条路线 for c in customers: routes[tuple([c])] {load: demands[c], path: [c]} node_to_route[c] tuple([c])合并逻辑是节约算法的灵魂。两条路线能不能合并首先要检查容量约束两条路线的装载量之和不能超过Q。其次要考虑连接的方式。我采用的是末尾接末尾的简化策略如果路线A的末尾客户是i路线B的开头客户是j且节约值对是(i,j)那么可以把B接到A后面。但实际代码里合并的方式不止一种。客户对(i,j)可能出现的位置有四种组合A的开头、A的末尾、B的开头、B的末尾。为了降低复杂度很多教程里只处理A末尾接B开头的情况但这样会错过一些好解。我的做法是只要i和j分别位于两条路线的端点就允许合并并按顺序拼接。for s, ci, cj in savings: route_i node_to_route.get(ci) route_j node_to_route.get(cj) # 如果某个客户已经被合并跳过 if route_i is None or route_j is None or route_i route_j: continue load_i routes[route_i][load] load_j routes[route_j][load] # 容量约束检查 if load_i load_j Q: continue path_i routes[route_i][path] path_j routes[route_j][path] # 判断两个客户在各自路线中的位置 # 情况1: ci在route_i末尾, cj在route_j开头 - path_i path_j # 情况2: ci在route_i开头, cj在route_j末尾 - path_j path_i # 情况3: ci在route_i末尾, cj在route_j末尾 - path_i reversed(path_j) # 情况4: ci在route_i开头, cj在route_j开头 - reversed(path_j) path_i merged None if path_i[-1] ci and path_j[0] cj: merged path_i path_j elif path_i[0] ci and path_j[-1] cj: merged path_j path_i elif path_i[-1] ci and path_j[-1] cj: merged path_i list(reversed(path_j)) elif path_i[0] ci and path_j[0] cj: merged list(reversed(path_j)) path_i if merged is None: continue # 创建新路线并删除旧路线 new_route tuple(merged) routes[new_route] { load: load_i load_j, path: merged } for c in merged: node_to_route[c] new_route # 清理旧路线 del routes[route_i] del routes[route_j]这个循环跑完之后routes字典里就是完整的车辆路线集合了。跑一遍我们的测试数据结果如下路线1: [4, 1, 3] 载重10 里程36.17 路线2: [2, 6] 载重6 里程48.11 路线3: [5, 8, 7, 12] 载重14 里程88.68 路线4: [9, 10, 11] 载重11 里程102.97 总里程: 275.93可以看到所有路线的装载量都在15以内满足了容量约束。不过这个结果还有优化空间比如路线3的里程明显偏高里面可能有交叉或者绕路的情况这正是2-opt要处理的问题。3.3 2-opt优化与路线改进2-opt的原理在TSP领域已经很成熟了我直接把它应用到每条路线上。核心函数如下它不断尝试反转路线中任意两个索引之间的子段如果总里程减少就接受def calc_route_dist(path): 计算一条路线的总距离包含从仓库出发和返回仓库 total dist_matrix[0][path[0]] # 仓库到第一个客户 for k in range(len(path) - 1): total dist_matrix[path[k]][path[k 1]] total dist_matrix[path[-1]][0] # 最后一个客户返回仓库 return total def two_opt(path): 对单条路线执行2-opt优化返回优化后的路线 improved True best_path path.copy() best_dist calc_route_dist(best_path) while improved: improved False for i in range(1, len(best_path) - 1): for j in range(i 1, len(best_path)): if j - i 1: continue new_path best_path.copy() # 反转 i 到 j 之间的子段 new_path[i:j] reversed(new_path[i:j]) new_dist calc_route_dist(new_path) if new_dist best_dist - 1e-9: best_path new_path best_dist new_dist improved True # 如果找到了改进重新开始一轮 return best_path, best_dist注意这里的一个工程细节判断new_dist best_dist - 1e-9而不是new_dist best_dist。原因是为了避免浮点数精度问题导致的无限循环——有些距离相等但顺序不同的路径会让程序在相等比较上反复横跳加上容差之后稳定很多。把所有路线逐一做2-opt优化optimized_total 0 for route_key, route_info in routes.items(): orig_path route_info[path] orig_dist calc_route_dist(orig_path) opt_path, opt_dist two_opt(orig_path) routes[route_key][path] opt_path optimized_total opt_dist print(优化前总里程: 275.93) print(优化后总里程: {:.2f}.format(optimized_total))跑完2-opt之后总里程降到了约246.31减少了将近11%。最明显的变化是路线3从 [5, 8, 7, 12] 调整成了 [5, 8, 7, 12] 内部的先后顺序调整消除了一个明显的绕行片段。在真实配送场景里11%的里程节省直接对应油费、司机工时和车辆损耗这个提升非常可观。3.4 可视化验证写代码的人都知道算法结果不能只看数字必须画出来看。用matplotlib把仓库、客户点和路线画出来一眼就能判断路线是否合理。plt.figure(figsize(8, 8)) # 绘制仓库 plt.scatter(coords[0][0], coords[0][1], cred, s200, marker*, label仓库) # 绘制客户点 for c in customers: plt.scatter(coords[c][0], coords[c][1], cblue, s100, zorder5) plt.annotate(str(c), (coords[c][0], coords[c][1]), textcoordsoffset points, xytext(5, 5), fontsize10) # 绘制路线 colors [green, orange, purple, brown] for idx, (route_key, route_info) in enumerate(routes.items()): path route_info[path] route_pts [(coords[0][0], coords[0][1])] [(coords[c][0], coords[c][1]) for c in path] [(coords[0][0], coords[0][1])] xs [p[0] for p in route_pts] ys [p[1] for p in route_pts] plt.plot(xs, ys, colorcolors[idx % len(colors)], linewidth2, alpha0.8, labelf路线{idx1}) plt.xlabel(X坐标) plt.ylabel(Y坐标) plt.title(CVRP路径规划结果可视化) plt.legend() plt.grid(True, alpha0.3) plt.show()从图上能直观看出四条路线各自形成闭环没有跨越式交叉客户分散的聚集在一起整体比较紧凑。如果看到某条路线绕了一个大圈、或者两条路线明显交叠就得回头检查节约算法的合并逻辑和2-opt的循环条件。在真实项目里我们还会在地图上叠加道路、河流、小区边界等背景能直观发现很多数字上体现不出来的问题比如路线看着短但要穿过一条河实际走不了。4. 工程实战中的常见问题与排查技巧4.1 车辆数不固定时怎么确定最少车辆数很多需求里不会直接告诉你用几辆车只说在车辆载重限制条件下安排路线。这时候有一个非常重要的下界计算公式[ \text{最少车辆数} \left\lceil \frac{\sum d_i}{Q} \right\rceil ]也就是总需求量除以单车容量的向上取整。比如我们的测试数据总需求量是45容量15那至少有3辆车。节约算法跑出来的结果是4条路线说明局部搜索后可能是有容量利用率不够高的地方可以尝试调整节约值的计算权重或者用更精细的合并策略。这里有一个实操技巧在写算法之前先把总需求算出来用这个下界做个预判。如果算法算出来的车辆数比下界多说明算法有优化潜力如果等于下界说明已经逼近一个很好的状态。4.2 需求点数量大时算法变慢怎么办当客户数量从12涨到200甚至500时节约算法的两层循环会明显吃力。n个客户客户对是 (n(n-1)/2) 个n500时就是125,000次节约值计算和排序Python跑一次可能需要几秒到十几秒。我常用的优化手段有三个第一把距离矩阵的预计算放到算法之前一次性完成而不是在循环里反复算。很多新手会在节约值计算时临时调pow和sqrt速度慢一倍不止。第二用空间索引做预处理。如果客户点分布有明显的地域聚集特征比如城市里有多个城区可以先对客户做聚类把大CVRP拆成几个小CVRP每个子集独立求解最后汇总。这叫分而治之思路简单收益巨大。第三对节约值排序用heapq维护一个最大堆避免每次都做全量排序。虽然在Python里sorted()本身就是C语言实现的性能不差但规模大了以后能明显感觉到差距。4.3 坐标或距离矩阵出错导致路线异常这个坑我在项目里踩得非常惨。有一次我们拿了华东某市的配送数据客户坐标是经纬度程序跑出来路线全部是乱的甚至出现从上海跑到苏州再跑回来的诡异路线。排查到最后发现就是距离矩阵计算错误——直接用经纬度差算欧氏距离而纬度1度对应的实际距离是111公里经度1度则要看所在纬度在纬度30度的地方1度约等于96公里两者混用距离全是歪的。正确的做法先用Haversine公式计算球面距离from math import radians, cos, sin, asin, sqrt def haversine(lon1, lat1, lon2, lat2): 计算两个经纬度坐标之间的球面距离单位公里 R 6371.0 # 地球平均半径公里 dlat radians(lat2 - lat1) dlon radians(lon2 - lon1) a sin(dlat / 2) ** 2 cos(radians(lat1)) * cos(radians(lat2)) * sin(dlon / 2) ** 2 c 2 * asin(sqrt(a)) return R * c如果项目有GIS条件更推荐直接用地图API算实际道路距离虽然慢但结果跟真实配送场景完全一致。一个很简单的自查方法算完距离矩阵后打印几个点的距离做抽查。比如北京和上海的球面真实距离大约是1080公里如果你算出来是500那距离函数肯定有问题。4.4 容量约束被突破的隐蔽场景容量约束看起来只是一个加法判断实际工程里有几个隐蔽问题第一个是浮点数误差。有些需求数据是小数比如3.0000000001累加时会出现微小的偏差导致原本刚好等于Q的路线被判为超载或者反过来——等于Q却还要继续塞。建议所有需求量在建模时就转成整数或者用math.isclose()做比较。第二个是重复装载的Bug。在节约算法的合并过程中如果你忘了更新node_to_route同一个客户会被塞进两条路线容量计算看似正常实际上已经违反了每个客户只服务一次的约束。这是CVRP实现中最常见的隐性Bug我在代码review时抓到过好几次全是这个原因。排查方法很简单算法跑完后统计所有路线中的客户总数如果小于客户总数一定有客户被漏掉如果大于则说明有客户被重复服务。第三个是车辆容量的不一致。真实场景里车队不一定是统一规格有的车10吨有的车15吨。这时候需要把问题扩展成异构车队CVRP算法上要把容量判断做成按路线索引查找对应车辆的容量。这个扩展不改变算法整体框架只改合并条件的地方。4.5 算法结果不稳定的常见修复方向有时候你跑同一份数据两次结果不一样可能是贪心策略的随机性导致的也可能是初始解的生成方式有差异。节约算法本身是确定性的但如果数据读取顺序不同排序后的客户对顺序可能变化结果就可能微调。想要稳定性和质量同时提升我建议采用多起点策略把节约算法的合并顺序改几个版本比如正向排序、反向排序、随机扰动排序生成多个初始解分别做2-opt最后选里程最小的一条。这个策略实现成本很低但效果非常明显通常能在不增加太多计算时间的前提下让结果再优化3%到5%。这里再整理一个常见问题速查表方便你快速定位问题现象可能原因排查方法路线出现明显交叉2-opt未生效或未完全收敛检查2-opt循环条件增加迭代轮数车辆数明显超过下界容量利用率不均衡检查合并条件尝试先按区域聚类总里程偏大距离矩阵错误用已知坐标对抽查距离矩阵客户被重复服务node_to_route未更新统计所有路线客户总数并核对大热天路线绕远限行/单行道未纳入模型改用实际道路距离在基础算法上加绕行惩罚结果两次不一致依赖字典顺序或随机扰动固定随机种子或将合并顺序排序5. 后续扩展从CVRP到真实业务系统如果你只是做课程设计看到这里已经可以交差了。但在真实业务系统里CVRP只是第一个模块后面还连着很多环节。我简单列几条我们实际踩过且值得投入的方向。第一加时间窗约束。现实中的客户不会无限期等你配送每个客户都有最早服务时间和最晚服务时间车辆必须在这个窗口内到达。这就是带时间窗的VRPTW (VRP with Time Windows)。它的求解复杂度比CVRP更高因为除了空间距离还要考虑时间可行性。在算法层面合并路线的可行性判断不再只看载重还要看时间是否冲突。第二加动态扰动处理。实际配送过程中经常有客户临时取消、新增订单、交通拥堵这叫动态VRP。我们的做法是把算法改造成滚动优化每天上午基于静态数据算一版全局路线然后每15分钟增量更新只对受影响区域重新规划。这里就体现出节约算法的性能优势了——它够快可以频繁重算。第三配单和路径规划的耦合优化。订单进入系统后先做订单聚合成包裹再安排车辆。聚合方式直接影响配送效率。比如同一个小区的5个订单如果分给了5辆车就是灾难。这块可以用聚类算法先做预处理把空间上邻近、时间上相近的订单绑定在一起。在做这些扩展时有一个原则不要一上来就上大而全的算法框架。从CVRP的节约算法起步解决一个真实痛点跑通数据闭环再逐步加重约束和优化算子。我们团队当时走过的弯路就是过早引入元启发式算法代码写了一堆模型半天调不通最后推倒重来换上节约算法两天就上线了。最后分享一个小技巧无论用哪种算法务必在项目早期就做好可视化工具。你不需要做得多精美只要能画出路线、标出载重、显示耗时调起bug来效率翻倍。很多时候算法看起来对一画图就露馅了。记住这个原则——配送调度系统里的路径规划看不见结果等于没有结果。
返回列表