☰
MATLAB实现粒子群算法求解带时间窗的多工地车辆调度排班
2026/10/6 14:22:57 网站建设 项目流程

做调度排班这行的人,十有八九都遇到过这种现场:搅拌站旁边停着五六辆罐车,调度员拿着对讲机喊“先去三号工地,那边等着浇筑呢”,电话一响工地又催“二号楼断料了”。人脑在同时处理车辆位置、工地时间窗、道路拥堵这些信息时,很容易顾此失彼,排出来的班表要么让司机空跑,要么让工地干等,成本哗哗地流。车载GPS和ERP系统只能记录状态,真正难的是“接下来到底该怎么安排”。

我最近刚完成的一套方案,就用MATLAB实现了一个基于粒子群算法(PSO)的带时间窗卡车多工地调度排班程序。输入各工地的位置、需求时间窗、车辆容量和数量,程序会自动给出每辆车的访问顺序和发车时刻表,目标是在满足工地时间窗的前提下让总运输成本尽量低。这篇文章把整个项目从建模、算法设计到代码实现、踩坑调试的过程完整拆一遍。

如果你在读运筹优化、做物流车辆路径问题(VRP)相关的毕业设计,或者是土木、工程管理方向需要开发调度排班工具,再或者产线上想搞一套自动排车逻辑,这篇内容应该能帮你省下至少一周的试错时间。编码方式、约束处理和关键参数我会尽量讲细,这些恰恰是公开代码里最不会写的部分。

1. 车辆调度问题:从现场场景到数学描述

1.1 先把“多工地排班”抽象成模型

我接手的实际场景是这样的:一个大型商砼站,每天要为周边若干个在建工地供应混凝土。每辆罐车从搅拌站出发,装完料后去工地卸料,卸完再回站装下一车。每个工地对混凝土的需求时间不是一个点,而是一个区间——最早可以接收的时间、最晚必须到达的时间,比如“8点到10点之间必须到三号工地”,这就是典型的时间窗约束。除了时间窗,还有车辆容量约束、工地的卸货服务时间约束,以及每辆车的最大工作时长约束。

把这个场景抽象成数学模型后,本质上是一个带时间窗的车辆调度问题(VRPTW的变体),区别在于目的地不是一条配送环线上的客户点,而是多个工地,车辆往返于站场和各工地之间,相当于多趟次、多工地的访问序列优化。决策变量有两个:一是每辆车去各个工地的先后顺序,二是每趟任务对应的发车时刻。目标函数通常是总运输成本,包括车辆行驶总里程、等待时间、时间窗违约惩罚,还有超出保底台班后的额外车辆使用成本。

这里有个现场细节特别容易被忽略:混凝土从搅拌站装料到工地卸料是有时间限制的,比如到工地后必须在90分钟内卸完,超时就可能初凝报废。所以约束条件里还得加一条“服务时长窗口”约束。如果你直接套用经典VRPTW模型而不加这个,程序给出的班表很可能在工地上翻车。我在第一版就把这条漏了,后面测试时才补上,教训很深刻。

1.2 时间窗约束:硬限制还是软限制

时间窗约束在建模时有两种处理习惯。硬时间窗指的是车辆必须在区间[ET, LT]内到达,早到了只能排队等着,晚到了直接不许进入,这种约束在医药冷链、机场地勤这类场景里很常见。软时间窗则允许车辆提前或迟到,但会产生相应的违约惩罚成本。真实的混凝土工地其实介于两者之间——浇筑面没准备好时车到了也只能等,但硬性拒绝达成的车辆又不太现实,因为一个浇筑仓断料会造成更严重的质量事故。

我在这套代码里采用的方法是软时间窗加分段线性惩罚函数。早到的惩罚比较低,因为无非是排队等待;晚到的惩罚设得高一些,尤其是超过某个容忍上限(比如晚到60分钟)时惩罚斜率陡增,这样可以防止算法在解里大量引入“晚到太久”的方案。惩罚函数里我用了两个独立的系数,分别对应早到和晚到的惩罚斜率,什么时候用哪个系数,就由当前到达时间落在时间窗左侧还是右侧来决定。

这个设计的理由很直白:硬时间窗会让搜索空间出现大量不可行区域,粒子群这种基于连续空间搜索的算法处理起来非常吃力,容易把所有粒子限制在死胡同里。改用软时间窗后,不可行解也能参与迭代,只是适应度很差,算法会在迭代过程中慢慢把粒子推向可行域和更优区域,收敛过程平滑很多。

1.3 目标函数设计:别让成本算少了

目标函数我最终定为三项相加:行驶成本、时间窗违约成本、固定出车成本。行驶成本直接按总里程乘以每公里油耗单价计算,这一项是主要优化对象。违约成本就是上面提到的软时间窗惩罚。固定出车成本则是“每启用一辆车就必须支付一笔固定开支”,用来惩罚调度方案里车辆数量过多的情况。因为在很多实际场景里,少开一辆车所省下来的司机工资和车辆折旧,往往比多跑一点里程更重要。

三个成本项的权重需要根据实际业务数据来标定。我的办法是先用一组历史排班数据跑一遍模型,把三个成本项的量级分别统计出来,再把权重调整到“总成本最低的最优解”和现场调度员的经验方案基本一致的程度。这一步看着不起眼,却是模型能否落地接受的关键。单纯按理论比例设置权重,算出来的“最优解”往往会被业务部门质疑,因为他们关心的成本项排序和公式里的权重存在偏差。

2. 粒子群算法设计:为什么是PSO,以及编码怎么做

2.1 为什么不是遗传算法、蚁群算法

调度排班属于组合优化问题,最常见的三类元启发式算法是遗传算法(GA)、粒子群算法(PSO)和蚁群算法(ACO)。我最终选择PSO,有几个实际原因。首先是参数少,GA需要调交叉率、变异率、选择压力、种群规模,蚁群要调信息素挥发系数、启发式因子强度、蚂蚁数量,而PSO最核心的只有惯性权重和两个加速因子,调参工作量小一个量级。对于工期紧的小项目来说,这是一项实打实的优势。

其次是收敛速度快。粒子群所有粒子同时向着当前最优方向靠近,信息共享机制非常直接,在中小规模调度问题上通常几十代就能看到一个不错的解。GA靠选择交叉变异,信息流动是间接的;蚁群要靠信息素慢慢堆积,前期收敛更慢。我分别跑过对比实验,在任务数15、车辆数5的算例上,PSO到达相同目标值的迭代次数大约只有GA的六成。

当然PSO也有明显短板——容易早熟,后期种群多样性和遗传算法相比要差一些。我在标准的PSO基础上做了惯性权重线性递减,效果好了不少,这部分放在后面参数配置里细说。

2.2 粒子编码:把连续向量翻译成调度方案

这是整个实现里最关键的环节,也是最容易卡壳的地方。PSO的粒子天生是连续实数向量,但车辆调度是离散的组合问题,两者之间必须有一次编码和解码的映射。

我采用的编码方式是“基于任务优先级的实数编码”。假设一共有M项运输任务,比如“给三号工地送两车料”算作两项任务,“给五号工地送一车料”算作一项任务,粒子就是一个M维实数向量。向量每一维的值对应一个任务,值的大小表示该任务被安排的优先级。解码时,先把所有任务按粒子对应维度上的值从大到小排序,这个排序结果就是任务的执行顺序,然后再按顺序把任务分配给车辆,同时检查容量和时间窗约束。

这里有一个细节值得多说一句:粒子向量里维度之间数值的大小关系是相对的,而不是绝对顺序。就算把所有维度同时加上一个常数,解出来的调度方案完全不变,因为排序结果没变。所以PSO迭代过程中位置更新带来的整体偏移是允许的,不会破坏解的编码意义。

解码分配车辆时,我采用顺序插入法:调度方案里的每一辆车都有一个任务序列,按排序后的任务顺序逐一尝试插入到每辆车的现有序列中,计算插入后新增的行驶距离和时间窗惩罚增量,选择增量最小的车辆插入进去。如果所有车都装不下或严重违约,就新增一辆车。这个贪心插入式的解码策略能保证车辆数量自动被压制到合理水平,而不是一上来就固定车数。

2.3 参数配置公式与经验值

PSO的核心更新公式就是那两条:

  • 速度更新:v(i+1) = w * v(i) + c1 * r1 * (pbest - x(i)) + c2 * r2 * (gbest - x(i))
  • 位置更新:x(i+1) = x(i) + v(i+1)

其中w是惯性权重,c1是自我认知加速因子,c2是社会认知加速因子,r1和r2是[0,1]之间的随机数。我使用的惯性权重是线性递减策略,w从0.9递减到0.4,公式为w = w_max - (w_max - w_min) * iter / MaxIter。这么做的原因是前期需要大权重来保持粒子探索能力,防止过早扎堆,后期缩小权重让粒子在最优解附近精细搜索。

种群规模N我一般取30到80之间。任务数小于20的算例,N取40足够;任务数超过50,N取80以上更稳妥。加速因子c1和c2都取1.5,或者经典的c1=c2=2也可以,实测在调度这个场景下1.5配合线性递减w的稳定性更好。最大速度Vmax需要根据粒子维度范围来设定,我这里任务优先级值的范围是[-5, 5],所以Vmax设为2,避免粒子一步跨出太远导致编码失效。

% 粒子群主循环的简化逻辑 for iter = 1 : MaxIter w = wMax - (wMax - wMin) * iter / MaxIter; for i = 1 : N r1 = rand(size(x(i,:))); r2 = rand(size(x(i,:))); v(i,:) = w * v(i,:) + c1 * r1 .* (pbest(i,:) - x(i,:)) + c2 * r2 .* (gbest - x(i,:)); v(i,:) = max(min(v(i,:), Vmax), -Vmax); % 限速 x(i,:) = x(i,:) + v(i,:); % 解码并计算适应度 cost = evaluateSchedule(x(i,:), problemData); % 更新个体最优与全局最优 end end

3. MATLAB代码实现:核心逻辑与可视化

3.1 程序结构:模块分开写,调试不头大

MATLAB实现的第一个建议就是尽量把功能模块拆开,不要在一个脚本里把建模、算法和画图全堆一起。我的项目目录下分成了几个文件:主入口脚本(负责加载数据、初始化参数、调用算法、输出结果)、数据加载与预处理函数、粒子群主函数、解码与适应度计算函数、约束校验函数、结果可视化脚本。这样每个函数可以单独调试,尤其是适应度计算函数,单独测试时能快速定位逻辑错误。

数据结构方面,我用的是MATLAB的struct和table来保存工地信息、车辆信息和任务列表。比如工地信息表里每行是一个工地,列有编号、横纵坐标、最早可接收时间、最晚可接收时间、服务时长、需求量。任务表则由工地需求生成,每个任务记录关联的工地编号和需求量。在粒子群迭代过程中,这些数据被传给适应度计算函数,作为解码时的背景数据。

有一个值得注意的细节:MATLAB中函数参数的复制机制是写时复制,如果在循环里反复修改大的结构体或矩阵,会产生频繁的复制开销。我在粒子群主循环里尽量提前把问题数据转成数值数组,不用table在循环里逐列索引,实测能让迭代速度提升30%以上。这个问题在任务数不多时感觉不明显,任务上百时差距非常大。

3.2 适应度函数:解码、校时、算成本

适应度函数是整个程序的心脏,我把它拆成了两个子步骤:第一步是根据粒子编码生成调度方案,第二步是逐辆卡车模拟任务执行并统计目标函数值。

生成调度方案时,先把粒子各个维度对应任务的优先级排序,得到任务执行序列。然后按顺序把每个任务插入到各车辆的任务队列末端,同时更新该车完成上一个任务后的位置和时间。这里做插入判定的依据是“新增行驶距离增量 + 新增时间窗违约增量”,哪个车增量最小就安排给哪个车。

模拟执行时要注意,每辆车完成一趟任务后的时间不是单纯“上一任务完成时间 + 行驶时间”,因为还有装车等待和卸货等待。我的处理方式是记录每辆车的可用时间点,任务开始时取当前时间和工地时间窗下界的较大值,也就是允许车辆早到后等待,然后累加卸货服务时间,得到下一个可用时间点。

时间窗违约成本在这一步计算:如果任务到达时间早于工地最早可接收时间,按早到分钟数乘惩罚系数;如果晚于最晚可接收时间,按晚到分钟数乘另一个更大的惩罚系数。累计所有任务、所有车辆的违约成本,加上总行驶里程成本和车辆固定成本,就得到这个粒子的适应度值。

% 适应度计算核心逻辑(伪代码) function totalCost = evaluateSchedule(particle, data) taskOrder = sortTaskByPriority(particle, data.taskList); % 按粒子值排序 vehicleTime = zeros(data.numVehicles, 1); vehiclePos = ones(data.numVehicles, 1) * data.depotId; vehicleSeq = cell(data.numVehicles, 1); for t = 1 : length(taskOrder) task = taskOrder(t); bestIncrement = inf; bestV = 0; for v = 1 : data.numVehicles [ok, increment] = tryInsertTask(vehicleSeq{v}, vehicleTime(v), task); if ok && increment < bestIncrement bestIncrement = increment; bestV = v; end end % 如果已有车辆都不能插入,启用新车辆 if bestV == 0 bestV = allocateNewVehicle(vehicleSeq, vehicleTime); end insertTaskToVehicle(vehicleSeq{bestV}, task, vehicleTime(bestV)); end totalCost = computeTotalCost(vehicleSeq, data); end

3.3 画甘特图:让调度方案一眼能看懂

代码跑完只是第一步,把结果直观呈现出来同样重要。我在项目里实现了两个可视化模块,一个画算法的收敛曲线,一个画调度的甘特图。收敛曲线很简单,记录每次迭代全局最优的适应度值,画成一条下降曲线,可以从图上直观判断算法是否收敛、是否陷入平台期。

甘特图对业务方来说意义更大。横轴是时间,纵轴是每辆车的编号,每个任务用色块标出,颜色对应不同工地。色块从开始端到结束端的长度就是这个任务从出发到卸完的时间。通过甘特图能快速发现不合理现象,比如某辆车两个任务之间出现大段空白、某个工地的时间窗被排到特别晚的时段等。MATLAB里用barh逐段画矩形就能实现,代码量不大但效果非常好。

如果数据里有工地坐标信息,我还会增加一个路径图模块,把所有车的位置路线画在地图上。一个方案到底是把所有车都派去先送最近的工地,还是让每辆车负责一片区域,看路线图立刻一目了然。这个可视化对向业务部门解释算法结果也特别有帮助,人都是看图的动物。

4. 实验结果与参数敏感性分析

4.1 一个标准算例的求解过程

为了验证程序的正确性,我先设计了一个中小规模的测试算例:1个搅拌站,6个工地,每个工地需要1到2车次,一共10个运输任务,可用车辆5辆。工地的时间窗从早上6点到下午18点随机分布,每个工地的服务时长为30到60分钟,车辆容量为12方(混凝土罐车常见容积),车速按城市道路情况设定为25km/h。

用默认参数(种群40、迭代300、w从0.9到0.4、c1=c2=1.5)运行,收敛曲线大致呈现三个阶段:前50代目标值下降非常快,从初始随机解的8000多降到5000左右;50到150代下降缓慢;150代以后基本稳定在4600左右不再变化。最终调度方案启用了4辆车,每辆车平均执行2到3个任务。对比初始随机解,总成本下降了42%,效果非常明显。

这里要提醒一点:单个算例跑出来的最优解没有统计意义,因为粒子群是随机搜索算法,每次运行结果都可能不同。我的做法是每个参数组合独立重复运行20次,记录最优值、平均值、标准差,用中位数或平均结果做对比结论。如果一次实验就下结论,很容易被偶然性误导。

4.2 参数组合怎么调:我做过的对比实验

为了摸清参数对求解质量的影响,我做了几组对比实验。第一组是惯性权重策略对比:固定w=0.7不变、w线性递减、w阶梯递减,结果表明线性递减的平均目标值比固定w低约6%,而且标准差更小,稳定性更好。第二组是种群规模对比,种群从20加到80,最优值确实越来越低,但从40到80的改善幅度远小于从20到40的改善,考虑到运行时间成倍增加,我建议小算例取40、大算例取80就够了。

第三组是加速因子对比。c1=c2=2的经典配置收敛快,但多次运行结果中偶尔会出现早熟;c1=c2=1.5配合线性递减w,收敛速度稍慢但结果更稳。如果想让粒子更多探索个体最优方向,可以把c1调大到2,c2保持1.5;想让粒子更快向全局最优靠拢,就反过来。具体业务对稳定性要求高的话,我建议采用c1=c2=1.5。

还有一个容易被忽略的参数是最大速度Vmax。Vmax设太大会导致粒子位置更新幅度过大,优先级值频繁越过边界,解码出的任务顺序颠倒混乱,搜索退化为随机猜测;设太小又会导致粒子移动缓慢,探索能力下降。我的经验值是Vmax设为粒子维度数值范围的20%到40%,具体视任务数规模做微调。

5. 实操踩坑与调试心得:这些坑你一定也会遇到

5.1 早熟收敛:全局最优在迭代早期就锁死了

粒子群最常见的坑就是早熟收敛。表现是迭代到50代左右,全局最优值就长时间不再变化,所有粒子被“吸”到同一个区域,群体多样性几乎丧失。我在项目初期就踩过这个坑,最优解一直停在一个并不好的局部极值上。

解决手段我用了几种组合。一是惯性权重线性递减,前文说过,效果显著。二是在每次迭代结束时,以很小的概率(比如5%)对某个随机粒子的某些维度做一次重新初始化,相当于给群体注入一点随机扰动,让粒子有机会跳出局部区域。三是如果连续50代全局最优没有改善,就对整个群体按一定比例重新初始化,只保留当前gbest所在粒子的位置继续搜索。

另外一个务实经验是:不要只跑一次,多次独立运行取最优。实际项目中我通常跑10到20次,取目标值最高的结果作为最终方案。这个方法虽然“笨”,但在生产场景里非常管用,因为每次运行几分钟就能完成,多跑几次的成本完全可以接受。

5.2 时间窗冲突漏判,问题出在“等待时间”

我调试过程中遇到最隐蔽的问题,是解码时对等待时间的处理不一致。第一阶段代码里,我判断任务插入是否可行时犯了两个错误:第一,只检查了当前任务到达工地的时间是否落在时间窗内,没有考虑上一任务结束时车辆的位置;第二,早到等待的时间没有累加到车辆状态里。结果就是算法自认为完美的方案,在模拟执行时出现连环迟到。

举个例子,某个工地时间窗是8到9点,任务车早到后等到8点才开始卸,卸完已经是8点50。接着赶下一个工地需要30分钟车程,就算速度完美最快也要9点20才到,但这下一个工地的时间窗恰好也是8到9点。如果解码时只检查“到达时刻是否落在时间窗内”,就会漏掉这种由前序任务延误传导出来的违约。所以模拟执行时,每个任务完成后的车辆可用时间都必须包含等待和卸货时长,再叠加行驶时间,才是下一任务的到达时间。

我把这个逻辑抽成了一个独立的函数simulateTaskAssignment,专门做插入可行性判断和车辆状态更新。做完这一步,程序的误判问题基本清零。强烈建议开发调度类代码时,把时间推进逻辑单独写一个函数,不要散落在主代码各处,否则调试时真的会疯。

5.3 性能优化:从半小时跑到三分钟

在任务数扩展到80个时,初始版代码的运行时间让我很崩溃,跑一次要半个多小时,完全没法做参数对比实验。排查后发现性能瓶颈在两个地方:一个是在解码函数里频繁使用sortrows对结构体数组排序,另一个是在插入可行性判断中用ismember反复在数组里查找元素。这两个操作在小规模算例里没感觉,数据量一上来就成了重灾区。

优化的办法是:先把任务数据全部转成数值矩阵,任务编号、关联工地编号、需求时间窗上下界、服务时长都作为矩阵的列,排序时直接对矩阵的特定列排序,同时把索引向量一并带回。可行性检查也改成基于数值矩阵的循环,避免每次用ismember做O(n)查找。优化后,同样的算例运行时间从30分钟降到3分钟以内,完全满足交互式调参的需求。如果任务数更大,可以考虑用parfor并行计算粒子适应度,每个粒子的解码彼此独立,天然适合并行,但这个优化要和MATLAB并行工具箱配合使用,小问题规模时反而会因并行开销变大,不建议一上来就套。

5.4 数据标定:别用拍脑袋的行驶速度

这个不属于算法编码,但实际项目里必须处理。多工地调度涉及车辆在搅拌站和各工地之间的行驶时间,而行驶时间依赖车速。很多初学者在代码里用一个固定车速乘距离来计算,在真实场景里误差很大——城市早高峰、工地周边道路泥泞、混凝土车重载爬坡,速度差异非常明显。我是用工地的历史GPS轨迹数据做了速度分位数统计,高峰期和平时分别标定两个平均速度,在目标函数计算行驶时间时,按任务所处时段自动选择速度值。

如果项目没有历史数据,至少要按不同的道路类型分几档速度来估算,千万不要全程用一个值。这个标定精度的提升,对时间窗排队计划和总成本计算的影响非常大,我亲手验证过:同样一套算法,粗略速度和分时段速度下的调度方案,总里程可能差不多,但时间窗违约成本相差20%以上。

6. 扩展思路:这套代码还能往哪些方向走

这套调度程序如果只停留在离线排班层面,其实只发挥了六成功力。我做完第一版之后,顺手加了两个扩展,都收到了很好的效果。一是把单目标改成双目标,在总成本之外再加入碳排放或车辆均衡度指标,用带权重的多目标和PSO的Pareto前端版本扩展,能给出多个可选方案,业务方可以根据现场偏好挑一个执行。二是做了动态扰动重调度,比如运行中途某个工地临时增加供应量,或者车辆故障退出,程序会保留当前未执行的部分调度方案,用粒子群增量重排后续任务,而不是从头再排全天的班。

这些扩展如果一开始就把主函数和解码函数写得足够模块化,成本并不高。核心原因在于解码和适应度计算是相对独立的模块,算法框架只要换一个目标函数和约束条件,就能适应新需求。所以前期花在模块划分上的时间,会在后续扩展时加倍赚回来。

最后再分享一个实践经验:所有调度算法类项目,先搭一个能快速可视化的小界面或脚本,比追求复杂算法的收敛精度更重要。你看到的调度方案合不合理,画成甘特图一眼便知,远比盯着几十行成本数字靠谱得多。代码会跑谁都会写,但能写出让现场调度员点头的方案,才是这项目的真正价值所在。

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

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

立即咨询