☰
带时间窗车辆路径规划(VRPTW)的模拟退火求解与Matlab实现
2026/10/11 3:42:28 网站建设 项目流程

前阵子为了搞定一个带硬性交付时间的配送调度需求,我把带时间窗的车辆路径规划问题(VRPTW)完整啃了一遍,最终落地实现用的是模拟退火算法结合 Matlab。市面上的开源代码不少,但真正能看懂、能改、能放进论文里的其实不多。这篇博文就把我的完整思路、核心代码结构、参数调优经验以及踩过的坑一次性讲明白,适合刚接触 VRP 的学生、考研或竞赛党、以及想快速验证算法的从业者参考。

模拟退火的优势在于实现门槛低、对初值不敏感、不容易写崩,再加上问题规模控制在几百个节点以内时,它拿到高质量可行解的概率非常高。配合时间窗的惩罚处理,它能把“先满足时间约束、再优化总里程”的多目标压力转成一个可量化的目标函数,这也是它能成为 VRPTW 经典入门算法的原因。

这篇内容会从问题建模开始,一直讲到 Matlab 代码怎么写、参数怎么定、结果怎么画、论文怎么描述,最后附上几个我实际排查过的坑。别把模拟退火想得太玄,本质就是一个“热的时候乱跳、冷的时候细调”的随机搜索,难的是给这个随机搜索配一套不会跑飞的目标函数和解码逻辑。

1. 先把问题本身拆干净:什么是带时间窗的车辆路径规划

1.1 问题定义和常见约束

先看最基础的描述:有一个配送中心,有一组客户,每个客户有坐标位置、货物需求量和允许服务的时间区间,也就是时间窗。仓库需要安排若干台车辆,从配送中心出发,依次服务若干个客户,最后回到配送中心。目标一般是最小化总行驶距离或总成本,同时满足以下几个约束:

  • 每辆车有最大载重,客户需求量不能超过车辆剩余容量;
  • 每个客户只能被一辆车服务一次,且必须在规定的时间窗内开始服务;
  • 车辆可以在时间窗开始前到达并等待,但晚到通常视为严重违约,除非模型允许软时间窗并附加惩罚;
  • 每辆车从仓库出发后连续作业,服务完成后回库。

如果去掉时间窗,这就是经典的容量约束车辆路径问题,也就是 CVRP。而 VRPTW 在 CVRP 基础上多出来的核心难点是“时间窗”这种序列相关的约束。路径的位置顺序会直接影响到达时间,晚到、等待、服务时长、行驶时间交错在一起,导致解空间的可行域被切割得七零八落。

实际物流场景里的时间窗通常分成两类。硬时间窗要求车辆必须在截止时间之前到达,否则这条线路不能成立;软时间窗则允许稍微迟到,但会产生惩罚成本,比如客户投诉折算、赔偿费用、下级转运延迟等待。算法上处理软时间窗更容易收敛,因为可行域不要求严格断裂,而是变成目标函数中的一个大惩罚项。

1.2 数学建模和优化目标

用比较简明的符号表示这个问题。假设有 n 个客户节点,编号从 1 到 n,配送中心记作 0,用集合 N 表示所有节点。客户 i 的坐标、需求量、最早服务时间、最晚服务时间、服务时长分别记为 xi, yi, qi, ei, li, si。行驶时间矩阵为 t_{ij},车辆数量为 K,载重为 Q。

决策变量为 x_{ijk},表示车辆 k 是否从节点 i 行驶到节点 j,另用变量 a_i 表示车辆到达节点 i 的时间。目标函数可以写成:

min Z = ΣΣΣ t_{ij} * x_{ijk} + penalty

约束条件大体包括以下几点:

  • 每个客户只能被访问一次:Σ_j x_{ijk} 对于任意客户 i 和被选中的车辆 k 的求和等于1;
  • 车辆进出守恒:每辆车在每个节点的出度和入度相等;
  • 载重约束:每条路径上的累积需求不超过 Q;
  • 时间窗约束:ei ≤ a_i ≤ li,如果提前到达则允许等待;
  • 起终点约束:所有车辆从仓库出发并回到仓库。

实际编程时我不会字面意义上实现所有约束,而是把约束拆成两类。硬性约束如“每个客户访问一次”和“载重不超”直接控制解的结构;时间窗以及少量载重越界则放进目标函数里加惩罚。这样的处理方式让邻域搜索中的无效解比例大大降低,退火过程也更平滑。

1.3 为什么推荐模拟退火这个元启发式

VRPTW 是典型的 NP-Hard 问题,节点规模一大,精确算法就跑不动了。工业场景和毕业论文里常用的方法是各类元启发式:遗传算法、禁忌搜索、粒子群、蚁群,以及模拟退火。我的经验是,模拟退火在“代码可控性”这个维度上非常强,几乎没有需要调节的复杂算子,核心就是三件事:目标函数、邻域动作、温度更新。

相比遗传算法,模拟退火不需要设计交叉和变异的比例,不担心种群早熟,也不会因为染色体长度不同出现解码困难。相比禁忌搜索,模拟退火的邻域动作可以写得非常简单,不需要维护禁忌表、特赦规则和评价函数。对时间窗这种强约束问题,禁忌表设计不好反而会越搜越偏。

但要注意,模拟退火是单点搜索算法,没有种群多样性,理论上更容易陷入局部最优。因此工程上常常用“多起点”和“加大扰动”来对冲这个问题。后面我会说怎么在参数上做文章,既保留退火的稳定性,又能尽量接近全局最优。

2. 算法设计与实现的关键点

2.1 解的表达方式:用客户序列生成路径

VRPTW 的常见解编码方式有两种。一种是固定车辆数的分段编码,比如每个客户直接指定给第几辆车,再把每辆车的客户列表排序成路线;另一种是客户全排列编码,把所有客户排成一个序列,然后按容量和时间窗约束切分成若干段。

我强烈推荐第二种。原因在于,在做邻域搜索时,你只需要对一条字符串序列做交换、插入、反转,不用时刻顾着车辆编号边界,解码的时候再统一拆分,这样做代码简单得多,而且插入类算子本身就包含“重新分车”的语义。

举个例子,假设存在 8 个客户,排列是 [3, 7, 1, 5, 2, 8, 6, 4]。解码时会从第一个客户开始累加需求和行驶时间,如果某个客户加进来之后导致车辆超载,或者到达时间已经无法满足时间窗,那就在这个位置切开,新开一条路线。每个客户会按排列顺序依次访问,切割后的代码块各自形成一条从仓库出发并回归仓库的路径。

这种编码的优点之一在于,你不需要在每一步都直接记录完整路线,只需要维护当前排列顺序,路径切片完全由约束决定。这个设计大大降低了邻域算子的复杂度。

2.2 初始解的重要性

很多人直接随机生成一个客户排列当作初始解,结果发现后面跑了很久效果都一般。原因不在于模拟退火本身,而在于初始解距离可行域太远,进入可行区域后温度已经降得很低,几乎没有跳出局部极小的能力。

我惯用的初始解构造方法是一个贪心插入策略:从仓库出发,每次都挑选一个“插入后增量最小”的客户。具体逻辑是,先在已有路径的各个位置尝试插入当前候选客户,计算行驶距离增加量、到达时间延迟、违反时间窗的惩罚增量,然后选增量最小的位置插入。如果当前车辆无法承载,就开一辆新车。

这种初始解比纯随机解的质量高不少,通常已经是一个相对合理的路线方案。即使后续退火过程不理想,输出结果的下限也有保障。实测在中等规模算例里,良好的初始解能帮助最终目标值提升约 10% 到 15%。

2.3 邻域搜索算子怎么选

模拟退火迭代的核心动作就是“在当前解附近生成一个新解”,这个动作决定了算法的探索能力和搜索效率。我做 VRPTW 时最常用的邻域动作有三个。

第一是插入算子,从当前序列中随机抽出一个客户,重新放到另一个随机位置。这个算子不仅是路径内部排序的变化,还隐含着改变车辆分配的可能性。如果你只做段内的重新排序,可能会把搜索范围限制在固定车辆路线里,降低求解质量。

第二是交换算子,随机选择序列中的两个客户,交换它们的位置。这个动作对路径长度和到达时间的影响比较随机,升温时可用来做大幅度扰动,低温时则会退化成为微调。

第三是反转算子,类似 2-opt 的思路,取序列中随机一片区间,把区间内客户顺序倒排。这个动作对长距离交叉线路特别有效,可以瞬间消掉一条绕行的路段。

实际执行时,我会给三个算子分配不同概率,通常插入占 0.4,交换占 0.3,反转占 0.3。每次迭代只执行其中一个算子,生成的新序列与原序列只差一个局部结构,目标函数变化也相对平滑,有利于退火过程稳步收敛。

2.4 目标函数与时间窗惩罚的设计

目标函数怎么写,直接影响搜索方向。如果只算总行驶距离,忽略时间窗,算法大概率倾向于把位置相近的客户放在一起,但完全无视时间紧迫性,最后产出大量不可行解。

我采用的方式是加权求和:总成本 = 总行驶距离 + 等待时间成本 + 时间窗延迟惩罚 + 载重超限惩罚。行驶距离是主要优化目标,等待时间乘以一个较小系数,时间窗延迟惩罚则用大权重控制。

时间窗惩罚的设计尤其关键。硬时间窗场景下,若晚到超过阈值就直接判为不可行,那么目标值就会变得非常突兀,搜索容易“碰壁”。更稳妥的做法是设置一个很大的惩罚系数 P,并在退火早期允许少量违约,使算法能穿过不可行区域,在后期再逐渐强化时间窗约束。这实际上是一种变惩罚策略,和退火过程天然契合。

载重惩罚可以很简单,车辆超载量和权重系数的成绩加到目标函数里。由于邻域动作大多是小幅度改动,超载情况一般不会一直存在,惩罚项更多起到引导角色。

3. Matlab 工程实现细节与核心代码

3.1 数据组织和距离矩阵

在 Matlab 写 VRPTW,第一步是把数据整理成标准化结构体。我经常这样组织数据:

  • nodes:n×6 矩阵,分别保存 x 坐标、y 坐标、需求量、最早开始时间、最晚开始时间、服务时长;
  • distMatrix:n×n 对称矩阵,元素是节点之间的欧氏距离或实际行驶距离;
  • travelTimeMatrix:n×n 矩阵,代表行驶耗时;
  • vehicleCapacity:每辆车最大载重;
  • numCustomers:客户总数。

计算距离矩阵时要注意比例问题。如果坐标单位是公里,行驶速度按 40km/h 算,那么时间窗也要用相同的分钟或小时单位。否则可能出现“距离满足、时间完全不匹配”的荒谬局面。代码上通常直接这样生成:

coords = nodes(:, 1:2); numNodes = size(nodes, 1); distMatrix = zeros(numNodes, numNodes); for i = 1:numNodes for j = 1:numNodes distMatrix(i, j) = sqrt((coords(i,1)-coords(j,1))^2 + (coords(i,2)-coords(j,2))^2); end end travelTimeMatrix = distMatrix / speed;

务必要在代码注释里标明单位换算。我见过不少案例,坐标是千米、时间窗是分钟,结果界面上下乱跳。后面这些误差极难排查,因为它不是程序崩溃,而是所有结果都很“合理但差”。

3.2 解码与可行性检查函数

带时间窗的解码听起来复杂,但核心代码其实就是一条循环,逐客户累加时间,并判断新插入客户后是否会超载或超时。

标准化解码函数的形式大致是:

function [routes, totalCost, feasible] = decodeSequence(seq, nodes, distMatrix, travelTimeMatrix, cap, speed, timePenalty, capacityPenalty) % 这是一个示意结构,实际运行时需要处理仓库节点索引 routes = {}; totalCost = 0; currentLoad = 0; currentTime = 0; currentRoute = []; for k = 1:length(seq) nodeIdx = seq(k); if isempty(currentRoute) travelDist = distMatrix(1, nodeIdx); travelTime = travelTimeMatrix(1, nodeIdx); arrive = currentTime + travelTime; else lastIdx = currentRoute(end); travelDist = distMatrix(lastIdx, nodeIdx); travelTime = travelTimeMatrix(lastIdx, nodeIdx); arrive = currentTime + travelTime; end wait = max(0, nodes(nodeIdx,4) - arrive); serviceStart = max(arrive, nodes(nodeIdx,4)); newLoad = currentLoad + nodes(nodeIdx,3); late = max(0, serviceStart - nodes(nodeIdx,5)); % 如果超载或严重超时,强制切分新路线 if newLoad > cap || (late > 1e-6 && 当前路线已有多个客户) % 先收尾当前路线,再开新路线 routes{end+1} = currentRoute; currentRoute = []; currentLoad = 0; currentTime = 0; continue; end currentRoute(end+1) = nodeIdx; currentLoad = newLoad; currentTime = serviceStart + nodes(nodeIdx,6); totalCost = totalCost + travelDist + wait * 0.1 + late * timePenalty; end if ~isempty(currentRoute) routes{end+1} = currentRoute; end end

上面的代码是示意,真实使用还要把“回到仓库”的距离和耗时补上。这里的关键点是服务开始时间的计算:如果车辆提前到达,它要等到最早服务时间才开始;如果晚到,服务开始时间等于到达时间。arrive应该等于上一节点的服务结束时间加上行驶时间,而不是上一节点到达时间直接加上行驶时间。

还有一点很多新手会漏掉:仓库节点本身没有时间窗,但车辆从仓库出发的时间一般设定为 0。当一条新路线开始后,currentTime要重新归零,不能沿用上一条路线的返回时间。

3.3 模拟退火主循环

主循环的结构非常固定。先初始化温度和当前解,然后在每个温度下进行固定次数的迭代。每次迭代随机挑一个邻域动作,生成新序列,解码出目标值,计算增量 ΔE,并按 Metropolis 准则决定是否接受。

function [bestSequence, bestCost, costHistory] = saSolve(nodes, distMatrix, travelTimeMatrix, vehicleCapacity, opts) seq = initialSolution(nodes, distMatrix, travelTimeMatrix, vehicleCapacity); currentSeq = seq; currentCost = evaluateSequence(seq); bestSeq = currentSeq; bestCost = currentCost; T = opts.T0; costHistory = []; while T > opts.Tend for inner = 1:opts.innerIter newSeq = neighborOperator(currentSeq); newCost = evaluateSequence(newSeq); delta = newCost - currentCost; if delta < 0 || rand < exp(-delta/T) currentSeq = newSeq; currentCost = newCost; end if currentCost < bestCost bestSeq = currentSeq; bestCost = currentCost; end end T = T * opts.alpha; costHistory(end+1) = bestCost; end end

这里的关键参数有三个:T0、Tend和alpha。innerIter是每个温度下的迭代次数,一般设置成节点数量的线性或平方函数。如果问题有 100 个客户,我会选innerIter在 500 到 1000 之间;如果节点数到了 300,这个数字可能要提升到 2000 以上。

3.4 初始解生成与邻域算子实现

初始解生成我用的是贪心插入。Matlab 里可以写成这样:

function route = initialSolution(data, distMatrix, travelTimeMatrix, cap) customerList = 1:data.numCustomers; route = []; while ~isempty(customerList) bestNode = -1; bestIncrease = inf; bestPos = -1; for i = 1:length(customerList) cand = customerList(i); % 计算插入到每个位置后的增量成本 for pos = 1:length(route)+1 inc = computeInsertionPenalty(route, cand, pos, data, distMatrix, travelTimeMatrix, cap); if inc < bestIncrease bestIncrease = inc; bestNode = cand; bestPos = pos; end end end route = [route(1:bestPos-1), bestNode, route(bestPos:end)]; customerList(customerList == bestNode) = []; end end

邻域算子的实现就是在序列上做索引操作。插入算子抽出某个位置的元素再插入新位置;交换算子直接newSeq(i) = seq(j); newSeq(j) = seq(i);反转算子则是对中间段使用fliplr。这里要记住一个细节:生成新解后不要修改原序列,而是复制一份再操作。如果直接原地修改,后面计算 delta 用到的“原目标值”和“新目标值”就不是同一基线上的对比了。

4. 参数调优与实验效果分析

4.1 退火温度该怎么初始化和衰减

初始温度 T0 直接影响算法在前面阶段的“容错能力”。如果 T0 太小,算法一开始就不敢接受劣解,搜索很快退化成局部贪婪;如果 T0 太大,前期大量接受劣解,虽然探索范围大了,但会把本来就优质的初始解折腾得面目全非。

一个经验公式是:先随机生成一组邻域解,统计目标值变化量的标准差 σ,然后取 T0 = 初始允许接受概率所需的倍数关系。更粗暴一点,直接把 T0 设成初始目标值的 10% 到 30%。比如初始目标值是 800,那 T0 可以设在 80 到 240 之间。后期温度降到 Tend = 1 或者 0.1 就足够稳定,因为此时每个劣解被接受的概率已经微乎其微。

衰减系数 alpha 的选取与迭代次数密切相关。alpha 越小,降温越快,搜索越快但容易陷入局部最优;alpha 越大,降温越慢,结果通常更好但时间更长。我常用的范围是 0.90 到 0.98 之间的一档。

4.2 参数参考表和实测对比

为了让大家好抄作业,我把自己在 100 个客户规模算例上常用的参数整理成一个表。

参数建议取值调高效果调低效果
T050 ~ 200探索更广,前期震荡大收敛快,容易局部最优
alpha0.90 ~ 0.98结果更稳,耗时增加降温快,结果波动大
innerIter500 ~ 2000求解更彻底,耗时线性上升迭代不足,解质量下降
插入算子概率0.3 ~ 0.4路径分配变化更灵活搜索偏局部
交换算子概率0.2 ~ 0.4大范围扰动更多收敛相对平稳
反转算子概率0.2 ~ 0.4消除交叉路径长距离绕行难改善
时间惩罚系数100 ~ 1000约束硬,可行性强允许晚到,目标值偏低

需要说明的是,惩罚系数不是一个拍脑袋的数,它必须明显大于“正常行驶一段路的成本”。如果时间窗违约一次的总成本是 50,而车辆开一单位距离的成本是 1,那么算法为了规避违约宁愿多绕 50 单位路程,这是合理行为。

4.3 收敛判断和终止条件

判断收敛的方法有两种。一种直观的办法是记录目标值历史曲线,如果在连续多个温度段内,最优解的提升幅度小于 0.5%,就可以提前终止。另一种办法是固定总运行时间,比如限制整体循环 60 秒或 200 秒,到时间直接输出当前最优解。

我在实际求解时倾向使用“温度下限 + 无改进最大温度数”的组合终止条件。也就是即使 T 还没降到 Tend,如果连续 30 个温度阶段都没有找到更优解,就直接跳出。这样可以避免退火后期纯粹在最优解附近白耗时间,对大批量算例尤其有效。不过,论文实验里为了画平滑的收敛曲线,我还是会保留完整退火过程,并把 pre-termination 逻辑单独注释掉。

4.4 多起点和重启策略

模拟退火的单点性质决定了它容易陷入局部最优。最简单的补救办法就是跑多次,随机设置不同初始解,或者温度重启。比如每完成一轮完整退火后,把当前最优解稍微扰动,再把温度弹回 T0,重新开始搜索,这样做相当于做了一次粗糙的“多起点”搜索。

实测结果表明,对 100 个客户级别的问题,跑 3 次独立退火,取最优值,通常比单次长迭代效果更稳定。这是因为每次的运行时长控制在可接受范围,而不同起点能覆盖到不同的搜索区域,组合起来比一条热链贯穿到底更稳妥。

5. 常见问题与排查技巧实录

5.1 解总是晚到,但目标值却很漂亮

这个问题我遇到太多次了。现象是输出的总距离明显低于合理值,但拆开每条路线一看,不少客户到达时间晚于最晚服务时间。原因几乎总是目标函数里时间窗惩罚权重开得太小,等于给算法“吃了颗定心丸”,让它觉得违约无所谓。

排查步骤:第一步检查解码函数中对late的统计;第二步检查惩罚系数是否比“一单位总距离成本”高至少一个数量级;第三步检查时间窗单位对不对。如果坐标单位是公里、速度是 40km/h,那时间单位应该是小时,而不是分钟。我曾经有过整套代码跑了半小时结果全是“晚到”,最后查出来是速度写成了每小时公里数,第一遍单位没对齐。

5.2 邻域操作破坏了客户访问唯一性

插入、交换、反转算子都是在客户序列上做操作,理论上不会改变客户集合本身。但如果你在某一步用了带重复或缺失的随机索引,或者不小心对序列seq直接赋值给newSeq,就会出现“新解里某个客户出现两次、某客户消失”的情况。

建议是在所有邻域操作里都写一个防呆检查:

assert(length(unique(newSeq)) == length(newSeq), '邻域操作生成了非法序列');

这个小断言在调试阶段非常有用。有时候明明目标值改进很多,但跑着跑着就出现“客户数凭空少一个”的问题,根源就是索引打架。

5.3 退火过程快速收敛但结果很差

如果目标值从第一个温度段开始就快速下降,之后几乎不动,大概率是 T0 设得太低或者衰减系数 alpha 太小。可以增加一个“扰动观察”:在初始温度阶段随机接受多个劣解,观察目标值是否能上升之后再下降。如果几次劣解直接全被拒绝,说明初始温度不够,需要把 T0 调大。

另一种情况是邻域算子的“步长”太小,每次只交换相邻客户,导致搜索只能在原路径附近微调。这时可以临时把反转算子加大,或者对序列中间段做更大幅度的重排。步长和温度是两个不同层面的控制维度,二者不能用一个参数替代。

5.4 Matlab 画图时路径乱连线

画车辆路径图一般用 plot 函数,把每辆车的路径节点按顺序连起来。如果解码后返回的 route 列表顺序不对,画出来就会“飞线”。一个关键检查点是:解码函数切分路线后,每辆车的路线结尾是否补上了仓库节点索引 1。如果缺少回程节点,图会缺一条边,看起来像所有路径都直接悬空。

画图代码示例:

figure; hold on; plot(nodes(2:end,1), nodes(2:end,2), 'bo', 'MarkerSize', 8); plot(nodes(1,1), nodes(1,2), 'rs', 'MarkerSize', 12); colormapLines = lines(length(routes)); for i = 1:length(routes) r = [1, routes{i}, 1]; plot(nodes(r,1), nodes(r,2), '-', 'Color', colormapLines(i,:), 'LineWidth', 1.2); end

画完再看一眼,如果某条边跨越了整个图面,大概率是路线序列里少了或多了某个节点。

5.5 数值稳定性问题

时间窗服务时间和等待时间的小数点误差,在某些判断里可能会造成问题。比如判断“到达时间是否晚于最晚服务时间”,由于浮点数的表达误差,一个精确位于边界上的数值可能被判为晚到,从而影响目标函数值。

处理办法很简单,在比较时加上一个小阈值,例如:

if arrive > nodes(nodeIdx,5) + 1e-6 % 计为晚到 end

这个 1e-6 的松弛量不会产生实质影响,但能避免很多“莫名其妙被切路线”的问题。

6. 从代码到论文:实验和结果呈现怎么写

6.1 实验设计和算例准备

论文里最害怕的问题是“只有一张结果表,没有实验过程”。写 VRPTW 求解实验时,至少要交代以下几个方面:

  • 测试算例的规模:节点数量、客户数量、时间窗宽度、车辆容量限制;
  • 参数设定:初始温度、终止温度、衰减系数、内层迭代次数、邻域算子概率;
  • 对比基线:是使用人工经验路径、贪心算法、其他启发式还是公开已知最优解;
  • 评价指标:总行驶里程、总耗时、平均等待时间、超时次数、车辆使用数。

建议在论文中放一张“算例参数表”,列出所有节点的坐标范围、需求量分布、时间窗分布。这样读者才能判断你的实验是否可信。

6.2 结果可视化与收敛曲线

收敛曲线图是模拟退火论文的标配。横轴可以选“迭代次数”或“温度下降阶段”,纵轴选当前最优目标值。不要只画一条最终最优值的曲线,那样看不出算法过程。常规做法是同时画两条线,一条是当前解在每个温度段的目标值,另一条是历史最优目标值。两条线之间的差距能直观体现退火搜索的波动性。

路径规划图建议按不同车辆用不同颜色区分,并标注仓库位置。如果时间窗比较紧,还可以在节点旁边标注“最早开始时间/最晚开始时间”,帮助审稿人理解算法为什么给某些客户安排了特殊顺序。

6.3 对比实验怎么更有说服力

单纯的模拟退火结果很难让审稿人眼前一亮。更稳妥的策略是设置三组对比:

第一组是纯贪心算法结果;第二组是模拟退火使用随机初始解的运行结果;第三组是模拟退火使用贪心初始解加调优参数的运行结果。通过三组数值对比,可以证明算法的改进不只是来源于元启发式自身,更来源于初始解和惩罚参数设计的组合优势。

如果时间允许,可以再加一组“不同惩罚系数下的结果对比”,说明过大惩罚会让解偏保守,过小惩罚会让时间窗失去意义,以此论证你选择的惩罚系数有实验依据。不要只列最佳结果,也要给出平均数、最差值、标准差,这会显著提升论文的实验可信度。

6.4 代码附录的组织

很多论文要求附代码,但很多人直接贴整个脚本,阅读体验很差。更理想的做法是拆分成几个有注释的函数块:数据导入模块、初始解生成模块、邻域搜索模块、解码评估模块、主循环模块、可视化模块。每个模块前用一段文字说明输入输出含义,并把参数设置独立成一个小节,方便读者调整。

Matlab 代码里尽量加中文注释,并写明运行环境版本。如果代码中使用了自定义矩阵或结构体字段,最好在附录开头写一个字段说明表,能省掉读者大量对字段名发愁的时间。

一些实操层面的经验心得

我自己的体会是,模拟退火求解 VRPTW 的难点不在算法本身,而在“时间窗检查”和“路径解码”这两个容易犯迷糊的地方。很多开源代码喜欢把所有逻辑都堆在主循环里,看起来很短,但改一个参数就要全局排查,远不如我上面这种“解码函数 + 邻域算子 + 主循环”的结构清晰。

最后再分享一个小技巧:求解之前,先把仓库坐标和数据规模在图上画一遍,直观感受一下客户分布。如果客户太分散,时间窗又窄,那无论算法调得多好,也不可能达到理想中的“几辆车全走完”。先通过画图确认问题本身是不是“有资格难”,再决定要不要用更复杂的算法。别一上来就写模拟退火主循环,跑完之后才发现数据存在严重单位或坐标错误,那样一天时间就白费了。

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

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

立即咨询