简介:这份资源是面向运筹优化学习者与物流调度研究者的两层车辆路径问题(2E-VRP)MATLAB实现代码包,聚焦第一层中心仓库至次级配送点、第二层配送点至终端客户的双层配送体系建模与求解。包内共138个文件,以93个dat算例数据、19个m脚本为主,另含若干txt与md说明文档,并附带2evrp_100-5、2evrp_100-10、2evrp_200-10等系列测试实例,压缩包约124KB,结构紧凑便于直接运行与二次开发。代码覆盖模型构建、启发式与遗传算法选择、双层路线编码、迭代优化、容量与服务时间窗约束处理、性能评估及路径可视化等环节,读者可据此快速复现实验、调整参数并迁移到实际配送场景。目前已有538人学习下载,适合具备一定MATLAB与运筹学基础、希望深入理解并测试2E-VRP求解策略的读者参考。
1. 2E-VRP 到底难在哪:从单层配送跨到两层的那道坎
单层 VRP 跑熟了的人,第一次看 2E-VRP 的算例往往会愣一下:明明只是多了一层中转站,为什么求解时间翻了好几倍,解的质量还忽上忽下。2E-VRP(Two-Echelon Vehicle Routing Problem,两层车辆路径问题)在城配场景里非常真实——大型货车把货送到城市边缘的卫星站,再由小型车从卫星站送到最终客户。它比经典 VRP 多出的不是一条边,而是「卫星站该开几个、开在哪、两层车怎么衔接」这一整套耦合决策。标题里的2E-VRP-master1205就是一套用 MATLAB 写的两层 VRP 求解代码,2evrp、vrp、matlab三个词基本框定了它的技术栈:MATLAB 环境、两层结构、路径优化。这篇笔记面向的是已经会写单层 VRP、想把这套代码跑通并改出自己算例的人,重点讲清楚它的数据组织、求解流程、参数怎么调,以及我踩过的那些坑。
2. 拆开 2E-VRP 的数据结构与 MATLAB 工程组织
2.1 两层结构在代码里是怎么落地的
单层 VRP 的输入通常就是一张距离矩阵加一个需求向量,2E-VRP 不行,它至少要把节点分成三类:主 depot(一级车起点)、卫星站(一级车终点兼二级车起点)、客户(二级车终点)。这三类节点在代码里如果混在一个坐标矩阵里,后面算距离和约束时会非常痛苦。常见做法是分开存:depot一行坐标,satellites若干行坐标,customers若干行坐标,再各自维护需求量和容量。
一级车和二级车的约束是分开的,但目标函数是合并的。一级车关心的是「从 depot 出发、访问若干卫星站、回到 depot」的吨位和行驶成本;二级车关心的是「从某个卫星站出发、服务一批客户、回到该卫星站」的路径成本。两层之间唯一的耦合量是:每个卫星站被一级车送进来的货量,必须等于它下面所有二级车送出去的货量之和。这个守恒约束是 2E-VRP 区别于「两个独立 VRP 拼起来」的核心,也是很多初学者代码跑出来解不可行的根源。
在 MATLAB 里表达这种结构,我一般不会用嵌套 cell 硬堆,而是用结构体数组。每个卫星站一个 struct,字段包括coord、capacity、assignedCustomers、inboundLoad。这样在邻域搜索里移动一个客户时,只需要改它所属卫星站的assignedCustomers,同步更新inboundLoad,逻辑清晰,调试时disp一下就能看出哪层不守恒。
2.2 距离矩阵与索引映射:别让下标错位毁掉整晚
2E-VRP 代码里最容易出玄学 bug 的地方就是索引。客户编号、卫星站编号、距离矩阵行号三者一旦错位,程序不报错,但解会莫名其妙变差。我的习惯是建一张全局索引表,把所有节点统一编号,再用映射数组区分类型。
% 节点统一编号:1 为 depot,2..S+1 为卫星站,S+2..S+C+1 为客户 S = size(satellites, 1); C = size(customers, 1); allCoords = [depot; satellites; customers]; N = size(allCoords, 1); % 类型标记:1=depot, 2=satellite, 3=customer nodeType = [1; 2*ones(S,1); 3*ones(C,1)]; % 卫星站在统一编号中的下标 satIdx = 2 : (S + 1); % 客户在统一编号中的下标 cusIdx = (S + 2) : N; % 全节点欧氏距离矩阵 D = pdist2(allCoords, allCoords);这段代码的关键在于satIdx和cusIdx这两个映射。后面无论是一级车算 depot 到卫星站的距离,还是二级车算卫星站到客户的距离,都从D里按这两组下标取子矩阵,不再手写偏移量。参数上,S和C完全由输入数据决定,不要硬编码;nodeType在写约束检查函数时特别有用,比如判断一条路径是否合法,直接看节点类型序列是否符合「depot → satellite → depot」或「satellite → customer → satellite」的模式。
提示:如果你的算例里卫星站有容量上限,记得在
satellites数据里单独留一列容量,不要和坐标混在一起,否则后面做容量约束时又要拆列,容易出错。
2.3 初始解的构造:先可行再谈优化
2E-VRP 的初始解构造比单层麻烦,因为要同时满足两层容量和客户全覆盖。我一般分两步走:先把客户按最近卫星站分配,形成每个卫星站的客户集合;再对每个卫星站单独跑一次节约算法或最近邻,生成二级路径;最后根据各卫星站的总需求,用一级车做一次配送路径规划。
% 第一步:客户按最近卫星站分配 assignment = zeros(C, 1); for i = 1:C distToSat = D(cusIdx(i), satIdx); [~, assignment(i)] = min(distToSat); end % 第二步:检查每个卫星站分配到的总需求是否超容量 for s = 1:S loadS = sum(customerDemand(assignment == s)); if loadS > satelliteCapacity(s) warning('卫星站 %d 超容量: %.2f > %.2f', s, loadS, satelliteCapacity(s)); end end分配逻辑本身不复杂,但这里有两个参数要留意。一是customerDemand的顺序必须和cusIdx一致,否则需求对不上客户;二是卫星站容量检查要在构造二级路径之前做,如果超了,要么调整分配,要么在后续邻域搜索里允许跨站移动客户。我见过有人跳过这步检查,结果初始解就不可行,后面所有优化都在不可行域里打转,跑再久也没用。
3. 在 MATLAB 里跑通 2E-VRP 主流程:从算例到输出
3.1 主程序骨架与求解器调用顺序
一套能跑的 2E-VRP 主程序,顺序基本是固定的:读数据、建距离矩阵、构造初始解、进入迭代优化、输出结果。2E-VRP-master1205这类代码通常会把优化部分做成一个主循环,里面嵌套邻域搜索或遗传操作。下面是我常用的骨架,你可以对照自己的代码看结构是否一致。
%% 主流程 data = loadCase('case_small.mat'); % 读入算例 [D, satIdx, cusIdx, nodeType] = buildDistance(data); sol = constructInitialSolution(data, D, satIdx, cusIdx); bestSol = sol; bestCost = evaluate(sol, D, data); for iter = 1:maxIter newSol = neighborhoodMove(sol, D, data); newCost = evaluate(newSol, D, data); if newCost < bestCost bestSol = newSol; bestCost = newCost; end sol = accept(sol, newSol, iter); % 接受准则 end printSolution(bestSol, bestCost);maxIter是外层迭代次数,小算例几百到几千都行,大算例要配合时间上限。neighborhoodMove里通常包含 relocate(移动一个客户到另一条路径)、swap(交换两个客户)、2-opt(路径内反转)这几类算子。accept可以是简单的「更优才接受」,也可以是模拟退火式的概率接受。参数上,我建议先把maxIter设小一点,比如 200,确认流程能跑通、解在合理范围,再逐步加大。
3.2 评价函数:两层成本怎么合并
评价函数是 2E-VRP 代码里最需要写清楚的部分,因为它直接决定优化方向。一级成本和二级成本要分开算,再按权重合并。
function totalCost = evaluate(sol, D, data) % 一级车成本:depot -> 卫星站 -> depot cost1 = 0; for r = 1:length(sol.firstEchelon) route = sol.firstEchelon{r}; for k = 1:length(route)-1 cost1 = cost1 + D(route(k), route(k+1)); end end % 二级车成本:卫星站 -> 客户 -> 卫星站 cost2 = 0; for s = 1:length(sol.secondEchelon) routes = sol.secondEchelon{s}; for r = 1:length(routes) route = routes{r}; for k = 1:length(route)-1 cost2 = cost2 + D(route(k), route(k+1)); end end end % 固定成本:启用的卫星站和车辆 fixedCost = data.satFixedCost * sum(sol.satOpen) + ... data.vehFixedCost * (length(sol.firstEchelon) + countSecondVehicles(sol)); totalCost = cost1 + cost2 + fixedCost; end这里cost1和cost2都是距离成本,fixedCost是启用卫星站和车辆的固定开销。参数satFixedCost和vehFixedCost的取值会显著影响解的结构:固定成本高,算法倾向于少开卫星站、少用车;固定成本低,则倾向于多开站来缩短距离。我一般会先设成距离成本的量级,比如平均距离的 10 到 50 倍,再根据结果调整。注意countSecondVehicles要自己实现,统计所有卫星站下的二级路径条数。
3.3 结果输出与可行性校验
跑完不算完,必须校验解的可行性。2E-VRP 的可行性至少包括:每个客户被服务且仅一次、每条路径的载重不超容量、每个卫星站的进出货量守恒、一级车和二级车的路径首尾正确。
function ok = checkFeasibility(sol, data, D) ok = true; % 客户覆盖检查 served = []; for s = 1:length(sol.secondEchelon) for r = 1:length(sol.secondEchelon{s}) route = sol.secondEchelon{s}{r}; served = [served, route(2:end-1)]; % 去掉首尾卫星站 end end if length(unique(served)) ~= data.numCustomers fprintf('客户覆盖异常: 服务了 %d 个, 应为 %d 个\n', ... length(unique(served)), data.numCustomers); ok = false; end % 卫星站流量守恒检查 for s = 1:length(sol.secondEchelon) outLoad = 0; for r = 1:length(sol.secondEchelon{s}) route = sol.secondEchelon{s}{r}; outLoad = outLoad + sum(data.demand(route(2:end-1))); end inLoad = sol.inboundLoad(s); if abs(outLoad - inLoad) > 1e-6 fprintf('卫星站 %d 流量不守恒: 进 %.2f, 出 %.2f\n', s, inLoad, outLoad); ok = false; end end end这个校验函数建议每次迭代后都调用一次,至少在调试阶段。served里去掉首尾是因为二级路径首尾都是卫星站,不是客户。流量守恒的容差用1e-6是防止浮点误差误报。如果这里报不守恒,基本可以断定是邻域移动时只更新了一层的负载,忘了同步另一层。
4. 参数调优与邻域算子:让 2E-VRP 解质量稳下来
4.1 容量、固定成本与迭代次数的联动
2E-VRP 的解质量对参数很敏感,但敏感的不是单个参数,而是几个参数的相对关系。我整理了一张常用参数的影响表,方便你对照调。
| 参数 | 作用 | 调大后果 | 调小后果 |
|---|---|---|---|
| 一级车容量 | 限制单次进站货量 | 一级车次增多,卫星站补货频繁 | 一级车次减少,可能超载不可行 |
| 二级车容量 | 限制单条配送路径长度 | 二级路径变多,车辆固定成本上升 | 路径变长,客户等待时间增加 |
| 卫星站固定成本 | 控制开站数量 | 开站减少,二级距离可能变长 | 开站增多,一级成本上升 |
| 迭代次数 | 搜索深度 | 耗时增加,后期收益递减 | 可能未收敛,解质量不稳定 |
我的经验是先把容量按算例实际车辆吨位设死,不要动;然后调卫星站固定成本,观察开站数量的变化;最后加迭代次数。如果迭代次数从 500 加到 2000,成本只降了不到 1%,说明当前邻域算子已经挖不动了,该换算子而不是继续加迭代。
4.2 relocate 与 swap 算子的 MATLAB 实现要点
relocate 是把一个客户从一条二级路径移到另一条,swap 是交换两个客户的位置。这两个算子写起来不难,难在移动后要同步更新卫星站的进出货量。
function newSol = relocateCustomer(sol, cust, fromSat, toSat, data) newSol = sol; % 从原路径移除 for r = 1:length(newSol.secondEchelon{fromSat}) route = newSol.secondEchelon{fromSat}{r}; idx = find(route == cust, 1); if ~isempty(idx) newSol.secondEchelon{fromSat}{r}(idx) = []; break; end end % 插入目标卫星站的第一条路径(简化处理,实际应选最优插入位置) newSol.secondEchelon{toSat}{1} = ... [newSol.secondEchelon{toSat}{1}(1:end-1), cust, newSol.secondEchelon{toSat}{1}(end)]; % 同步更新进出货量 newSol.inboundLoad(fromSat) = newSol.inboundLoad(fromSat) - data.demand(cust); newSol.inboundLoad(toSat) = newSol.inboundLoad(toSat) + data.demand(cust); end这段代码里插入位置做了简化,直接放在倒数第二个位置。实际用的时候应该遍历所有插入位置,选成本增量最小的。inboundLoad的更新是必须的,否则下一轮可行性校验就会报不守恒。参数cust是统一编号下的客户下标,fromSat和toSat是卫星站的局部编号,调用时注意别传错。
4.3 收敛曲线怎么读:判断是卡住还是正常波动
跑优化的时候,把每轮的最优成本存下来画条曲线,比盯着数字有用。正常收敛曲线应该是前期快速下降,中期震荡下行,后期趋于平缓。如果曲线很早就平了,可能是初始解太好导致邻域跳不出去,也可能是算子太弱。如果曲线一直剧烈震荡不下降,检查评价函数是不是有随机项没固定种子,或者接受准则太宽松。
我一般会同时记录当前解成本和最优解成本两条线。当前解线用来判断搜索是否还在探索,最优解线用来判断有没有实质进步。两条线贴在一起且都不降,就是该停的信号。
5. 2E-VRP 实战避坑:那些让我重跑整晚的细节
5.1 客户被重复服务或漏服务
现象:可行性校验报客户覆盖数不对,但程序不报错,解的成本看起来还挺低。
原因:relocate 或 swap 时,从原路径删除了客户,但插入新路径失败(比如容量检查没过直接 return),导致客户凭空消失;或者插入时没检查是否已存在,同一个客户被插了两次。
解决:所有移动操作先做「预检查」,容量、客户存在性都过了再真正修改解。修改后立即调用一次轻量级的覆盖检查,只查客户集合,不查全量约束,代价很小。
5.2 卫星站流量不守恒
现象:一级车送进卫星站的货量和二级车送出的货量对不上,差得不多但一直存在。
原因:更新inboundLoad时只更新了移动的客户需求量,忘了路径本身可能因为 2-opt 反转导致首尾卫星站变化;或者一级车路径调整后没有重新计算各卫星站的入库量。
解决:把inboundLoad分成inLoad和outLoad两个字段,每次修改后分别重算,最后比较。不要增量更新,增量更新在多层结构里太容易漏。
5.3 距离矩阵下标越界但不报错
现象:程序正常运行,但某些路径成本明显偏大或偏小,解的结构很奇怪。
原因:MATLAB 里D(i, j)如果i或j超出矩阵范围会直接报错,但如果你的索引映射写错,比如把客户下标当成了卫星站下标,取到的距离是另一对节点的,程序不会报错。
解决:在buildDistance里加断言,检查satIdx和cusIdx的最大值不超过N,并且两组下标没有交集。每次取子矩阵前,用assert确认下标范围。
5.4 固定成本设得离谱导致解退化
现象:算法跑出来的解要么只开一个卫星站,要么每个客户一个卫星站,完全不符合实际。
原因:satFixedCost设得太大,开站成本压倒距离成本,算法干脆只开一个站;设得太小,开站几乎免费,算法给每个客户配一个站来缩短距离。
解决:先算一下算例的平均距离和平均需求量,把satFixedCost设成「平均距离 × 平均每站服务客户数」的量级,再上下调一个数量级观察解的变化。不要拍脑袋填 10000 或 1。
5.5 迭代次数够但解不改进
现象:迭代 5000 次和 500 次结果几乎一样,成本曲线早早躺平。
原因:邻域算子单一,比如只有 relocate,搜索空间被限制在「移动单个客户」这个尺度上,无法探索「整条路径重组」或「卫星站开关」这类大动作。
解决:加入 swap、2-opt、cross-exchange 等算子,并且定期做一次「扰动」,比如随机打乱一个卫星站下的客户顺序再重新优化。扰动幅度不用大,但要有,否则局部最优出不来。
6. 把 2E-VRP 代码改造成自己的算例:一个可复用的技巧
跑通别人的代码只是第一步,真正有价值的是把它改成能吃你自己数据的版本。我一般不会直接改主程序,而是写一个适配层,把外部数据转成代码要求的内部结构。这样原始代码保持干净,换算例时只动适配层。
function data = adaptMyCase(rawFile) % rawFile 里假设有三张表:depot.csv, satellites.csv, customers.csv depot = readmatrix('depot.csv'); % 1x2 坐标 satellites = readmatrix('satellites.csv'); % Sx3,前两列坐标,第三列容量 customers = readmatrix('customers.csv'); % Cx3,前两列坐标,第三列需求 data.depot = depot; data.satellites = satellites(:, 1:2); data.satelliteCapacity = satellites(:, 3); data.customers = customers(:, 1:2); data.demand = customers(:, 3); data.numCustomers = size(customers, 1); data.numSatellites = size(satellites, 1); % 车辆参数按实际填 data.vehCapacity1 = 1000; % 一级车容量 data.vehCapacity2 = 200; % 二级车容量 data.satFixedCost = 500; % 卫星站固定成本 data.vehFixedCost = 50; % 车辆固定成本 end这个适配层的价值在于,它把「数据格式」和「算法逻辑」解耦了。你换一个城市的配送数据,只要改 CSV 文件,不用动求解器。参数部分,vehCapacity1和vehCapacity2按你实际车型的载重填,satFixedCost和vehFixedCost按前面说的量级估。填完之后,先跑一个小规模算例,确认可行性校验全过,再上大规模。
我自己的习惯是,每换一次数据,先只跑 100 次迭代,看可行性和成本量级对不对,对了再放开跑。这个习惯帮我省过很多次「跑了一整晚发现数据读错了」的后悔药。2E-VRP 本身不难,难的是两层之间的耦合细节,把索引、守恒、可行性这三件事盯住,剩下的就是调参和换算子的事。希望帮到你。
本文还有配套的精品资源,点击获取