ARTICLE DETAIL

资讯详情

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

基于VNS的VRPTW求解:C++实现与工程实践

基于VNS的VRPTW求解:C++实现与工程实践 最近帮一个团队做配送路径优化的原型验证翻来覆去还是绕回VNS变邻域搜索这个算法上。说白了VNS不是那种让人眼前一亮的新算法但它胜在稳——实现一个能用的版本很快跑出来的结果在大多数情况下也不输给那些调参调到吐的复杂算法。这篇文章既聊原理也聊落地手把手把基于VNS求解带时间窗车辆路径问题VRPTW的方案讲透最后给出C代码层面的关键实现和我在工程里踩过的坑。先说清楚这套东西适合谁看如果你是刚接触元启发式算法的学生本文能把VNS的完整脉络讲清楚如果你已经在用遗传算法或禁忌搜索做路径优化本文能给你一个更简单但同样有效的替代方案如果你就是想在工程项目里快速搭一个能跑的VRPTW求解器那直接按照文中的C代码思路去实现至少能在标准算例上跑出竞争力的结果。全文不会写那些“从入门到放弃”的废话直接上实操层面的东西。1. 为什么挑VNS来解决路径规划问题1.1 VNS到底在解决什么困难先看一个本质问题组合优化里的大部分问题比如VRP车辆路径问题、排班问题、调度问题都属于NP难问题。什么叫NP难就是不指望在多项式时间里找到精确最优解。当客户数量到几十个上百个时精确算法已经无能为力工业界几乎都转向启发式算法。启发式算法的思路很直接从一个初始解出发在解的邻域里不断寻找更好的解。这听起来简单但有一个绕不开的老大难——局部最优。随便挑一个局部搜索策略跑下去十有八九会卡在某个并不是全局最优的“山包”上而且怎么跳都跳不出去。传统的应对方式有两个模拟退火用它爬山过程中的随机性跳出局部最优遗传算法用种群多样性和交叉算子来探索更广的空间。VNS的思路完全不同它基于一个很朴素的观察一个局部最优解在一个邻域结构下是局部最优的但在另一个邻域结构下不一定是局部最优的。比如你在“交换两个客户位置”这个邻域里已经找不到更优解了但换到“把一段路径整体搬到另一段路径中”这个邻域里可能还有很多改进空间。更进一步的观察是对于一个当前解来说离它越远的邻域越有可能包含能跳出当前局部最优状态的“逃生通道”。这两个观察构成了VNS的地基。它不需要像遗传算法那样维护一群解也不需要像模拟退火那样精心设计温度衰减曲线。它做的事情用一句话概括就是用偏远的邻域做扰动跳出局部最优用精细的邻域做局部搜索找当前范围内的最优。思路简单但效果非常能打。1.2 变邻域的两个关键动作Shaking和VNDVNS主循环里有两个核心操作Shaking抖动和VND变邻域下降。这两个动作互相配合决定了算法是能在搜索空间中有效移动还是原地打转。Shaking从当前解 (x) 出发在距离它“比较远”的邻域里随机选一个新解 (x)。注意这里用的是“随机选择”不是“选择最好的”。这个随机的扰动目的是让解从当前的局部最优位置跳出去进入一个陌生的区域。如果扰动不够远解可能很快又掉回原来的局部最优如果扰动太远解就变成了随机重启之前的搜索积累全部浪费。所以Shaking的强度也就是邻域索引 (k)是逐步递增的先从比较近的扰动开始试不行再增加扰动幅度。这就是VNS名字里“变邻域”的另一个体现。VND则是一个完整的局部搜索过程。它维护一组邻域结构的列表比如 (N_1) 表示单点插入(N_2) 表示两点交换(N_3) 表示2-opt……然后按顺序依次在这些邻域里做局部搜索。当在 (N_1) 里找不到改进解时切换到 (N_2)如果在 (N_2) 里找到了改进解则跳回 (N_1) 重新开始。只有所有邻域都找不到改进解VND才停止。这个过程保证了在局部搜索阶段算法已经把一个解“榨干”到在当前邻域集合中的最优状态。这一整套机制结合起来VNS的行为就像一个人爬山每到一处山顶先用小范围动作往周边探一探确认没有更高的山头然后向远处眺望瞄准远方的山峰纵身一跃。落地之后再重复“小范围搜索远跳”的循环。相比于其他元启发式VNS最大的优点是少了很多玄学参数主循环里需要调的就两三个数剩下的交给邻域结构的定义。2. 从VRP到VRPTW模型与约束拆解2.1 VRPTW的数学模型VRPTW全称Vehicle Routing Problem with Time Windows网上也有很多资料把它翻译成“带时间窗约束的车辆路径问题”。它本质上是在经典VRP的基础上给每个客户点加上了一个时间窗的限制。经典VRP的目标很简单有一堆客户每个客户有货物需求量仓库有一堆车怎么安排每辆车的路线使得总行驶距离最短。VRPTW在此基础上进一步约束每个客户存在一个“最早开始服务时间”(a_i)和一个“最晚允许开始服务时间”(b_i)车辆必须在这个时间段内到达并开始服务。模型可以形式化写成这样一个带约束的整数规划决策变量 (x_{ijk} \in {0,1})表示车辆 (k) 是否从客户 (i) 直接行驶到客户 (j)目标函数(\min \sum_{k \in K} \sum_{i \in V} \sum_{j \in V} c_{ij} x_{ijk})其中 (c_{ij}) 是客户 (i) 到客户 (j) 的行驶成本通常就是距离约束一每个客户点必须被访问且只被访问一次约束二车辆从仓库出发最后必须返回仓库且出发和返回各一次约束三每条路径上客户的需求量总和不能超过车辆容量约束四每个客户的服务开始时间必须在时间窗 ([a_i, b_i]) 内车辆如果早到了只能等待在实际工程中时间窗又分为硬时间窗和软时间窗。硬时间窗要求必须满足车辆早到了就等着晚到了就没戏或者需要重新安排路径软时间窗允许违反但会在目标函数里加入惩罚项。大多数标准测试算例用的是硬时间窗但实际业务场景中两者结合的也不少。2.2 时间窗为什么是最大的“捣乱鬼”如果只是加了一个时间窗看起来只是多了一个约束但求解难度完全不一样。原因在于时间窗让“解的距离大小”和“解的可行性”之间产生了耦合。举个直观的例子两条路径的距离差异可能只有1公里但距离短的那条会因为某个客户的时间窗冲突而完全不可行而距离稍长的那条虽然绕了路但可以让所有客户都在时间窗内被服务。这意味着算法的评价函数不再只是简单的距离比较还必须时刻考虑时间窗的可行性。更麻烦的是VRPTW的可行解空间非常“碎片化”——一个可行的大邻域里可能夹杂着大量不可行的中间状态而从一个可行解移动到另一个可行解往往要经过一步“看起来不可行”的过渡。这一点在邻域搜索算法里非常致命因为很多算子会只接受可行解导致搜索被约束在一个很小的空间里。另一个容易被忽略的细节是VRPTW是一个双目标问题。工业界通常希望最小化行驶总距离但在同等条件下使用车辆的数目越少越好。很多基准算例比如Solomon标准算例集的评价标准是先比车辆数再比总距离。所以在实际实现时目标函数通常写成 ((NV, TD))先用车辆数排序再用总距离排序。这个细节在下文代码设计里会直接影响解的编码和评价方式。3. 用VNS求解VRPTW的算法设计3.1 解的表示路径集合怎么编码把VRPTW的解交给算法处理第一步就是确定解的数据结构。这里最常用的是“序列表示法”所有客户点中把分配给同一辆车的客户按访问顺序排成一个子序列多条子序列组成一个解。用4个客户 ({1,2,3,4}) 举例一个解可以表示为车辆A仓库 → 客户2 → 客户1 → 仓库车辆B仓库 → 客户4 → 客户3 → 仓库在数据结构层面最直观的方式是用vectorvectorint存这个解的路径集合每条路径是一个客户序列或者用vectorint存一条包含所有客户的排列序列再在序列中间插入“分割点”表示不同车辆的划分。前者更直观算子操作时对路径增删改都是自然操作好处是结构清晰坏处是每次变异涉及内存重分配。后者在工程实践中更紧凑一些高级算子比如带分割点的插入操作处理起来更方便。从解映射回具体路径时必须保证序列满足所有硬约束容量约束、时间窗约束。如果当前解不满足约束就要考虑是把它判为非法解丢弃还是把违约量折算成惩罚加入目标函数。在VNS这类邻域搜索算法中我的建议是使用罚函数法——允许临时出现违反时间窗或容量的解在评价函数里加一个足够大的惩罚项。这样解的搜索空间是连续的算法可以穿过“不可行区域”从一个可行解跳到另一个可行解。单纯禁止不可行解的方案会让搜索空间变成一座孤岛。3.2 邻域算子的设计与选择在VRPTW中的邻域算子来源于经典VRP算子的扩展。下面是我实测下来比较有用的五个Relocate单点插入从路径 (p) 中取出一个客户插入路径 (q) 的某个位置。如果 (p q)相当于路径内局部调整如果 (p \neq q)相当于把客户从一个路径调度到另一条路径。这是最基础的移动方式。Swap两点交换交换路径 (p) 中的客户 (i) 和路径 (q) 中的客户 (j)。它不会改变每条路径的客户数量除非两条路径都互相换出不同的客户但会改变路径内的访问顺序对时间窗的可行性有直接影响。2-opt边反转在路径内任选两个位置把这两个位置之间的客户序列反过来。这是解决路径交叉问题的经典算子可以大幅降低路径总长度。2-opt的优势是所有VRP问题都能受益缺点是在带时间窗的情况下会很容易破坏可行性必须配合可行性检查。Or-opt段移动在路径中取一段连续的客户比如2到5个客户整体移动到另一个位置可以是同一条路径也可以是另一条路径。这是大规模破坏后修复的一种轻量级操作对于时间窗约束较强的场景比单纯Relocate更稳定。Cross-exchange跨路径段交换在两条路径中各取一段连续客户交换它们的位置。这个算子的邻域规模很大对路径间的平衡调整很有帮助但对时间窗可行性检查的开销也很高。在VND内部的顺序安排上我的经验是先做计算量小、改进效果快的算子再做计算量大、更“重”的算子也就是类似“Relocate → Swap → 2-opt → Cross-exchange”的顺序。原因很简单大部分改进可以由前面几个轻量级算子完成后面的重算子只是用来做最后的精细化调整。3.3 抖动与接受准则的细节处理Shaking的实现策略有一个关键决策从哪个邻域做随机扰动标准的VNS做法是维护一个“远处邻域”的索引 (k)比如 (k1) 时执行随机Relocate(k2) 时执行随机Swap(k3) 时执行随机2-opt……每一轮迭代从 (k1) 开始如果执行完VND后找到改进解就回到 (k1)如果没有改进就 (kk1)加大扰动幅度继续尝试直到达到最大邻域索引。关于接受准则这个细节决定了算法最终是偏向“探索”还是“开发”。在基础的VNS中只有“local search返回的解比当前解更优”才接收这个策略收敛非常快但搜索半径有限。很多改进版VNS引入了模拟退火式的基于概率的接受准则当前解改进时必然接受当前解变差时以 (e^{-(\Delta / T)}) 的概率接受其中 (T) 是当前温度。这个方法能让搜索者有一定概率接受一个质量略差的解换取更广的探索范围。实际工程里可以用一个简单的线性温度衰减效果不错。VRPTW的抖动有个特殊坑因为时间窗约束相当敏感一个大动作的随机扰动几乎必然产生大量不可行解。如果处理不好VND会一直尝试修复一个被破坏得面目全非的解效率极低。我的处理办法是在抖动过程中直接对不可行解做一次初步片段评估——如果扰动解中超过一定比例的路径违反时间窗就重新生成扰动方向而不是直接把这个解交给VND。这个小细节实测能减少大约30%的无功计算量。4. C代码实现维度与关键逻辑4.1 基础数据结构设计代码实现的第一步就是把上文中的模型和算子映射到C的类与结构体上。为VRPTW实现一套顺手的数据结构是我觉得整个工程里投入产出比最高的事情——结构设计得不好后面的每个算子写起来都很别扭。可以定义一个客户节点结构体struct Customer { int id; // 客户编号0通常表示仓库 double x, y; // 坐标用于计算距离 double demand; // 需求量 double a, b; // 时间窗最早开始服务时间 double service; // 服务时长 bool isDepot; // 是否为仓库 };路径与解的结构struct Route { vectorint customers; // 按访问顺序存储客户ID double load 0.0; // 当前总载重 double duration 0.0; // 该路径总行驶时间含等待 bool feasible true; // 是否满足所有约束 }; struct Solution { vectorRoute routes; // 所有车辆的路线 double totalDistance 0.0; int vehicleCount 0; // 使用车辆数 };这里有一个工程技巧Route中缓存 load 和 duration而不是每次需要时重算。因为在邻域搜索的每一步迭代中算法都要计算大量移动的 delta 值即新旧解的差值如果每次移动都重新扫描整条路径计算开销会爆炸式增长。缓存后每次移动时只需要局部更新受影响的几条路径。另一个关键预计算是距离矩阵。VRPTW中所有客户之间的距离或者行驶时间是固定的应该在算法启动时一次性计算并存成一个double dist[MAX_N][MAX_N]表后续所有算子都通过查表获取距离。哪怕客户数上千O(n²)的一次性预计算也远好于在算子内部反复计算欧氏距离。4.2 时间窗检查函数算法正确性的心脏时间窗的可行性检查是VRPTW求解器里最容易写错的部分。很多初学代码的读者会误以为只需要检查每个客户到达时间是否落在时间窗内但实际上要从车辆从仓库出发生成一条完整的时间线。核心计算逻辑是这样的对于一条路径从仓库出发再返回仓库按客户顺序逐个计算[ arrival_i \max(prevDeparture travel(prev, i),\ a_i) ]其中prevDeparture是上一个节点的离开时间如果早到了 (arrival_i a_i)则服务开始时间为 (a_i)需要等待到时间窗起始时刻才能开始服务。服务结束时间[ departure_i arrival_i service_i ]只有满足 (arrival_i \le b_i)这个客户才可行。如果不满足就说明路径上存在时间窗违反整个方案不可行。这里有两个边角细节要特别注意一是仓库通常也定义了一个“营业时间窗”车辆从仓库出发的最早时间和返回仓库的最晚时间都受限于这个窗二是等待时间看起来无害但会影响后续所有客户的到达时间所以在计算时一定不能省略。实现上建议直接写成函数返回该条的违反量而不是布尔值。这样可以支持软时间窗的处理模式// 计算路径 p 的时间窗违反总量可行返回0不可行返回正数 double timeWindowViolation(const Route r) { double t depot.a; // 从仓库最早出发时间开始 double violation 0.0; for (int cid : r.customers) { const Customer c customers[cid]; double arrival t distMatrix[prev][cid]; if (arrival c.b) { violation arrival - c.b; // 晚到累加违反量 } t max(arrival, c.a) c.service; // 等待到最早服务时间 prev cid; } // 最后检查返回仓库的时间 double returnTime t distMatrix[prev][0]; if (returnTime depot.b) violation returnTime - depot.b; return violation; }4.3 邻域算子的增量评估实现每个邻域算子的实现都有一个共同点不需要重算整条路径的成本只需要计算移动前后的成本差异delta。以Relocate为例把客户 (i) 从路径 (p) 中位置 (pos_i) 删除再插入路径 (q) 中位置 (pos_j)新成本 旧成本 - 删除 (i) 带来的节省 插入 (i) 带来的增加。实现时定义一个通用接口struct Move { int type; // 0relocate, 1swap, 22opt, ... int routeA, posA; int routeB, posB; double delta; // 移动带来的成本变化负数表示改进 double timeViolation; // 时间窗违反量 }; bool evaluateAndApply(Solution sol, const Move m);这里很容易踩一个坑只评估距离变化 delta 会忽略时间窗可行性。所以每次移动的评估必须同时包含两部分距离变化量和时间窗违反量。如果目标是用硬时间窗求解就只有在timeViolation 0且delta 0时才接收这次移动如果使用软时间窗则可以把时间窗违反量折算成成本一起算进 delta 里。第一次实现时可以先做“全量重算”版本保证正确性确认没问题后再逐算子改成增量计算。不要上来就做增量计算很容易埋下一堆隐式 bug。4.4 VNS主循环的C骨架把上面的结构拼起来VNS主循环代码大致是这样的。这个骨架已经包含了标准VNS的所有组成元素Solution VNS(Solution initSol, int kMax, int maxIter) { Solution best initSol; int iter 0; while (iter maxIter) { int k 1; while (k kMax) { Solution x1 shaking(best, k); // 从第k邻域随机扰动 Solution x2 VND(x1); // 在内层变邻域下降 if (accept(x2, best)) { // 按接受准则判断 best x2; k 1; } else { k; } } iter; } return best; }shaking的实现根据k的取值执行不同幅度的随机扰动Solution shaking(const Solution x, int k) { Solution x2 x; // k1做1次随机Relocatek2做2次随机Swapk3做3次随机2-opt... for (int i 0; i k; i) { switch (k % 3) { case 0: randomRelocate(x2); break; case 1: randomSwap(x2); break; case 2: randomOrOpt(x2); break; } } return x2; }VND则是串行执行多个邻域的局部搜索Solution VND(Solution x) { bool improved true; while (improved) { improved false; for (int n 0; n NUM_NEIGHBORS; n) { Solution bestNeighbor localSearchInNeighborhood(x, n); if (bestNeighbor.totalDistance x.totalDistance - 1e-9) { x bestNeighbor; improved true; break; } } } return x; }这里最需要注意的是“找到一个改进就跳回第一个邻域”的行为这是VND发挥作用的关键。如果顺序执行且不跳回就退化成了普通的multi-start局部搜索。4.5 初始解构造与随机数使用初始解的质量直接影响VNS的最终结果。工业界一般用两种方式构造初始解一是最近邻贪心法从仓库出发每次选择当前路径末尾客户距离最近且满足容量和时间窗约束的客户作为下一个访问对象如果所有客户都已经无法插入当前路径就开一条新路径。这个方法快而且可以给出一条质量不错的可行解。二是随机构造法把所有客户随机打乱然后逐个插入到随机选择的路径的随机位置。这个方法的优点是为算法提供了良好的随机多样基础缺点是初始解质量可能很差需要算法花更多迭代来修复。我个人的经验是先用贪心法生成一个不错的可行解放入VNS作为初始解然后把随机生成的多条初始解逐一用VNS优化保底取其中最优的。这个策略既保证了搜索起点也让算法不会因为单一起点陷入过窄的搜索区域。另外C里写元启发式算法不要再用std::rand()随机数质量太差。用random里的std::mt19937和std::uniform_int_distribution采样效率和周期质量都完全够用代码也就多两行std::mt19937 rng(std::random_device{}()); int pick std::uniform_int_distributionint(0, n - 1)(rng);5. 用标准算例实测和调参经验5.1 在Solomon算例上验证效果先说明一点VRPTW领域公认的基准测试集是Solomon算例集也是评判一个VNS实现到底行不行的“及格线”。Solomon算例分为C型聚类分布、R型随机分布和RC型混合分布每个算例包含100个客户。在验证自己的算法时至少要跑这几个有代表性的算例C101、R101、RC101。这三个算例分别覆盖了聚类场景、随机场景和混合场景是验证邻域搜索算子鲁棒性的基本盘。实测VNS跑Solomon算例在不经过专门调参的前提下结果大致能达到Optimal或接近Best Known的5%左右差距。其中聚类分布的C系列解的质量会明显好于随机分布的R系列这是因为聚类分布中客户天然聚集在几个区域路径结构相对清晰随机分布的客户没有空间规律时间窗冲突更频繁邻域搜索更容易被约束卡住。5.2 各参数如何设置VNS最核心的三个参数是VND邻域集合的构成、Shaking的最大邻域索引kMax、迭代终止条件。VND邻域集合的构成前文中推荐的Relocate Swap 2-opt Or-opt Cross-exchange已经足够覆盖100个客户规模的算例。如果客户数扩大到了500以上建议增加一个耗时开销较大的“破坏重建”类算子比如随机移除若干个客户后按贪心规则重新插入。这个组合能在更大规模问题里明显改善搜索结果。kMax取值Shaking的最大邻域索引一般设置在[3, 7]之间。对于100个客户的算例kMax4左右比较合适客户规模越大可以适当把kMax调大让扰动幅度增强。一个需要警惕的问题是 kMax 太大时Shaking 会直接等价于随机重启搜索不具备记忆性结果反而变差。建议从小到大调每次只调一个参数记录10次运行的平均表现和最好表现。迭代终止条件最常见的两种是最大迭代次数如5000轮和最大无改进次数如连续300轮没有改进就停止。后者在工程上更常用因为它能自动适配问题难度——难的问题迭代多一些简单的问题很快就能收敛。为了避免算法偶发停滞可以配合全局计时器限定运行时间上限比如10分钟强制终止。5.3 高频问题和排查思路这里整理一份VRPTW求解器最常见的问题清单都是我实际调代码时遇到过的情况。问题一所有可行解的时间窗检查都报不可行排查思路检查你的时间线计算是不是漏掉了仓库的时间窗。很多算例中仓库不只一个而是用“0号客户”表示两个节点——一个是出发仓库一个是返回仓库它们的关门时间可能不同。如果代码里只检查了客户点的时间窗忘了最后回到仓库的时间限制那么一些路径实际上不可行但被误判成了可行。问题二VND陷入极慢的循环每个邻域扫描耗时太长排查思路计算复杂度太高特别是2-opt和Cross-exchange这种邻域规模为 (O(n^2)) 的算子。优化方式有三种一是给算子加一个maxMovesPerCall的扫描上限一次最多检查N个候选移动二是维护一个候选列表只对“潜力大”的客户区间做邻域搜索三是把Best Improvement改成First Improvement找到一个改进就立刻应用不要遍历完全部邻域再决定。问题三VNS结果波动很大多次运行结果方差明显排查思路这是元启发式算法的正常现象但如果波动超出合理范围说明初始解质量波动或随机扰动幅度选择不当。解决办法是保存多个独立随机种子下的初始解或者把结果收集后选最优而不是只依赖于某一次的运行结果。问题四时间窗冲突总是在局部搜索中被破坏算法难以为继排查思路这是VRPTW里最经典的问题。核心原因在于时间窗约束的本性——它像一个突然出现的悬崖稍微移动一个节点就会导致整条路径的可行性崩塌。这里我的建议是接受“软时间窗策略”在评价函数里把时间窗违反量放大10100倍作为惩罚项这样局部搜索会主动避开时间窗违约的解。当算法收敛到无法继续改进时再把所有解切换成硬时间窗检查得到最终可行解。5.4 性能优化心得最后分享一个工程性能优化的心得。VNS在VRPTW上的热区高度集中在邻域评估上也就是时间窗和距离的delta计算。我用过的非常有效的一个优化是在VND内部每次对一个邻域做完整扫描时不直接修改解结构而是先生成所有合法的候选移动并计算delta然后统一应用对总成本改善最多的那个移动。这个策略避免了在循环过程中反复调用复制构造函数和内存重分配配合vector的reserve预分配实测能让500客户规模的VRPTW求解时间缩短约30%。再一个细节是缓存每个算子的评估代码里如果每条路径的 load 和 duration 已经被缓存那么容量可行性检查就是一次if (routeA.load routeB.load capacity)的常数时间判断而不是重新累加一遍。这部分写的好不好在1000客户规模下性能差异可以达到一个数量级。6. 这套方法能扩展到哪一步写到这里VNS求解VRPTW的完整思路和代码脉络已经基本齐了。最后提一个扩展方向如果你在实际项目中需要处理动态订单、实时路况变化VNS的框架仍然完全适用只需要把Shaking和VND里的评价函数改成语义相关的计算比如加入车辆的预计到达时间窗、实时交通速度系数等算法的骨架不需要推翻重来。我个人在实际操作中的一个体会是元启发式算法的选型要追求“够用”而不是“炫技”。很多团队一上来就上自适应大邻域搜索ALNS或者并行遗传算法真正布置到生产环境之后需要调的参数反而成了负担。VNS从代码量上看只是一个主循环加几个算子从原理上看也不需要复杂的参数养成机制但它能在各种规模的VRPTW实例上给出非常稳健的结果。如果你现在正被路径优化问题困扰不妨先用本文这套思路在标准算例上跑一遍再针对自己的业务约束做扩展。踩过几次坑之后你会意识到算法本质上是在“怎么快速找到好解”和“怎么防止过早收敛”之间找平衡而VNS就是这两种诉求里最诚实的解法之一。
返回列表