☰
模拟退火求解VRPSPD:同时取送货车辆路径问题Matlab实现
2026/10/15 22:53:01 网站建设 项目流程

上个月我在做一个物流调度的仿真项目,业务方提了一个挺实际的需求:一辆车从仓库出发,挨个客户送货,同时还要把这些客户手里的旧件、返修品当场收回来。一开始我打算偷个懒,直接套经典车辆路径问题来解,结果很快发现不对劲——车装满了要送的货出发,跑一半收了回收件,车厢可能直接爆掉,后面的货根本送不出去。如果严格按两趟车跑,路线成本又几乎翻倍。这个场景就是典型的VRPSPD,同时取送货的车辆路径问题。

这篇文章把我从建模到用模拟退火算法求解、再到Matlab代码落地的一整套过程完整拆开讲一遍,包括问题为什么难、为什么选模拟退火、核心算子怎么写、参数怎么调、现场踩过的坑,最后附可以直接跑的Matlab核心代码。适合运筹优化方向的初学者、做物流调度算法的工程师,以及任何被"既要送货又要收货"折磨过的朋友参考。

1. 同时取送货的车辆路径问题:先从模型看懂核心约束

1.1 经典VRP与同时取送货VRP的核心区别

普通车辆路径问题里,一辆车从车场出发,把货物送到各客户点就完事,每个客户只有需求,车上的货越送越少,容量约束只需要检查"整条路径的总送货量不超过Q"。这种模型在很多教科书里被反复讲,做项目时也最容易先想到。

但VRPSPD完全不同。每个客户有两个量:送货量和取货量。车从仓库出发,按照排好的路线,每到一个客户,先把他要的货卸下来,再把从他手里收到的货装上车。这两个动作在同一时刻、同一客户点发生,不能拆开,更不是"先全送完再统一收"的简单叠加。

为了把问题说清楚,我做了一个10个客户的模拟数据来演示,坐标随机分布在一个平面上,每个客户同时有pickup和delivery值。

客户X坐标Y坐标送货量取货量
1102053
215824
3303042
4251035
552561
618523
7202014
881542
9281223
10122842

车场的坐标是(0,0),所有车辆从车场出发,服务完最后一个客户后返回车场。车辆容量Q设为15,这意味着单辆车不可能一次装下所有客户的送货量总和,必须拆分成多辆车协同作业,路径规划的空间一下子就大了。

1.2 动态载货量的计算与容量约束

VRPSPD最核心的技术点不在目标函数,而在容量约束的"动态性"。普通VRP里,判断一条路径是否超载很简单:路径上所有客户需求加起来小于等于Q就行。VRPSPD不行,因为在路径中途,车上的货物既有"还没送掉的"部分,又有"已经收回来的"部分。

计算当前载货量的公式是:

当前载货量 = 该路径初始装载的送货总量 - 已累计送达的送货量 + 已累计收取的取货量

什么意思呢?假设一条路径是 车场 -> A -> B -> C。出发时车上装着A、B、C三个客户的送货量。服务完A之后,A的送货量卸下去了,A的取货量又装了上来。这时候车上载货量已经变成:B和C的送货量 + A的取货量。如果这个值超过Q,或者之后任何一段路上超过Q,这条路就是不可行的。

所以容量判断必须逐点逐边去做,不能只看整条路径的总量。很多刚接触这个问题的人会在这里栽跟头,把VRPSPD当成普通VRP来校验,结果代码跑出来的路径表面看着总需求不超容量,实际中途某一截早就超载了。

1.3 为什么这问题比普通VRP更难解

理解了动态载货量之后,再看问题的复杂度就很清晰了。首先是解空间规模大,10个客户就是10!种排列,再叠加车辆划分方式,数量级远超过普通VRP。其次是路径间存在强耦合,一辆车载货量变化会影响后续所有客户能否被服务,所以单条路径的局部调整很容易引发全局可行性崩塌。

还有一点容易被忽略:取货和送货的顺序会让路径结构变得更敏感。比如一个客户取货量很大,把他排在路径靠前位置,后续载货量会迅速升高,可能后面的客户就放不进去了;排到靠后位置,又可能让车空跑一段额外路程。这种"位置敏感度"是VRPSPD相比普通VRP特有的难点。

正因为精确算法在这种组合爆炸面前乏力,实际项目里大家才会转向启发式算法。而模拟退火,就是在这个问题上性价比极高的一个选择。

2. 模拟退火算法求解VRPSPD:选型逻辑与整体流程

2.1 几种常见求解方法的适用性对比

做VRPSPD的第一步不是写代码,而是选算法。我在项目里把主流的几类方案都对比过一遍,各有各的适用场景,我整理了一个表:

方法解质量实现难度跳出局部最优能力适用规模
精确算法理论上最优高不具备30个客户以内
贪心/最近邻一般低不具备大规摸快速初始解
遗传算法较高中高较强,靠变异算子中大规模,参数多
禁忌搜索高高强,靠禁忌表中大规模
模拟退火较高低较强,以概率接受劣解中等规模

从我的实际经验看,当客户数量在20到100这个区间时,模拟退火的解质量和投入成本最均衡。遗传算法需要调的参数太多,交叉算子在VRPSPD这种路径型问题上还要处理路径可行性,很麻烦;禁忌搜索效果确实好,但禁忌表的设计、候选解的管理都比较费功夫,项目周期紧张时不划算。

2.2 模拟退火的核心机制:为什么允许劣解反而能找好解

模拟退火借鉴的是金属热处理工艺中的退火过程。金属在高温状态下原子剧烈运动,随着温度缓慢降低,原子逐渐形成能量最低的稳定晶格结构。算法模仿这个过程:先给一个很高的"温度"T,允许解在搜索空间中剧烈跳跃,随着T逐渐降低,搜索范围收窄,最终稳定在一个较优解附近。

这里的关键是Metropolis准则。当新解比当前解差时,算法不是直接拒绝,而是以 exp(-ΔE/T) 的概率接受它。温度越高,这个接受概率越大;温度越低,接受劣解的概率越小。

这个设计解决了一个根本问题:传统爬山法只会往好的方向走,很容易被困在某个局部最优的小山头。模拟退火允许偶尔"走下坡路",相当于让搜索过程能翻过当前山头之间的低谷,去看另一座可能更高的山峰。放到VRPSPD的场景里,就是某个劣解虽然当前总距离更远,但它可能对应的路径结构能释放出更多容量空间,让后续调整找到更优解。

2.3 从初始解到收敛的完整求解框架

我用模拟退火求解VRPSPD的整体流程可以概括为五个阶段:

  1. 构造初始解:随机生成一个客户访问序列,再按容量约束拆分成多辆车路径,算出总行驶距离。
  2. 邻域扰动:对当前序列做反转、交换、插入等操作,生成一个新序列,重新拆分路径并计算总距离。
  3. 接受判断:应用Metropolis准则,决定是否接受新解,同时记录历史最优解。
  4. 降温:按降温系数降低温度,进入下一轮循环。
  5. 终止:温度降到设定阈值以下时输出最优结果。

这套框架最大的优点是把问题求解拆成了独立的模块:编码方式、路径拆分逻辑、邻域算子、温度策略可以分别迭代优化,不会牵一发动全身。我在实际做的时候,每一步都可以单独验证,排查起问题来轻松得多。

3. Matlab代码实现详解:编码、算子与主循环

3.1 输入数据与距离矩阵预处理

Matlab实现的第一步是把数据准备好。我习惯把车场编号固定为1,客户编号从2到11,这样距离矩阵的第一行和第一列就是车场到各客户的距离。

% 输入数据:第1行是车场,其余是客户 coords = [0 0; 10 20; 15 8; 30 30; 25 10; 5 25; 18 5; 20 20; 8 15; 28 12; 12 28]; % 送货量与取货量,长度与coords的行数一致,第1个是车场,量为0 delivery = [0; 5; 2; 4; 3; 6; 2; 1; 4; 2; 4]; pickup = [0; 3; 4; 2; 5; 1; 3; 4; 2; 3; 2]; Q = 15; % 车辆容量 % 计算欧氏距离矩阵 n = size(coords, 1); D = zeros(n, n); for i = 1:n for j = 1:n D(i, j) = sqrt((coords(i,1)-coords(j,1))^2 + (coords(i,2)-coords(j,2))^2); end end

这里我故意不用pdist2,原因有两个:一是pdist2需要统计工具箱,有些基础版Matlab没有;二是对10个客户这种规模,手写双重循环的耗时完全可接受,还能避免额外依赖。如果你的客户量大到上千个,再考虑用向量化距离计算。

3.2 客户序列编码与车辆路径拆分

VRPSPD的模拟退火搜索是在"客户序列"上进行的,这个序列不包含车场,只是一个1行n列的排列。每评估一个序列,就把它按容量约束拆分成若干条车辆路径。

function routes = splitRoutes(seq, pickup, delivery, Q) % 将客户序列按容量约束拆分成多辆车的路径 % 输入seq是不含车场的客户排列,pickup/delivery是对应取送货量,Q是容量 % 返回routes是一个cell数组,每个元素是一条路径的客户序号向量 routes = {}; currentPath = []; nSeq = length(seq); % 初始装载量 = 当前路径所有客户的送货量之和 initLoad = sum(delivery(seq)); load = initLoad; for i = 1:nSeq c = seq(i); % 服务完客户c后,卸掉c的送货,装上c的取货 load = load - delivery(c) + pickup(c); if load > Q % 超载:当前路径结束,客户c分到新路径 routes{end+1} = currentPath; % 剩余客户作为新路径的候选 remainSeq = seq(i:end); currentPath = []; % 新路径的初始装载 = 剩余未服务客户的送货量总和 initLoad = sum(delivery(remainSeq)); load = initLoad - delivery(c) + pickup(c); currentPath = [currentPath, c]; else currentPath = [currentPath, c]; end end if ~isempty(currentPath) routes{end+1} = currentPath; end end

这个拆分逻辑的要点是,每加入一个客户就实时更新当前载货量,路径一旦超载立即截断。如果某个客户单独一辆车都装不下,代码会报错,这提醒你数据需要重新检查。实际项目中,可以用几个不同随机序列测试一下splitRoutes的输出,确认路径拆分逻辑在各种排列下都不会出错。

3.3 三种邻域操作:反转、交换、插入

邻域操作是模拟退火的"引擎",负责从当前解生成新解。我在项目里用了三种算子混合,比单一算子效果好很多。反转算子也叫2-opt,作用是调整车辆内部客户的访问顺序,让路径不自交;交换算子是随机挑两个客户互换位置,可能发生在同一条路径内,也可能发生在不同路径间,等于重新规划了路线结构;插入算子是把一个客户从当前位置拔出来,塞到另一个位置,相当于在不同车辆之间移动客户,这是VRPSPD里最敏感的算子——它直接影响每辆车的容量分配。

function newSeq = neighborhood(seq) % 随机选择一种邻域操作:1反转 2交换 3插入 n = length(seq); newSeq = seq; op = randi(3); switch op case 1 % 反转:区间内客户顺序逆转 i = randi(n-1); j = randi(n-i) + i; newSeq(i:j) = flip(newSeq(i:j)); case 2 % 交换:两个位置互换 i = randi(n); j = randi(n); newSeq([i j]) = newSeq([j i]); case 3 % 插入:把i位置的客户移到j位置 i = randi(n); j = randi(n); if i ~= j c = newSeq(i); newSeq(i) = []; % 删除后索引变化,需要修正j if j > i j = j - 1; end newSeq = [newSeq(1:j), c, newSeq(j+1:end)]; end end end

三种算子各有侧重,如果只用一个,搜索很容易在某个局部区域打转。我实测下来,插入算子的引入对解质量提升最明显,因为它能改变客户所属车辆,相当于把整个解的结构都重新洗了一遍。

3.4 目标函数与模拟退火主循环

目标函数我直接取总行驶距离,同时把容量约束交给splitRoutes来保证。每次评估一个序列时,先拆分路径,再计算所有车辆从车场出发到回到车场的全程距离总和。

function totalDist = evaluateSeq(seq, D, pickup, delivery, Q) % 评估客户序列对应的总行驶距离 % 车场编号固定为1 routes = splitRoutes(seq, pickup, delivery, Q); totalDist = 0; depot = 1; for r = 1:length(routes) path = routes{r}; % 车场到第一个客户 totalDist = totalDist + D(depot, path(1)); % 路径内相邻客户 for i = 1:length(path)-1 totalDist = totalDist + D(path(i), path(i+1)); end % 最后一个客户回车场 totalDist = totalDist + D(path(end), depot); end end

有了评估函数,模拟退火主循环就可以写了:

function [bestSeq, bestDist] = sa_vrpspd(seq0, D, pickup, delivery, Q, params) % 模拟退火求解VRPSPD % params包含T0初温、Tend终止温度、alpha降温系数、L内循环次数 T0 = params.T0; Tend = params.Tend; alpha = params.alpha; L = params.L; curSeq = seq0; curDist = evaluateSeq(curSeq, D, pickup, delivery, Q); bestSeq = curSeq; bestDist = curDist; T = T0; while T > Tend for k = 1:L newSeq = neighborhood(curSeq); newDist = evaluateSeq(newSeq, D, pickup, delivery, Q); delta = newDist - curDist; if delta < 0 || rand() < exp(-delta / T) curSeq = newSeq; curDist = newDist; if curDist < bestDist bestDist = curDist; bestSeq = curSeq; end end end T = T * alpha; % 指数降温 end end

关于这段代码,有一点想特别说明:我记录的是历史最优解,而不是退火过程结束时的最终解。因为模拟退火在高温阶段会频繁接受劣解,最终解不一定是最好的,但整个搜索过程中经过的所有解里,一定有一个相当不错的候选。把bestDist单独存下来,相当于"沿途收集金子",是模拟退火实践里非常实用的小技巧。

3.5 完整调用示例与结果输出

把上面几个函数组合起来,完整调用如下:

% 设置模拟退火参数 params.T0 = 100; % 初始温度 params.Tend = 1e-3; % 终止温度 params.alpha = 0.99; % 降温系数 params.L = 1000; % 内循环次数 % 构造初始解:随机排列 seq0 = randperm(10); % 10个客户 % 调用模拟退火 [bestSeq, bestDist] = sa_vrpspd(seq0, D, pickup, delivery, Q, params); % 查看最终路径 bestRoutes = splitRoutes(bestSeq, pickup, delivery, Q); fprintf('最优总距离: %.2f\n', bestDist); for r = 1:length(bestRoutes) fprintf('车辆%d路线: 车场', r); fprintf(' -> %d', bestRoutes{r}); fprintf(' -> 车场\n'); end

这段代码跑通后,你会看到类似"车辆1路线: 车场 -> 5 -> 10 -> 1 -> 7 -> 车场"这样的输出。我建议第一次运行时把客户数量控制在10到15个之间,这样既能快速验证算法逻辑,也能通过手工推算粗略检查结果合理性。

4. 模拟退火参数调优:从能跑通到跑得好

4.1 初始温度T0:决定接受劣解概率的起点

初始温度T0是模拟退火里最重要的参数。它决定了算法一开始以多大概率接受劣解。如果T0太高,搜索前期完全随机乱逛,浪费大量计算时间;如果T0太低,算法从一开始就退化成爬山法,容易陷入局部最优。

一个比较工程化的做法是:随机采样一批邻域解,统计目标值增量的平均值,用这个平均增量反推初始温度。常见的经验公式是让初始接受概率在0.8左右,即 exp(-Δavg/T0) ≈ 0.8,推导出 T0 ≈ Δavg / 0.223。实际操作中我建议从100开始试,观察接受率,接受率太高就降低,太低就升高,调到0.8附近为准。

4.2 降温速率alpha与内循环次数L

降温速率alpha决定了温度下降的快慢,常用值在0.9到0.999之间。alpha越大,降温越慢,搜索越充分,耗时也越长;alpha越小,收敛越快,但解质量容易打折。我个人的习惯是先跑一轮短实验,客户数10到30个用alpha=0.95快速看结果,确定没有代码bug之后再调到0.99甚至0.995跑最终结果。

内循环次数L表示每个温度下要生成多少个邻域解。一个简单的参考值是L取客户端数的50到100倍。如果你有20个客户,L就是1000到2000。这个值和alpha是搭配使用的,L太小、alpha太小,温度降得飞快,搜索空间根本没被充分探索。

4.3 判断收敛与稳定性的两个观察指标

调参不能靠感觉,得看数据。我会在算法里记录两个指标:一是温度变化过程中每一轮的最优解和当前解,二是每个温度下的接受率。画一张温度-目标值曲线图,理想状态下应该看到目标值前期快速下降,中段缓慢下降,最后趋于水平。如果后段还在猛烈下降,说明终止温度太高或L不足,需要继续降温。

第二个指标是接受率。理论上接受率应该从初期的0.8左右逐渐降到接近0。如果接受率从一开始就很低,说明T0设置太小;如果到了中后期还是0.5以上,说明降温太慢或者L太大。这些都是非常直观的调参信号。

5. VRPSPD求解实战:常见问题与排查技巧

5.1 解经常不可行怎么办

这是我被问得最多的一个问题。很多人实现VRPSPD时,直接拿普通VRP的邻域算子和容量判断来用,结果生成的邻域解大量超载,算法在不可行解之间反复横跳。

我的排查顺序是:先单独测splitRoutes,用几个固定的随机序列手工推演,确认拆分逻辑正确;然后再测neighborhood,检查生成的新序列在拆分后是否可行;最后才连上模拟退火主循环。如果中间发现某一步有大量不可行解,优先检查是不是容量判断写成了"总需求≤Q"而不是逐点判断。

也有人问要不要用罚函数处理容量约束。我的建议是:如果你的目标函数只有距离,把超载量乘以一个惩罚系数加到目标函数里,也是一种可行思路。但用splitRoutes从结构上保证可行性更干净,代码更容易调。两者我都在项目里试过,splitRoutes这种硬约束方式在中小规模算例上完全够用,罚函数方式在超大规模或软约束场景更有优势。

5.2 收敛太快或太慢怎么判断

收敛太快的典型表现是跑了没几轮,目标值就不变了,但最终解明显不合理。这通常是T0太低或者alpha太小。你可以把每轮温度对应的历史最优距离打印出来,如果前面的下降幅度很小,基本就是初温和降温速率的问题。

收敛太慢的典型表现是后期目标值还在抖动,没有稳定下来的迹象。这时候检查终止温度Tend是否太低,或者内循环次数L是不是过大。一个明显的信号是:最后几轮温度下接受率还有30%以上,说明算法还在"热"的状态,其实不需要跑那么多。

5.3 结果每次都不一样正常吗

正常,而且非常正常。模拟退火本质上是随机搜索算法,初始解随机、邻域随机、接受随机,所以每次运行结果不可能完全一致,但因为最优解被单独保存,多次运行之间的差异主要是搜索路径不同。

工程上的标准做法是固定随机种子复现调试过程,用rng(2024)这类语句固定种子;跑最终效果时,则多次运行取历史最优中的最小值。我一般跑10次取最优,耗时可控,解质量也稳定。如果你发现10次运行结果波动很大,说明参数或者邻域算子有问题,需要回去调参。

5.4 Matlab实现中的几个低级坑

Matlab看起来上手容易,但细节坑很多。第一个是索引从1开始,写反转和插入算子时边界条件特别容易出错,建议先用小序列在命令行手动单步调试。第二个是cell数组的索引用大括号,routes{r}和routes(r)是完全不同的东西,我在刚开始写splitRoutes时就踩过。第三个是距离计算,不要在主循环里重复算距离矩阵,预处理一次存成全局变量或传入参数,否则运行时间会成倍增长。还有一个容易被忽略的问题:Matlab对向量和标量的运算会做广播,如果pickup和delivery是列向量,seq是行向量,直接运算可能出现维度对不上的情况,建议统一用列向量。

6. 真实场景与扩展方向

6.1 生产环境中的VRPSPD长什么样

VRPSPD在实际业务里的身影比想象中多。最典型的是快递揽派一体化,快递员送新包裹的同时收退件;另一个典型是汽车配件回收,配件商给维修点送新件,同时把旧件拉回仓库统一处理;还有共享单车调度,运维车辆往热点区域投放单车,同时从淤积点回收多余车辆,本质上也是同时取送货;再比如啤酒配送行业,送新酒和回收空瓶是一趟车完成的。

这些场景和教科书模型的差异在于,真实约束会更多:客户有服务时间窗、车辆有不同载重和车型、订单优先级不同、部分客户只能接受特定车型。基础版模拟退火框架在这些扩展面前依然是可用的,只需要在evaluateSeq或者splitRoutes里加对应的约束判断即可。

我建议做生产项目时,先用基础版模型跑通算法和参数,再在目标函数里逐步叠加约束条件。一次把全部约束塞进去,出了问题根本分不清是算法的问题还是某个约束实现的问题。

6.2 从demo到工程落地还要加哪些东西

如果要把这套demo变成工程可用的版本,我通常会加三样东西。第一是时间窗处理,把硬时间窗转成惩罚项加进目标函数,或者直接在路径拆分时判断到达时间是否可行,这两个方案我都试过,可靠的做法是硬约束加软惩罚结合:硬时间窗保证关键节点,软时间窗用惩罚系数引导。第二是多车场支持,把splitRoutes里车场编号从1改成按车辆起始位置动态变化即可,数据结构上每个客户加一个所属车场字段。第三是结果可视化,画一张路径图,把每辆车的路线用不同颜色标出来,检查路径是否交叉、是否绕路,比纯看数字直观得多。

还有一个比较进阶的方向是处理动态需求,也就是当天新增的取货或送货订单怎么插进已有路线。这个场景下可以定时重新触发模拟退火,只对受影响的车辆做局部重优化,效率比全部重算高很多。

最后分享一点个人体会:模拟退火不是万能的,但在VRPSPD这类中等规模组合优化问题上,它真的是投入产出比最友好的算法之一。做这个项目时我一开始总是想着怎么改进算子、怎么调更复杂的参数,结果浪费时间还不收敛。后来回到基础版本,把初始解、邻域扰动、Metropolis接受准则这三个核心模块吃透,算法很快就跑出了合理结果。如果你想在这个基础上继续深入,建议先把splitRoutes的容量校验逻辑彻底搞清楚,然后试着重写邻域算子,这个过程中你会对VRPSPD的解空间结构有非常直观的理解。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询