☰
VRPTW改进粒子群算法:融合局部搜索与LNS的MATLAB实现
2026/10/6 5:11:51 网站建设 项目流程

做配送路径优化的朋友肯定都有这种体会:拿到一个带时间窗的实例,改进粒子群算法用上去以后,前几十代收敛得挺快,可一到后面就卡住不动,总距离就差那么一点下不来。时间窗越紧,这种情况越明显。我在这类问题上折腾过不少轮,最后把局部最优搜索和大规模邻域搜索算法一起嵌入到粒子群框架里,才算是把解质量拉到了可接受的范围。这篇内容就围绕这套方案的思路拆解和 MATLAB 代码实现来写,适合正在做车辆路径优化、或准备用元启发式算法解决带约束配送问题的同学参考。

1. 问题定义:VRPTW 为什么和普通路径问题不一样

1.1 带时间窗的车辆路径问题到底在优化什么

带时间窗的车辆路径问题,英文缩写 VRPTW,本质上是经典 VRP 的加强版。每条客户需求都有一个最早服务时间和最晚服务时间,车辆到达太早可以等,到达太晚就属于违反约束,必须安排别的路径或别的车辆。我们要做的,是在满足每辆车容量限制、总车辆数限制、时间窗限制的前提下,找到一组从配送中心出发、服务完所有客户后返回配送中心的路径,让总行驶成本尽可能小。

写成数学模型大概是这样的:假设有 N 个客户,编号 1 到 N,配送中心编号 0。车辆集合为 K,容量为 Q。客户 i 的需求为 q_i,服务时间为 s_i,时间窗是 [a_i, b_i]。模型决策变量是 x_ijk,表示车辆 k 是否从点 i 直接行驶到点 j。目标函数通常取总行驶距离最小,也可以加入车辆启用成本。约束至少有这几个:

  • 每个客户必须被访问且只被访问一次;
  • 每辆车从配送中心出发,完成服务后回到配送中心;
  • 每条路径上的累计需求不超过车辆容量 Q;
  • 车辆到达客户 j 的时间不能晚于 b_j,如果早于 a_j,则允许等待到 a_j 再开始服务。

很多人第一次接触这个模型,觉得难的不是目标函数,而是时间窗约束。因为时间沿路径是累加的,前一个客户延误,会直接影响后面所有客户。特别在紧凑排程的场景下,一个路径段的顺序稍微调整,后面一连串客户的到达时间全都变了。所以"顺序优化"在 VRPTW 里不是一个简单排序问题,而是每一步都要做可行的确认。

1.2 为什么基础粒子群算法直接用在 VRPTW 上会很难看

粒子群算法最初是求解连续优化问题的。粒子位置是一个连续向量,速度决定了位置的更新方向和步长。但 VRPTW 的解本质上是离散的路径集合,是一组排列组合。直接用经典 PSO 的形式,连"粒子位置"和"路径方案"的对应关系都定义不出来,更谈不上用速度和位置公式做迭代。

常规做法是用随机键编码。把每个客户编号对应到粒子向量的一个维度,粒子在该维度上的值是一个实数,对所有维度的值排序,排序结果就得到一个客户访问顺序。比如粒子位置是 [0.3, 0.8, 0.1, 0.5],排序后客户顺序就是 3、1、4、2。拿到顺序以后,再按车辆容量和时间窗约束,把客户逐步分配到各条路径上。这个编解码机制本身是可行的,但它有一个致命弱点:粒子在连续空间里的微小变化,映射到客户排列上可能是大幅跳变,也可能是无效变化。粒子群在迭代后期容易陷入局部最优,所有粒子围着一个局部极值打转,速度越来越小,位置更新变成微小扰动,排列顺序几乎不发生变化。

我最早用这种基础 PSO 解 50 个客户的算例时,结果能跑出来,但解质量非常差。对比文献里已知较优解,差距经常在 8% 到 15% 左右,而且迭代曲线后半段基本是平的。问题不是粒子群本身不行,而是它擅长"大范围粗搜",不擅长"局部精修"。配送路径优化里,很多改进都是小幅调整某条路径的访问顺序,比如翻转一段路径、把某个客户挪到另一辆车里,这种局部调整恰恰是连续 PSO 编码最不擅长表达的。因此,要解好 VRPTW,必须给粒子群配上局部搜索和更宏观的扰动机制。

2. 算法层拆解:改进 PSO、局部最优搜索、大范围搜索如何各司其职

2.1 三层结构的整体思路

解决上面的问题,我的做法是把搜索过程分成三层,每一层负责不同粒度的搜索:

第一层是粒子群,负责全局粗搜。粒子群在解空间里保持较强的多样性,不断向历史最优和全局最优靠拢,把解快速拉到较优区域。第二层是局部最优搜索,对当前全局最优解做精细爬山,使用 2-opt、Or-opt、2-opt* 这类成熟算子,把粒子群找到的"好解"进一步打磨成"局部最优解"。第三层是大规模邻域搜索,简称 LNS,也叫破坏与重建搜索。它每轮移除一定数量的客户,再重新插入路径中,通过较大的解结构变动来跳出局部最优。

这三层不是简单叠加,而是按节奏配合。粒子群每迭代若干代,就对 gbest 做一次局部最优搜索;如果全局最优解连续多代没有更新,说明陷入停滞,就触发 LNS 对 gbest 或某个优秀粒子做扰动重建。这样做的好处是:局部搜索保证了对优质解的深度挖掘,LNS 负责在最容易卡死的时候把解结构打散重建,粒子群又保证了整个过程的搜索方向不会像纯局部算法那样完全随机。三者的功能有重叠,但各自解决的核心问题不同。

2.2 局部最优搜索层:把好东西做到极致

局部搜索层选择算子时,我比较过几种常用组合。2-opt 是翻转一条路径里的连续一段,对调整路径内部顺序非常有效,但要注意:翻转后该段拜访顺序完全反过来,时间窗可行性需要重新计算。Or-opt 是把路径中的一段连续客户移动到另一个位置,适合调整局部序列。2-opt* 则是交换两条路径的尾段,它不改变路径内部的客户顺序,只是换一个连接点,对时间窗的影响相对温和,实际效果很好,是带时间窗问题的常用算子。

在实现时,我不会每轮都对所有粒子做局部搜索,那样计算量太大。实际操作是每隔固定代数,对当前全局最优解执行一轮完整局部搜索;每隔更多代,再对粒子群中的若干个精英解做一轮。这样做的原因很简单:局部搜索的时间复杂度不低,特别是 2-opt 需要遍历路径内所有边对,对 100 客户规模的算例,一轮检查可能涉及数千次候选操作,如果每个粒子都做,单代计算成本会爆炸。

2.3 大规模邻域搜索层:破坏、重建,再决定要不要接受

LNS 的核心是破坏算子和修复算子。每次迭代先执行破坏,从当前解中移除 q 个客户,然后执行修复,把移除的客户重新插入路径中。移除比例一般控制在客户总数的 15% 到 30%。比例太小,扰动不足以跳出局部最优;比例太大,修复时间变长,而且解结构被破坏太严重,重新修复后质量可能很差。

破坏算子我常用两种。一是随机移除,实现简单,保证随机性;二是相关移除,把地理位置接近或者时间窗比较接近的客户放在一个移除集合里。相关移除的效果通常更好,因为它把本来可能属于同一条路径的客户解开,让修复阶段有机会重新规划成更合理的组合。修复阶段用贪婪插入或者 regret-2 插入。贪婪插入特别直观:逐个取出被移除的客户,尝试插入所有可行位置,选择成本增量最小的位置放进去。regret-2 更聪明一些,它不只考虑当前最优插入位置,还考虑第二优位置,优先插入那些"如果不现在放,之后可能代价大幅上升"的客户。

修复完成后,新解不一定会更优。LNS 的接受准则我通常用模拟退火式接受:如果新解更优,无条件接受;如果新解变差,以一定概率接受,概率随迭代进行而降低。这个设计很关键,它让算法在搜索早期敢于接受较大波动,后期逐步收敛到稳定区域。

2.4 两层搜索如何写进粒子群主循环

我把整个搜索流程组织成一个多层循环。外层是 PSO 迭代,内层在特定时机触发局部搜索和 LNS。伪代码逻辑是这样的:

初始化粒子群,计算初始 pbest 和 gbest for iter = 1 : maxIter 线性更新惯性权重 w 更新粒子速度和位置 解码粒子位置,评估可行性和目标函数 更新 pbest 和 gbest 如果 iter 达到局部搜索周期 对 gbest 执行局部最优搜索 如果 gbest 连续 stallLimit 代没有更新 对 gbest 执行 LNS 重置停滞计数 记录每代最优成本 end

这个流程里,局部搜索是"锦上添花",LNS 是"穷则思变"。前者让好解更好,后者让算法有机会跳出死胡同。两者和 PSO 交替配合,比我试过的其他混合方式稳定得多。

3. 关键 MATLAB 代码与实现细节

3.1 粒子位置到配送路径的编解码实现

编解码是整个算法里最容易出 bug 的地方。我的做法是随机键编码加顺序分配。粒子位置是一个 1×N 的实数向量,对位置值排序后得到客户访问顺序。随后按顺序把客户插入到当前路径中,如果插入后违反容量或时间窗,就开启一条新路径。

这里的关键在于,时间窗可行性检查必须被集成到解码过程中。假设当前路径最后访问的客户是 last,新的候选客户是 c,需要计算从 last 到 c 的行驶时间,加上到达 last 的时间和服务时间,得到理论到达时间。随后用 max(到达时间, a_c) 得到实际开始服务时间,如果开始服务时间大于 b_c,则客户 c 不能紧跟 last 放入当前路径。

function routes = decodeParticle(pos, model) N = model.N; dist = model.dist; demand = model.demand; timeWin = model.timeWin; serviceTime = model.serviceTime; capacity = model.capacity; [~, order] = sort(pos); routes = {}; curRoute = []; curLoad = 0; curTime = 0; % 从配送中心 0 出发的时刻 currentPoint = 0; for i = 1:N c = order(i); travelTime = dist(currentPoint, c); arrival = curTime + travelTime; start = max(arrival, timeWin(c, 1)); if curLoad + demand(c) <= capacity && start <= timeWin(c, 2) curRoute = [curRoute, c]; curLoad = curLoad + demand(c); curTime = start + serviceTime(c); currentPoint = c; else % 关闭当前路径,开启新路径 routes{end+1} = curRoute; curRoute = c; curLoad = demand(c); arrivalFromDepot = dist(0, c); start = max(arrivalFromDepot, timeWin(c, 1)); curTime = start + serviceTime(c); currentPoint = c; end end if ~isempty(curRoute) routes{end+1} = curRoute; end end

这个解码器看起来简单,但在"时间窗很紧"的场景下,直接把客户按排序结果放入路径可能导致很多客户都开新路径,车辆数爆表。我在实际代码里会加一个步骤:在放入客户 c 之前,不只尝试把 c 放到当前路径末尾,而是在当前路径内的所有插入位置之间做一次最小代价插入检查。这样可以显著提高路径的填充率。

3.2 粒子群更新主循环代码骨架

粒子群的主体代码没有太多玄学,重点是边界处理和惯性权重的变化策略。MATLAB 里写起来大概是下面这个样子:

function [gBest, gBestCost, history] = pso_vrptw(model, params) nPop = params.nPop; maxIter = params.maxIter; wStart = params.wStart; wEnd = params.wEnd; c1 = params.c1; c2 = params.c2; N = model.N; pPos = rand(nPop, N); pVel = zeros(nPop, N); pCost = inf(nPop, 1); pBest = pPos; gBest = pPos(1, :); gBestCost = inf; for i = 1:nPop routes = decodeParticle(pPos(i, :), model); cost = evaluateRoutes(routes, model); pCost(i) = cost; pBest(i, :) = pPos(i, :); if cost < gBestCost gBestCost = cost; gBest = pPos(i, :); end end stallCount = 0; for iter = 1:maxIter w = wEnd + (wStart - wEnd) * (1 - iter / maxIter); for i = 1:nPop pVel(i, :) = w * pVel(i, :) ... + c1 * rand(1, N) .* (pBest(i, :) - pPos(i, :)) ... + c2 * rand(1, N) .* (gBest - pPos(i, :)); pPos(i, :) = pPos(i, :) + pVel(i, :); % 边界处理,将位置限制在合理范围内 pPos(i, :) = max(pPos(i, :), 0); pPos(i, :) = min(pPos(i, :), 1); routes = decodeParticle(pPos(i, :), model); cost = evaluateRoutes(routes, model); if cost < pCost(i) pCost(i) = cost; pBest(i, :) = pPos(i, :); end if cost < gBestCost gBestCost = cost; gBest = pPos(i, :); stallCount = 0; else stallCount = stallCount + 1; end end if mod(iter, params.lsFreq) == 0 [gBest, gBestCost] = localSearchOnSolution(gBest, model); end if stallCount > params.stallLimit [gBest, gBestCost] = lnsOnSolution(gBest, model); stallCount = 0; end history(iter) = gBestCost; end end

位置上下界我用了 0 和 1,因为随机键的位置值本身只在排序中起作用,归一化以后不会影响解码结果。粒子速度我会限制在 [-0.5, 0.5] 之间,防止粒子位置飞出太远。实践里这个边界限制对收敛速度影响很直观,不加的话,粒子容易集群性发散。

3.3 局部搜索算子的实现:先算增量,再验时间窗

写局部搜索时,有个很实用的技巧:不要一上来就完整解码整条路径,而是先计算候选操作对路径成本的影响,再快速判断时间窗是否可行。如果不可行,直接跳过。这个技巧能省掉大量无意义的路径重构计算。

以 2-opt 为例,对一条路径 [0, c_1, ..., c_m, 0],去掉边 (i, i+1) 和边 (j, j+1),换成 (i, j) 和 (i+1, j+1)。在距离矩阵已经预计算好的前提下,新路径的总距离变化量是:

delta = dist(i, j) + dist(i+1, j+1) - dist(i, i+1) - dist(j, j+1)

如果 delta 不小于 0,这个操作对距离没有改善,可以直接跳过。如果 delta 小于 0,再进一步检查时间窗。由于 2-opt 会反转 i 和 j 之间的客户顺序,路径内各节点的到达时间都要重新推算。我会写一个独立函数,只对受影响的那一段做前向时间推进,而不是重新计算整条路径。

function route = twoOpt(route, model) n = length(route); improved = true; while improved improved = false; for i = 1:n-1 for j = i+2:n delta = model.dist(route(i), route(j)) ... + model.dist(route(i+1), route(j+1)) ... - model.dist(route(i), route(i+1)) ... - model.dist(route(j), route(j+1)); if delta < -1e-9 && isFeasibleAfterReverse(route, i+1, j, model) route(i+1:j) = route(j:-1:i+1); improved = true; end end end end end

局部搜索要得到好效果,关键是让 2-opt、Or-opt 和 2-opt* 共享同一套时间窗检查函数。时间窗检查函数的逻辑我写成了一个公共模块,所有算子在生成候选解后都调用它。这样避免了不同算子在时间窗处理上行为不一致的问题,也方便后续调试。

3.4 大规模邻域搜索的破坏与修复代码

LNS 的破坏阶段相对好写。相关移除的实现是随机选一个客户作为种子,然后按距离或时间窗差异从小到大选择其他客户,组成移除集合。修复阶段我用贪婪插入。这里给出最简可运行的版本:

function newSolution = lnsDestroyRepair(routes, removedCustomers, model) % 分两步:先移除,后重新插入 partialRoutes = removeCustomersFromRoutes(routes, removedCustomers); newSolution = greedyInsertion(partialRoutes, removedCustomers, model); end

greedyInsertion 的实现,会对每个待插入客户遍历所有路径的所有插入位置,计算成本增量和时间窗可行性,选择增量最小的位置插入。每插入一个客户后,需要重新计算受影响路径的时间信息。这个过程的复杂度是 O(R × M × P),其中 R 是待插入客户数,M 是路径数,P 是每条路径的平均位置数。对 100 客户的算例,这样一轮修复大概需要几千次候选操作,在 MATLAB 里跑一秒钟以内可以完成。

regret-2 插入比贪婪插入多了一步:对每个待插入客户,找出成本增量最小的两个可行位置,计算两者的差值。差值越大,说明"现在不插,之后可能付出更大代价",优先插入这类客户。这个策略对时间窗紧的情况特别有效,因为时间窗紧的客户可供选择的插入位置本来就不多,如果不在早期处理,后面可能根本没有可行位置。

function newSolution = regretInsertion(partialRoutes, customerList, model) while ~isempty(customerList) bestCosts = inf(length(customerList), 2); for idx = 1:length(customerList) c = customerList(idx); candidateCosts = []; % 遍历所有路径所有位置,收集可行的成本增量 for r = 1:length(partialRoutes) route = partialRoutes{r}; for pos = 1:length(route)+1 newRoute = insertCustomer(route, c, pos); if isFeasible(newRoute, model) costDelta = computeDelta(partialRoutes{r}, newRoute, model); candidateCosts(end+1) = costDelta; %#ok<AGROW> end end end candidateCosts = sort(candidateCosts); if length(candidateCosts) >= 2 bestCosts(idx, 1) = candidateCosts(1); bestCosts(idx, 2) = candidateCosts(2); elseif length(candidateCosts) == 1 bestCosts(idx, 1) = candidateCosts(1); bestCosts(idx, 2) = candidateCosts(1); end end regret = bestCosts(:, 2) - bestCosts(:, 1); [~, bestIdx] = max(regret); c = customerList(bestIdx); customerList(bestIdx) = []; % 将客户 c 放到其最优位置 [partialRoutes, ~] = insertCustomerAtBest(partialRoutes, c, model); end newSolution = partialRoutes; end

实际写代码时,我会把 insertCustomerAtBest 这类函数拆得细一些,保证每个候选位置都能复用同一个插入子函数。这样主流程看起来更清晰,也更方便做单元测试。

3.5 算法参数应该如何配置

参数配置是这类算法最玄学的部分。我针对 100 客户左右的算例,整理了一套比较稳的配置,见下表。

参数取值说明
粒子数 nPop80客户数越多,粒子数适当增加
迭代次数 maxIter300可以配合停滞条件提前退出
惯性权重 w0.9 到 0.4 线性递减前期重探索,后期重开发
学习因子 c1, c21.5, 1.5均衡个体认知和群体认知
局部搜索周期每 5 代一次对 gbest 执行
LNS 触发停滞代数15 代gbest 连续 15 代不更新时触发
移除比例20%例如 100 客户移除 20 个
LNS 接受温度初值当前最优成本的 5%按模拟退火方式衰减

局部搜索周期如果太密,计算时间会明显增加,但解质量提升不多;如果太疏,gbest 很长时间得不到精修,粒子群会在相对差的解上反复震荡。LNS 触发条件我倾向用"停滞代数"而不是固定周期。因为固定周期在算法前期会打断正常收敛,而停滞条件只在算法确实碰到瓶颈时才介入,干扰更小。

4. 参数配置、测试算例与对比效果

4.1 标准测试算例怎么选

做 VRPTW 算法对比,绕不开 Solomon 标准算例。它把测试数据分成 C 类、R 类和 RC 类三类,分别代表聚类分布、随机分布、随机加聚类混合分布。每类又有 100 客户的算例,时间窗口的松紧程度也做了区分。C101 这类算例客户在地理上聚集成簇,解起来相对容易;R101 客户分布比较随机,难度更高;RC101 是两者混合,结构更复杂。

我平时做算法验证,会在三类里各挑一个代表算例,比如 C101、R101 和 RC101。这样既能看算法在聚类结构下的表现,也能检验随机分布下的鲁棒性。测试时所有算法用相同的解码函数、相同距离矩阵和相同车辆容量,只改变搜索策略,这样对比结果才有说服力。

4.2 加不加局部搜索和 LNS 的差距有多大

不同算法组成的对比趋势很稳定。基础 PSO 解 100 客户的 R101 类算例时,总距离偏离已知较优解经常在 5% 到 8% 之间,有时车辆数还会多一辆。加入局部最优搜索后,解质量提升明显,偏离幅度能压缩到 3% 到 5% 左右。再加 LNS 后,偏离幅度进一步降到 1% 到 2%。这个趋势在图和表里已经看不出来有几倍差别,但放到城市配送的真实场景中,总距离每降低 1% 都可能意味着每趟车少跑几公里,积累下来很可观。

算法组成全局搜索能力局部精修能力跳出局部最优能力典型计算耗时
基础 PSO中弱弱低
PSO + 局部搜索中强较弱中等
PSO + 局部搜索 + LNS中强强中高
纯 LNS弱较强强高

基础 PSO 的特点是"跑得快但容易停在差解上",纯 LNS 的特点是"能跳出局部最优但搜索方向太随机"。两者结合的效果,比我单独加大 PSO 迭代次数或单独增加 LNS 迭代次数都要好。

4.3 收敛曲线能看出什么

我在项目里遇到过一种情况:加了 LNS 后,迭代曲线反而出现突然上升又下降的毛刺。这不是算法出错,而是 LNS 接受较差解导致的。模拟退火机制允许算法在特定温度下接受劣化解,所以成本值会短暂上升,然后修复插入又把它拉回来。看到曲线有小幅回弹,不必紧张,只要整体趋势向下就行。反而是那种曲线一直平滑下降、后期完全平坦的版本,更可能陷在局部最优里。

时间窗越紧,收敛后期的平台期越长。因为可行解的邻域结构被压缩,能做的局部改进越来越少。这时候 LNS 的移除比例要适当调大,比如从 20% 调到 30%。我的经验是,时间窗紧的算例,LNS 起作用的方式不是让小改动更精细,而是把一批客户从原路径中拔出来重新组合,形成新的可行结构。

5. 实操中的坑及排查方法

5.1 解码失败导致车辆数爆炸

最常见的 bug 出现在解码阶段。当粒子排序后的客户顺序不佳时,按顺序分配客户会不断开新路径,到最后一辆车都装不满,车辆数远远超过可用车辆数。这个问题在基础 PSO 里很常见,原因不是算法流程写错,而是解码时只把客户追加到路径末尾,从不在已有路径中寻找更优的插入位置。

解决办法有两个层面。第一层是解码时使用最小插入策略,在把客户加入路径前,尝试所有可行插入点,选择增量最小的位置。第二层是把"车辆数"作为目标函数的次要项,比如目标函数写成总行驶距离乘以一个大系数,再加上车辆数乘以一个惩罚权重。这样算法会主动倾向使用更少的车辆。

5.2 时间窗检查的细节错误

时间窗检查看着简单,实际容易出三个错。第一,只用客户的理论到达时间判断是否晚于 b_i,却忽略了服务时间和行驶时间的传递关系。第二,没有区分"到达时间"和"开始服务时间",早到客户有等待时间,等待之后的出发时间才是下一段路程的起点。第三,2-opt 翻转路径后没有重新计算整段顾客的到达时间,导致生成了实际上不可行的解。

我建议把所有时间窗检查集中到一个函数里,输入是完整路径,输出是布尔值。任何算子要修改路径,都先调用这个函数。这样排查时不用到处找逻辑分散的时间判断。

5.3 LNS 修复阶段性能太慢

LNS 每轮修复都要做大量插入尝试,如果直接遍历路径并逐个复制数组,在 MATLAB 里会特别慢。性能问题通常集中在两个地方:一是每次插入都重新计算整条路径的时间序列,二是候选位置探索用了不必要的 cell 数组复制。优化手段是先做预计算,把每辆车当前路径的到达时间序列存成数组,插入客户时只局部更新这个数组;候选位置探索则用累加器和索引记录,不要频繁扩展数组。

还有一个很实在的技巧:距离矩阵、时间窗矩阵、服务时间向量都提前做成全局变量或者结构体字段。在粒子群迭代中反复访问这些数据时,结构体字段的访问速度比反复从函数参数里解包快不少。

5.4 随机性带来的复现问题

元启发式算法本身有随机性,这没问题。但如果你的代码在两次运行之间结果差异很大,就要检查随机种子是否固定了。MATLAB 里用 rng(seed) 可以把随机数发生器重置到固定状态。我在项目开始时固定种子,在调试时需要对比不同参数时也固定种子。真正做实验对比时,则跑多个随机种子取平均值,避免参数调整被随机波动掩盖。

另外要注意,MATLAB 并行工具箱里的 parfor 循环,如果配合随机函数使用,每个工作进程的随机流需要单独设置。否则并行版本和串行版本的结果可能对不上。

5.5 惩罚系数的平衡问题

很多人在目标函数里加入时间窗违反惩罚项。惩罚系数太小,算法会大量生成超时解,因为超时付出的代价低于路径缩短带来的收益;惩罚系数太大,算法又不敢尝试任何轻微违反约束的解,搜索空间被严重限制。我的做法是让惩罚系数和总距离处于同一数量级,然后用一个加权因子慢慢递增。这种方式在实际使用中比固定惩罚系数更稳,既能前期探索较广区域,又能在后期收紧约束。

6. 后续扩展与个人心得

6.1 这套框架怎么扩展到更复杂的场景

这套"PSO + 局部搜索 + LNS"的框架,扩展性比我预想的好。加一个硬时间窗改成软时间窗的功能,只需要在时间窗检查函数里把不可行判断改为"可接受但需要惩罚";加入多车型,只需要在解码时给每辆车增加车型属性;加入客户偏好,只需调整插入位置选择逻辑。核心的粒子群迭代结构、局部搜索算子和 LNS 破坏修复机制都不用大改。

我还在一个带时间窗和多配送中心的项目里用过类似结构,改动主要集中在解码函数和局部搜索算子的适用范围。整体搜索框架几乎原样搬了过去,效果也稳定。

6.2 我在实际项目中坚持的几个原则

第一,先跑通基础 PSO,再加上局部搜索,最后加 LNS。别开始就堆所有模块,出了问题根本不知道是谁引起的。第二,参数调优时一次只改一个参数,先用默认参数跑一遍,再逐个调。第三,每次改完代码,都要用一个小算例快速验证可行性,再用大算例跑完整实验。第四,时间窗问题的局部搜索和 LNS 都必须建立在一套统一的时间窗检查函数上,各模块自己写检查逻辑后期会苦不堪言。

关于 LNS 的使用,我还有一个小技巧:不要每次触发 LNS 都对 gbest 做完整重建,可以把 LNS 用在几个不同粒子位置抽出的精英解上,增加解结构的多样性。这在实际运行中让最终解质量比单纯作用在 gbest 上更好一点,而且多消耗的计算时间并不多。

6.3 最后再分享一个调试经验

如果你发现改进粒子群算法的结果反而不如基础 PSO,不要先怀疑局部搜索或者 LNS 写错了。先看解码器对同样粒子位置是不是每次都给出稳定解,再看 gbest 在局部搜索后是否有同步更新。我踩过一次很深的坑:局部搜索改善了 gbest,但粒子群主循环里的 gbestCost 没有同步更新,结果粒子群还在向一个落后的 gbest 位置靠拢,搜索方向全是错的。这类状态同步问题,在多层结构里特别容易出现,建议把 gbest、gbestCost、gbestRoutes 放在同一个结构体里管理,避免多个变量不同步。

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

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

立即咨询