假设你接了一个真实的配送优化需求:手头几十个客户点,每个点不光给坐标和货量,还附带一个严格的收货时间窗,早到没人接、晚到要承担违约金。这时候光会跑一个最短路径算法是远远不够的,你面对的是典型的带时间窗车辆路径问题(VRPTW),组合优化里出了名的NP-hard难题。我刚做完的一套MATLAB方案,就是用遗传算法(GA)做VRP路径优化,把带时间窗当成默认配置来处理,同时给多车型、多车场这些扩展需求留好了口子。这篇就把这套程序的建模思路、编码设计、MATLAB实现模块,以及我实测踩过的坑完整复盘一遍。无论你是做物流调度、无人机或AGV路径规划,还是在写相关毕业设计,这套思路都能直接参考着用。
1. VRP家族到底在优化什么:从纯距离到时间窗的现实进化
1.1 基础VRP为什么在真实配送中不够用
经典的VRP(车辆路径问题)描述得非常简洁:一个车场、若干客户点、若干辆车,每辆车有容量上限,目标是用最少的车辆或最短的总行驶距离,完成所有客户的配送,每辆车从车场出发最终还要回到车场。单独看这个定义,很多人会以为"这不就是多辆车的旅行商问题(TSP)吗",但实际项目恰恰最容易栽在这个认知偏差上。
当我拿第一版代码去做真实算例时,立刻发现了问题:真实配送里客户并不是给个坐标就完事,几乎每个客户都有自己的"门禁条件"。最常见的就是时间窗,比如客户只允许周二上午十点到十二点之间收货,太早送过去仓库没人接,太晚又耽误人家营业。除此之外还有服务时间、卸货时长、车辆固定成本、司机工时限制。如果把这些约束全部忽略,只做纯距离最短优化,得到的路线很可能在实际中根本执行不了。
所以现在的VRP研究里,带时间窗版本(VRPTW)反而是实际应用最多的变体,Solomon基准算例的一大半精力都在研究如何平衡距离、车辆数和时间窗可行性。对做项目的人来说,掌握"如何在最短路线与现实约束之间权衡",比单纯会套一个遗传算法重要得多。
1.2 VRPTW的数学模型与四组核心约束
先把模型摆清楚。VRPTW定义在一个有向图 G = (V, E) 上,V = {0, 1, ..., n},其中0是车场,1到n是客户。每条边(i, j)有一个旅行成本 c_ij,一般是距离或行驶时间。每个客户i有一组属性:需求量 q_i、服务时间 s_i、允许开始服务的时间窗口 [e_i, l_i]。车队共有K辆车,每辆最大载重为Q。
决策变量是 x_ijk,表示车辆k是否从客户i直接驶向客户j,是则取1,否则为0。目标函数通常是最小化总行驶距离,即所有车辆、所有路段成本之和。约束条件里四组核心约束一个都不能少:
- 每个客户的入度和出度均为1,也就是每个客户恰好被服务一次,且必须由同一辆车完成。
- 车辆从车场出发后必须回到车场,形成闭合路线。
- 每辆车沿途累积需求不能超过容量Q。
- 车辆到达客户j的时间 t_j 必须落在时间窗口[e_j, l_j]内,违反即违约。
时间部分的递推公式是初学者最容易写错的点。正确的逻辑不是简单写 t_j = t_i + s_i + travel_time(i,j),因为如果车辆到得太早,应该在客户处等待到窗口开启,所以是 t_j = max(t_i + s_i + travel_time(i,j), e_j)。这个"早到可以等"的细节,直接决定了适应度函数怎么写,后面我会重点展开。
另外还有一个容易被忽略的点:车场本身也要当作一个节点参与建模,每辆车必须先访问车场、再经过若干客户、最后回到车场,车场的时间窗口等同于全天开放。如果数据结构里不把车场的角色统一,写代码时很容易出现车辆在"车场-客户"之间折返穿越的bug。
1.3 时间窗的软与硬:惩罚函数设计的前置判断
时间窗在建模里分硬时间窗和软时间窗。硬时间窗意味着 l_i 是绝对底线,晚到一点方案就作废,通常需要精确算法或非常可靠的约束处理手段;软时间窗则允许迟到,但迟到时长会被计入目标函数的惩罚成本。
遗传算法作为启发式算法,处理硬约束时我试过两种方案。第一种是在解码时直接淘汰违反时间窗的个体,但问题规模一旦扩大到30个客户以上,不可行解比例会明显升高,种群的进化效率很低,经常在500代内都收敛不到像样的结果。第二种是我实际采用的方案:把时间窗设计成软约束,在适应度函数里加惩罚项,让进化过程从"先找到可行区域、再在可行区域内精细优化"自然展开。实测下来,即使初始种群质量很差,算法也能靠一部分路径可行、一部分路径违约的中间状态逐步修正,收敛速度和解质量都有明显提升。
惩罚系数也别拍脑袋定。我第一版代码把早到和晚到一律按同等系数惩罚,结果算法为了躲惩罚拼命绕路延长行驶时间,把总距离推得很高。后来改成"早到只记录等待时间不计惩罚,晚到按单位时间乘以惩罚系数计入成本",目标函数的引导作用才恢复正常。
2. 遗传算法解VRPTW的核心决策:编码、解码与适应度函数
2.1 染色体编码:0分割的整数排列
我选的编码方案是"整数编码 + 0分隔符",这也是MATLAB实现里最直观、最容易调试的方式。染色体长度固定为 n + maxK - 1(n为客户数,maxK为允许的最大车辆数),基因由0和客户编号共同构成,客户编号恰好各出现一次,0负责把整条染色体切成多段,每一段代表一辆车的服务顺序。
举个例子,假如有5个客户、2辆车,染色体是 [0 2 1 5 0 3 4],解码结果就是:第一辆车从车场出发,依次服务客户2、1、5,然后回厂;第二辆车服务客户3、4,回厂。如果出现连续两个0,比如 [0 0 1 2 3 4],第二辆车从第二个0开始,但没有实际客户,相当于该车闲置,解码依然合法,只是目标函数里多了一辆空车的固定成本。
这种编码最大的好处是操作直观,交叉、变异都能直接在整数序列上进行。但它有两个必须处理的缺点:第一,0会重复出现,交叉后0的数量可能变化,导致隐式车辆数变动,需要设计修复逻辑;第二,标准遗传算子作用在这种编码上并不天然保证"每个客户只出现一次",所以每次交叉变异后都要做合法性修复。这两个点我在第三章会给出具体的处理办法。
2.2 解码:把一串整数变成"车辆的一天"
解码是整个程序里最核心的公共步骤,因为适应度计算、局部搜索、迭代过程可视化都要反复调用它。我实现的decode函数输入是染色体、客户坐标、需求量、时间窗、服务时间、车辆容量等参数,输出一个结构体,包含每辆车的客户访问顺序、累积载重和到达时间序列。
解码流程从前往后扫基因:遇到0就表示开启新车,遇到客户编号就追加到当前车的路径末尾。如果0的数量少于车辆上限,剩下的车不会出现,相当于闲置车;如果解码时发现某段路径的总需求超过该车容量,我不会马上把整条染色体判死,而是先记录容量违约值,交给适应度函数处理。
时间计算顺序是这样的:对每一段路径,从车场出发开始,先算边(i,j)的行驶时间(用欧氏距离除以速度,或者直接查实测旅行时间矩阵),到达j点后记录到达时刻arrival[j]。如果arrival[j]早于e[j],车辆等待e[j] - arrival[j]时间,然后再开始服务;服务完成后带着新的时刻前往下一个客户。全部扫完后,累计所有车辆的行驶距离、等待时间、迟到时间和总载重,作为目标函数的基础量。
解码这一步一定要把边界条件处理干净,我最初写解码器时因为连续0没处理好,导致空车段里出现"车场访问车场"的伪路径,适应度计算直接崩掉。建议你在实现时把"染色体开头结尾的0""连续两个0""某段只有车场没有客户"三种情况分别画到测试用例里,跑通了再往下走。
2.3 适应度函数:总距离、容量惩罚、时间惩罚如何加权
适应度函数我采用最小化目标函数的形式,遗传算法的选择环节基于它的倒数或排序值。我定义的目标函数如下:
totalCost = alpha * totalDistance + beta * totalLatePenalty + gamma * totalOverloadPenalty + vehicleFixedCost * usedVehicleNum
其中alpha通常取1,表示单位距离成本;beta是迟到惩罚系数,gamma是超载惩罚系数,vehicleFixedCost是每启用一辆车的固定成本。惩罚系数要设置得足够大,让违反约束的方案明显劣于可行方案,但也不能大到把距离差异完全抹平。经过多组实验,我发现beta取"平均单次运输成本量级的3到5倍"、gamma取"超载量平均价值10倍以上"比较合理,趋势是惩罚力度足够,进化过程才舍得花代数去修正违约部分。
如果时间窗是硬性的,可以把惩罚系数调得更大,等价于拒绝违约方案;如果业务上允许一定弹性,可以适当调低,允许算法在旺季容忍少量迟到换取明显缩短的总里程。这个取舍本质上是一个业务决策,不是纯算法决策。我建模时把权重都做成了参数,调用方传入一个struct对象就能灵活调整。
还有一个我踩过的重要误区:把早到时间也放进惩罚里。第一版适应度函数我对早到和晚到一视同仁,结果出现了一个很反直觉的现象——算法为了让某个点"恰好踩点到达",不惜大量绕路去拖延时间,总距离比不调时间窗的方案高出不少。后来我把等待时间从惩罚项中剔除,只作为统计信息输出,问题立刻缓解。这个改动小,但效果非常明显,第四节我会给出详细对比。
3. MATLAB实现:核心模块的拆解与代码骨架
3.1 主循环与参数配置
程序主循环严格遵循"初始化—评估—选择—交叉—变异—局部搜索—精英保留"的标准框架,最大进化代数用maxGen控制,每个世代记录当代最优和全局最优。为了让MATLAB跑得高效,我在进入循环前就预计算好距离矩阵和客户属性,主循环里只做索引运算,避免每代反复算坐标距离。
关键参数我统一放在配置文件里,方便批量实验:
| 参数 | 取值 | 说明 |
|---|---|---|
| popSize | 100~200 | 种群规模,问题规模大时取大 |
| maxGen | 300~500 | 最大进化代数 |
| pc | 0.85 | 交叉概率 |
| pm | 0.10 | 变异概率 |
| eliteRatio | 0.10 | 精英保留比例 |
| penaltyLate | 4~6 | 迟到惩罚系数 |
| penaltyOverload | 10 | 超载惩罚系数 |
这些参数不是凭空定的,而是我用Solomon C101一类标准算例做了20轮实验比对后的偏好值。你可以从这一组起点出发,再拿自己的数据重新调。
3.2 种群初始化:随机加贪心混合
初始化这一步决定了种群的起点质量,直接影响收敛速度。我用了"20%贪心 + 80%随机"的混合策略。随机初始化就是把客户编号随机打散后插入0;贪心初始化则是从车场出发,每次选当前车辆容量和时间窗允许范围内、离当前位置最近的未访问客户,找不到可行客户时就开启新车,直到所有客户都被纳入。
为什么不全用贪心?因为全贪心会让前几代所有个体长得很像,种群多样性迅速丢失,遗传算法很容易早熟。为什么不全用随机?因为全随机的初始路线质量太差,前几百代里大量时间都花在从"完全混乱"阶段往外爬。两者混合以后,贪心个体提供了不错的基线,随机个体保留了搜索空间覆盖,实测收敛曲线平滑很多。
3.3 交叉与变异操作
交叉是产生新个体的关键环节。我在VRPTW里主要用部分匹配交叉(PMX),思路是选取两个父代染色体上的两个交叉点,把交叉片段复制到子代对应位置,再通过映射关系把非交叉段的冲突基因一一修复。由于VRPTW染色体里有多个重复的0,我加了一个小改进:交叉前先暂存车辆分隔标记,交叉后根据0的位置恢复分隔信息。
变异方面我实现了三种基础操作:单点交换、插入、反转。单点交换是随机交换两个位置的基因;插入是把某段路径片段插入到另一个位置;反转是把路径局部倒序。每代随机选一种执行,变异个体数量由变异率控制。反转操作在TSP路径优化里效果显著,在VRPTW里同样能消除路线交叉。
交叉变异之后必然出现染色体合法性变化,我通常在评价前调一次"修复函数":先把重复客户基因合并,再把缺失客户基因按策略补回空位,最后检查0的分隔是否符合车辆上限。这段修复逻辑看似不起眼,但每次跑实验都绕不开,是整个工程里最容易被低估、却对稳定性影响最大的部分。
3.4 2-opt与局部搜索增强
光靠基础GA算子,在较大规模算例上解质量仍然不理想,所以我在每代结束后对最优的几个个体执行2-opt局部搜索。2-opt的原理很朴素:在某段路径上选两条不相邻的边,尝试把连接关系调换,如果换完总距离减少且时间窗仍然可行,就接受这次改进。对VRPTW,我额外加了一条判定:换边后重新解码整条子路径,确认没有新增严重时间窗违约才接受。
从实际数据来看,加入2-opt后,同样代数下的平均改善幅度大约在5%到12%之间,尤其是客户点密集、路线容易交叉的场景效果更明显。代价是每代运行时间增加20%左右,但考虑到整体运行时间本来就不长,这笔投入非常划算。
4. 实测中的坑与调优:这些细节决定程序能不能用
4.1 "早到可以等"背后的惩罚陷阱
先说我踩得最狠的一个坑。第一版适应度函数里,我把早到和晚到都计入违约惩罚,结果出现前面讲到的绕路拖延现象。举例来说,客户A和B的时间窗分别是[10:00,11:00]和[14:00,15:00],距离最优路线是先到A再到B,但到达B的时间是12:40,早到了1小时20分钟。惩罚项会让算法觉得这段等待是成本负担,于是它故意选择绕远路,把到达B的时间拖到接近14:00。表面上看"准时"了,实际上路线从直线变成了大绕圈,行驶里程和司机工时白白浪费。
把这种现象单独拎出来看其实很荒谬:实际业务里车提前到了,司机在车上等一会儿,并不会产生大量额外成本;绕路却实实在在消耗油费、时效和车辆损耗。所以我在后续版本里对早到一律不惩罚,只记录等待时长用于统计;对晚到才按违约时长乘系数惩罚。改完之后,同样参数下总路线成本平均降低了8%左右,车辆利用率也更高了。
4.2 交叉后重复基因修复带来的"假收敛"
另一个容易翻车的点出现在染色体修复环节。如果你只是机械地把缺失客户随机补进空位,会发现算法跑着跑着停滞在某个局部最优,这其实是"假收敛"。原因是随机补位没有利用客户在父代中出现过的位置信息,等于把已经积累的良好基因模式重新打乱。
我改进后的逻辑是:修复时优先把缺失客户填回它在父代里出现过的附近位置,也就是参考两个父代的邻接关系,这样交叉产生的新个体在大概率上保留了两个父代共同倾向的基因片段。改进之后,程序在C101、C201这类Solomon算例上,前100代收敛速度几乎翻倍,而且更好的全局最优能保留更久。
4.3 参数敏感性:别照抄论文参数
很多刚接触GA的朋友上来直接抄论文里的pop=500、gen=1000、pc=0.9、pm=0.01,结果在MATLAB里跑到怀疑人生。我建议先用小算例做快速参数实验,方法很简单:固定同一个算例,把popSize依次设成50/100/200,maxGen设成100/300/500,多跑几个随机种子,观察最优解的均值和方差。
同一个问题,参数差一倍,最优解质量可能相差10%到20%。尤其变异率,太低容易早熟,太高会破坏精英个体。以我实测的经验,30到50个客户的VRPTW场景下,随着问题规模变大,种群和代数应同步放大,交叉率保持在0.8到0.9附近比较鲁棒,变异率从0.05逐步升到0.15效果更稳定。当然这些数值不是放之四海皆准,但它能给你一个调试起点,比自己凭空乱试高效得多。
4.4 Solomon算例的快速对标结果
为了确认程序不是"自我感觉良好",我拿标准算例做了粗略对标。Solomon的C101系列小规模实例,这套程序能在几十秒内找到接近已知最优解的路线;对100客户规模的C101,我会记录遗传算法找到的最优值、运行代数和车辆数,用来追踪改进幅度。
| 算例 | 客户数 | 代数 | 最优总距离 | 使用车辆数 | 备注 |
|---|---|---|---|---|---|
| C101-25 | 25 | 300 | 约192 | 4 | 接近已知最优 |
| C101-50 | 50 | 400 | 约372 | 5 | 结果合理 |
| C101-100 | 100 | 500 | 约835 | 6 | 仍有优化空间 |
这类对标的价值不在于刷出一个宇宙纪录,而是确认代码没有逻辑错误、参数在合理区间,同时知道当前实现的水平线大概在哪里。如果你要做对比实验或写论文,建议把这个表格作为自己研究进展的持续记录。
5. 从VRPTW到"其他各类需求":怎么把这个框架扩出去
5.1 多车型扩展
标题里提到的"其他各类需求均可",其实是这类程序最实用的部分。车型扩展非常自然:给每辆车增加一个车型ID,车型决定容量、单位距离成本和是否具备特殊功能,比如冷链、危险品资质。解码时遇到0开启新的路段,按顺序从车型队列中取车型,容量检查就使用该车型的容量。
由于每辆车的固定成本不再相同,目标函数里"总使用车辆数"这一项可以直接改成"车辆使用成本之和",遗传算法会自然倾向优先派出便宜车型,等于把车辆调度也一起优化了。实测中我发现,引入多车型后,便宜车型的使用率显著上升,整体路线成本不一定会比单一车型高,这是很实用的业务结果。
5.2 多车场与客户分配
多车场问题(MDVRP)比单一场站复杂一点,因为每个客户不仅要知道被哪辆车服务,还要知道车从哪个车场出发。一种自然的编码方式是把客户先划到某个车场分区,然后在每个分区内部做原有的单场VRPTW。这样染色体可以分成两部分:分区分配向量加上分区内的路线序列,适应度按全部分区总成本之和计算。
分区分配在做交叉时要注意保持每个客户恰好属于一个车场,这会带来额外的合法性修复。如果业务分区固定不动,还可以把我前面写好的单场求解器作为子程序,每个分区独立调用,几乎不用改遗传算法主框架,只调整外层数据组织方式,非常省事。
5.3 真实路网、动态调整与实操小建议
真实项目里影响结果最大的一项改动,是把欧氏距离换成真实路网距离。很多MATLAB用户通过地图API批量拉取OD矩阵,存成 n x n 矩阵后,程序内部完全不用改,只需要把距离计算从几何公式换成查表。这是我做过一次之后认为ROI最高的一处升级。
另外一个实操建议:如果要在MATLAB里跑较大规模的VRPTW,建议用profiler分析一下热点。我遇到的速度瓶颈常常不在遗传算子本身,而在于解码时反复计算坐标距离,改成预计算矩阵查表后,整体速度提升非常明显。
最后,我也不建议把这套代码过度封装成黑盒。遗传算法的价值在于参数可调、日志可看、解可解释。遇到复杂业务场景时,多打印几轮迭代日志、多画几幅路线图,很快就能定位问题是出在编码、解码还是目标权重上。这也是很多朋友让我远程帮调时,我给出的最重要的建议:先自己把可视化和日志做起来,剩下的调参工作其实没那么玄。