
做调度排班这行的人十有八九都遇到过这种现场搅拌站旁边停着五六辆罐车调度员拿着对讲机喊“先去三号工地那边等着浇筑呢”电话一响工地又催“二号楼断料了”。人脑在同时处理车辆位置、工地时间窗、道路拥堵这些信息时很容易顾此失彼排出来的班表要么让司机空跑要么让工地干等成本哗哗地流。车载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(i1) w * v(i) c1 * r1 * (pbest - x(i)) c2 * r2 * (gbest - x(i))位置更新x(i1) x(i) v(i1)其中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足够任务数超过50N取80以上更稳妥。加速因子c1和c2都取1.5或者经典的c1c22也可以实测在调度这个场景下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 end3. 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); end3.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、c1c21.5运行收敛曲线大致呈现三个阶段前50代目标值下降非常快从初始随机解的8000多降到5000左右50到150代下降缓慢150代以后基本稳定在4600左右不再变化。最终调度方案启用了4辆车每辆车平均执行2到3个任务。对比初始随机解总成本下降了42%效果非常明显。这里要提醒一点单个算例跑出来的最优解没有统计意义因为粒子群是随机搜索算法每次运行结果都可能不同。我的做法是每个参数组合独立重复运行20次记录最优值、平均值、标准差用中位数或平均结果做对比结论。如果一次实验就下结论很容易被偶然性误导。4.2 参数组合怎么调我做过的对比实验为了摸清参数对求解质量的影响我做了几组对比实验。第一组是惯性权重策略对比固定w0.7不变、w线性递减、w阶梯递减结果表明线性递减的平均目标值比固定w低约6%而且标准差更小稳定性更好。第二组是种群规模对比种群从20加到80最优值确实越来越低但从40到80的改善幅度远小于从20到40的改善考虑到运行时间成倍增加我建议小算例取40、大算例取80就够了。第三组是加速因子对比。c1c22的经典配置收敛快但多次运行结果中偶尔会出现早熟c1c21.5配合线性递减w收敛速度稍慢但结果更稳。如果想让粒子更多探索个体最优方向可以把c1调大到2c2保持1.5想让粒子更快向全局最优靠拢就反过来。具体业务对稳定性要求高的话我建议采用c1c21.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前端版本扩展能给出多个可选方案业务方可以根据现场偏好挑一个执行。二是做了动态扰动重调度比如运行中途某个工地临时增加供应量或者车辆故障退出程序会保留当前未执行的部分调度方案用粒子群增量重排后续任务而不是从头再排全天的班。这些扩展如果一开始就把主函数和解码函数写得足够模块化成本并不高。核心原因在于解码和适应度计算是相对独立的模块算法框架只要换一个目标函数和约束条件就能适应新需求。所以前期花在模块划分上的时间会在后续扩展时加倍赚回来。最后再分享一个实践经验所有调度算法类项目先搭一个能快速可视化的小界面或脚本比追求复杂算法的收敛精度更重要。你看到的调度方案合不合理画成甘特图一眼便知远比盯着几十行成本数字靠谱得多。代码会跑谁都会写但能写出让现场调度员点头的方案才是这项目的真正价值所在。