
前阵子一直在折腾带工人约束的混合流水车间调度问题把整个实现过程整理一下。这个问题的背景很常见车间里有若干个生产阶段每个阶段又有若干台并行机工件按同样的工艺路线依次流过这些阶段这就是经典的混合流水车间调度问题HFSP。但实际排产往往比这个更麻烦——机器不是自动运行的每台机器都要有一名具备对应技能的工人来操作而工人数量通常比机器少技能覆盖也不完整。机器空闲但没人能开的情况比比皆是。我最后用Matlab实现了一个“结合多种启发式解码方法的混合多目标进化算法”这里把设计思路、关键代码和踩过的坑都写出来给做调度算法或者想复现类似论文的读者一个参考。整个实现的核心是把启发式解码当成“基因型到表现型”的桥梁嵌入到NSGA-II框架里用一组帕累托前沿同时优化最大完工时间和工人负荷均衡两个目标。论文里常说“混合启发式解码”落地到代码上不是一件简单的事它涉及数据结构设计、资源可行性判断、进化算子选择、实验对比等多个环节。下面按我实际推进的顺序来写。1. 带工人约束的混合流水车间调度卡住排产的往往不是机器而是人1.1 混合流水车间的三层结构先把问题的基本盘说清楚。混合流水车间里有多个生产阶段每个阶段配置一台或多台并行机。工件从第一个阶段开始依次经过所有阶段在某个阶段只需要选择该阶段的任意一台并行机加工一次。这种结构在纺织、电子装配、机械加工里非常常见典型特征就是“阶段串联、机台并联”。在不考虑工人的情况下一个调度方案只需要回答两个问题每个工件在每个阶段选择哪一台并行机同一台机器上的多个工序按什么顺序加工。因此经典的HFSP本质上是一个“机器资源”竞争问题。优化目标一般是最大完工时间makespan、总流经时间、总延迟等。这类问题已经被证明是NP-Hard所以大规模算例基本靠进化算法、启发式规则或者元启发式方法去求解。1.2 工人约束从“机器-工件”二维资源变成“机器-工件-工人”三维资源加了工人约束之后问题的复杂度立刻上了一个台阶。机器是固定位置的资源但工人是可移动资源。每个工序需要同时占用一台机器和一名工人而工人之间存在技能差异一名工人通常只能操作部分机台或部分阶段且同一时刻只能服务一台机器。这意味着即使机器空闲如果没有“有能力且有空的工人”来操作照样开不了工。我把工人约束拆成三种典型类型来建模技能矩阵用二值矩阵skilled(worker, machine)表示工人w能否操作机器m时间可用性每名工人有自己的可用时间窗比如不同班次、休息时间负荷限制同一工作日内每名工人的累计工作时间有上限或者希望尽量均衡。在一个实际的流水车间里工人约束会直接改变调度策略。比如某一个阶段有3台并行机但只有2名工人掌握这个阶段的技能那么该阶段实际同时加工能力的上限就是2。如果算法没考虑这一点按3台机器去排产给出的甘特图一定是假的。更麻烦的是工人是跨阶段流动的同一名工人可能在前半段操作3号机后半段又跑去下一阶段操作另一台机器这会让资源冲突更加隐蔽。所以在做算法设计时我把工人当作和机器一样独立占用、释放的资源来处理。调度事件不仅涉及“机器何时空闲”还要问“这个工人现在在哪里、能否调用他”。这也是为什么不能简单套用传统HFSP的解码器解码时必须同时维护机器工作状态和工人工作状态。2. 为什么是“启发式解码 多目标进化算法”而不是直接套NSGA-II2.1 编码空间到调度空间之间的鸿沟很多刚接触进化算法的人容易犯一个错误把染色体当作一个“还算不错的调度序列”然后用最直接的方式把工序按顺序塞进机器。问题是进化算法在编码空间里操作的是“基因”比如一个工件顺序排列它并不包含完整的机器分配、工人分配、开工时间等信息。真正形成调度方案的不是染色体本身而是“解码器”。如果解码器只是机械地把基因顺序映射到最早的可用机器不考虑工人能力那么产生的调度一定不符合带工人约束的实际情况如果简单地把不符合工人约束的个体判为非法并淘汰那么初始种群中的大部分个体可能都是非法的算法进化效率极低。这时候需要引入启发式解码解码器内部用一系列调度规则在资源分配过程中做贪心决策或构造式搜索保证从任意给定染色体出发都能得到一个“可行且尽量不错”的调度。启发式解码的意义在于它把进化算法从“随机搜索可行调度”里解放出来让进化过程专注于优化基因序列本身。2.2 多种启发式解码为什么能提升搜索鲁棒性单一启发式解码通常对应某一种调度偏好。举个例子如果解码规则是“总是选择最早空闲的机器”那么生产计划会倾向于把负载压在前几台机器上后面几台机器长期空置换成“总是选择累计负载最小的机器”又会造成机器频繁切换。不同问题实例对调度规则的敏感度不一样尤其是工人技能矩阵变化后某种规则可能在算例A上很好在算例B上却非常差。混合多种启发式解码的目的是让种群里的不同个体在解码阶段使用不同的资源分配逻辑从而增加调度结构的多样性。在实际实现时我在解码器里内置了三种规则每次解码时按预设概率选择一个规则来执行最早可用资源优先机器和工人同时取最早可用时间直观、快适合大规模算例工人负荷平衡优先优先把工序分配给当前累计负荷最小的合格工人对负荷均衡目标友好NEH式插入构造按染色体顺序依次把工序插入到当前局部调度的所有可行位置保留最优位置适合中小规模高质量求解。这三种规则产生了不同的调度形态。种群中一部分个体倾向于“赶进度”另一部分倾向于“工人轻松”整体搜索范围和帕累托前沿的覆盖程度比单一解码规则好很多。2.3 多目标处理用帕累托前沿替代加权和带工人约束的调度问题天然是多目标的。最常用的两个目标分别是最小化最大完工时间和最小化工人的总负荷或负荷不均衡程度。它们往往冲突让工人都在关键工序上集中猛干工期可以缩短但负荷分配极不均衡强行让工人轮流上岗工期又会拉长。传统做法是把两个目标加权成一个综合目标但权重的选择很主观。不同车间场景下决策者对工期和工人负荷的偏好是不一样的加权和只能给出一条折中曲线上的一个点。NSGA-II这类多目标进化算法可以通过非支配排序同时保留多个解最后给出一组帕累托前沿让车间主管根据实际情况选择。我选择NSGA-II而不是MOEA/D主要原因是NSGA-II在Matlab里实现起来比较直接非支配排序、拥挤度距离、精英保留这套框架非常成熟而且对排列编码的适应性好。当然如果你熟悉MOEA/D或者基于分解的算法也可以替换不影响启发式解码部分的设计。3. Matlab实现的第一步数据结构、编码和三种启发式解码3.1 数据结构设计Matlab里做调度问题我建议直接用struct数组不推荐一上来就写class。一个很重要的原因后续算法要跑大量实例struct数组的字段复制、传递和内存布局更直观也方便随时查看中间结果。我为问题实例定义了一个problem结构problem.nJobs 20; % 工件数量 problem.nStages 3; % 阶段数量 problem.nMachines [3, 4, 3]; % 每个阶段的并行机数量总机器数10 problem.nWorkers 5; % 工人数量 % 加工时间矩阵size nJobs x nStages % 表示每个工件在每个阶段的标准加工时间 problem.procTime randi([10, 30], problem.nJobs, problem.nStages); % 阶段-机器映射每台机器属于哪个阶段 problem.machineStage zeros(1, sum(problem.nMachines)); idx 1; for s 1:problem.nStages for m 1:problem.nMachines(s) problem.machineStage(idx) s; idx idx 1; end end % 工人技能矩阵size nWorkers x 总机器数 problem.skilled randi([0 1], problem.nWorkers, sum(problem.nMachines)); % 保证每台机器至少有一个熟练工人 for m 1:size(problem.skilled, 2) if sum(problem.skilled(:, m)) 0 problem.skilled(randi(problem.nWorkers), m) 1; end end % 工人的每个班次可用时间上限 problem.maxWorkerLoad 480; % 8小时实际算例里技能矩阵不会是完全随机的应该根据车间工种划分来设置比如“机加工组”和“装配组”分别熟练不同的阶段。随机生成时也要保证可行性否则后面解码会反复失败。3.2 染色体编码与种群初始化我用最直接、也最适合问题特征的排列编码一条染色体是一个包含1到nJobs的整数排列表示工件在解码阶段的优先顺序。注意这不是“工序编码”而是“工件顺序编码”。在HFSP中每个工件在每个阶段只有一道工序所以通过一个排列再按阶段依次安排每道工序就能唯一确定一个调度。种群初始化时我除了生成随机排列还混入了一些基于调度规则的种子个体包括按照总加工时间从大到小排列类似LPT按照总加工时间从小到大排列类似SPT用NEH启发式构造出的一个序列随机排列若干个体。这样做能让初始种群就具备一定的质量基础而不是完全白噪声。初始化代码如下pop zeros(popSize, nJobs); for i 1:popSize if i 1 [~, perm] sort(sum(problem.procTime, 2), descend); elseif i 2 [~, perm] sort(sum(problem.procTime, 2), ascend); elseif i 3 perm nehHeuristic(problem); % 用NEH构造一个工件顺序 else perm randperm(nJobs); end pop(i, :) perm; end3.3 解码流程集成了最早可用优先、技能匹配和NEH插入三种思路解码器是整个算法最核心的部分。它接收一条染色体输出每一个工序的开始时间、结束时间、机器编号、工人编号同时更新机器可用时间和工人可用时间。解码的基本框架是按阶段展开对于每个工件按阶段从1到K依次处理。每一步需要决定三件事在哪台机器上加工由哪个工人操作什么时候开始什么时候结束。我用一个结构体保存解码进度jobDoneTime zeros(nJobs, 1); % 每个工件上一阶段的完工时间 machineAvailTime zeros(totalMachines, 1); % 每台机器的下一次可用时间 workerAvailTime zeros(nWorkers, 1); % 每个工人的下一次可用时间 workerLoad zeros(nWorkers, 1); % 每个工人的累计负荷对于工序(i, s)先得到它所属阶段s的机器集合。机器m能否使用取决于machineAvailTime(m)和候选工人的workerAvailTime(w)。工序的最早可能开始时间st由三部分取最大值决定releaseTime jobDoneTime(i); % 工件进入该阶段的释放时间 machineReady machineAvailTime(m); workerReady workerAvailTime(w); startTime max([releaseTime, machineReady, workerReady]);如果是“最早可用资源优先”规则我遍历该阶段所有机器、所有合格工人组成的组合选出让startTime最小的组合如果是“工人负荷平衡优先”规则我会先选合格且workerLoad最小的工人再给他配一台当前可用时间最小的机器如果是“NEH插入式解码”则像构造启发式一样把当前工序插入到一个部分调度的所有可行时隙中评估插入位置对目标的影响保留最优解。为了让你看清楚下面给出“最早可用资源优先”的核心代码function schedule decodeEarliestAvailable(chromosome, problem) ... for i chromosome for s 1:problem.nStages machinePool find(problem.machineStage s); bestStart inf; bestMachine 0; bestWorker 0; for m machinePool for w find(problem.skilled(:, m)) % 能操作m的工人 st max([jobDoneTime(i), machineAvailTime(m), workerAvailTime(w)]); if st bestStart bestStart st; bestMachine m; bestWorker w; end end end % 分配并更新 ptime problem.procTime(i, s); jobDoneTime(i) bestStart ptime; machineAvailTime(bestMachine) bestStart ptime; workerAvailTime(bestWorker) bestStart ptime; workerLoad(bestWorker) workerLoad(bestWorker) ptime; % 记录到schedule end end ... end这个编码看似简单实际上能保证任何一个排列都能产生可行调度因为每次分配都显式检查工人技能和工人/机器的可用时间。如果某个阶段某台机器没有工人能做那么这种组合自然不会被选中算法会去找其他机器不会直接报错。3.4 可行性检查的落地细节关于“可行性检查”我遇到过一个问题如果只是想“让程序不崩溃”你可以用上面这种直接搜索合法组合的方式但如果想在解码中体现更真实的约束还需要增加两个检查点工人负荷上限检查如果在分配工人后该工人的workerLoad已经超过maxWorkerLoad那么应该禁止分配哪怕这会造成工序推迟。很多论文简化模型会忽略这一点但在实际车间里工人加班是有上限的。工人技能覆盖检查解码前可以做一个全局预处理比如某台机器没有任何一名工人能够操作那么这台机器实际上应该从模型中去掉否则会拉长所有等待时间。我在最初版本里忽略过工人负荷上限导致算法找到了一个“极端最优解”——让一名王牌工人全天满负荷干活其他工人基本闲着。这个解在一组目标下确实很漂亮但在实际中没法落地。加了负荷上限和负荷均衡目标后问题才真正变成“带工人约束”的形态。4. 进化主循环NSGA-II算子、目标函数评估和参数调优4.1 主循环骨架进化部分我采用标准NSGA-II框架迭代流程如下初始化种群P_0规模N对每个个体调用启发式解码计算两个目标值对当前种群做快速非支配排序计算拥挤度距离用锦标赛选择选出父代执行顺序交叉OX和变异生成子代Q合并P和Q再非支配排序精英保留前N个个体进入下一代直到达到最大代数。主要函数签名大概是for gen 1:maxGen childPop zeros(popSize, nJobs); for k 1:2:popSize p1 tournamentSelect(fitness, crowding); p2 tournamentSelect(fitness, crowding); [c1, c2] orderCrossover(pop(p1, :), pop(p2, :)); c1 mutationSwapInsert(c1, pm); c2 mutationSwapInsert(c2, pm); childPop(k, :) c1; childPop(k1, :) c2; end combinedPop [pop; childPop]; [fronts, crowding] nonDominatedSortAndCrowding(combinedPop, fitVals); pop selectElitist(combinedPop, fronts, crowding, popSize); end4.2 排列编码下的交叉和变异排列编码不能使用传统的二进制单点交叉否则会产生重复工件编号。我试过两种经典算子建议用顺序交叉OX因为它在保持父代相对顺序的同时不容易破坏调度序列中的块结构。顺序交叉的实现逻辑是先随机选两个交叉点把父代1中间片段复制给子代1再从父代2中按顺序取出剩余未被选中的工件号依次填充到子代1的空位中。这样得到的新染色体必然是合法排列。变异我采用“交换插入”混合策略以一定概率随机交换两个位置同时以较小概率把一个位置的工件拿出来插入到另一个位置。交换能造成小扰动插入能改变相对顺序组合起来比单一变异更有效。变异概率一般取0.1左右如果问题规模较大可以适当降到0.05。4.3 目标函数计算与约束处理解码之后每个个体都会得到完整的调度方案。两个目标我分别这样计算目标1——最大完工时间makespan直接取所有工序完工时间的最大值即max(所有schedule.endTime)。目标2——工人负荷标准差先统计每个工人的总工作负荷然后计算标准差。标准差越大说明工人之间忙闲越不均衡。也可以用最大负荷与最小负荷的差值但标准差更平滑。有一点值得注意如果解码器本身已经保证了技能、机器、工人冲突等约束那么进化过程中就不需要额外罚函数了。解码失败的个体比如因负荷上限无法分配导致工序无法安排需要特殊处理。我的做法是给这种个体赋上无穷大的目标值让非支配排序自然淘汰它。不过实际运行中只要初始化时保证可行性、解码规则完备这种个体很少出现。4.4 参数选择的实操经验Matlab跑这种算法参数不是越多越好。我试下来的建议是种群规模N小算例20个工件以内用60~100大算例50个工件以上至少150~200否则前沿覆盖很差。迭代代数一般100代就能看到明显收敛趋势但想让帕累托前沿更完整建议150~200代。交叉概率0.9基本不震荡变异概率0.1不需要太高。三种解码规则的混合概率我最初固定为均匀分布也就是每个个体解码时三个规则等概率选择。后来改成“第一个目标较差的个体更偏向工人负荷平衡解码”效果略有提升但这会引入额外动态不适合做公平对比实验。如果做论文实验建议先用均匀概率。另外Matlab里随机数种子一定要设置好。rng(2025)这种固定种子不仅能保证可复现还能在设计算例时避免“这次跑得好下次跑得差”的尴尬。5. 调试记录三个让程序“跑错但不报错”的典型坑5.1 工人和机器互相等导致机器利用率偏低我第一版解码器的逻辑是“先选机器再选工人”。具体操作是遍历机器选一台最早可用的机器然后再去找在这台机器上技能的工人结果经常出现选中的机器可用但唯一会操作它的工人还在忙于是工序被迫等到工人空闲。其实另一台机器和另一个工人已经空闲很久但由于机器选择已经定了算法不会回头去看。这个问题特别隐蔽因为程序不报错最后解的质量很差。而甘特图上看机器3几乎全程闲置工人1一直连轴转。解决办法是改成“先组合后选择”把“机器工人”当成一个二元素组合遍历所有可行组合选整体开始时间最早的组合。这样机器和工人的等待时间被同时纳入评估调度质量明显改善。对带工人约束的问题来说永远不要让决策顺序变成“先定机器再碰运气找工人”。5.2 解码规则太贪心种群全部“假收敛”我试过只用“工人负荷平衡优先”规则跑了80代发现帕累托前沿特别集中几乎所有解都在一条很窄的带上。原因是这个规则太贪心它让每个工序都尽量交给当前最闲的工人导致工人之间的负荷差异被抹平第二个目标很难拉开差距同时不同染色体解码出来的调度形态高度相似种群多样性下降得很厉害。这就是我引入三种解码规则混合的直观原因。后来我把解码方式调整成“根据个体在种群中的拥挤度选择解码规则”拥挤度高的个体用“最早可用资源优先”拥挤度低的个体用“工人负荷平衡优先”或NEH插入式解码前沿分布就正常了。虽然这种动态选择在对比实验里不够“公平”但作为工程改进非常有效。5.3 评估循环太慢如何定位瓶颈带工人约束的解码涉及多层循环尤其是NEH插入式解码需要对所有位置做可行性评估复杂度很高。我第一次跑50个工件、6个阶段、20台机器时一个个体解码就要几十毫秒整个种群跑100代差不多要半小时以上完全不可接受。定位瓶颈的方式是利用Matlab的profile工具逐行分析每个函数的耗时。我发现耗时主要集中在两个地方在循环内反复调用find找满足技能的工人每次更新机器/工人可用时间时用max对零碎向量做运算。优化手段是预处理技能索引把“每台机器能操作的工人列表”和“每个工人能操作的机器列表”提前算好避免重复find同时把可用时间向量拆成两个独立数组减少max的维度。这样单次解码时间能缩短一半以上。如果你做更大规模算例还有一个思路把解码器写成MEX文件或者用parfor并行评估种群中的个体。因为解码之间没有任何依赖并行化非常安全。只是注意parfor在每次迭代中不能修改共享变量需要把每个个体的解码结果独立返回再汇总。6. 实验验证从单规则解码到混合解码的提升6.1 随机算例生成为了对比算法效果我写了一个随机算例生成器主要控制以下几个维度工件数量nJobs分别取10、20、30阶段数量固定为3因为流水车间阶段太多会增加解码复杂度和不同规则之间的差异每个阶段的并行机数取2~4台机器总数约9台工人数量从2到6变化覆盖“工人紧缺”和“工人充足”两种场景技能矩阵按“每台机器对应一个主要工种”来生成保证存在交叉技能但不是每个人都有。算例生成时最需要注意的是要保证至少有一组可行解不能出现某一台机器从头到尾没人能操作。否则无论算法多好都不会有合理结果。6.2 对比设置与评价指标我对比了三个算法配置ANSGA-II 单一“最早可用资源优先”解码BNSGA-II 单一“工人负荷平衡优先”解码CNSGA-II 三种启发式解码混合本文方案。三个配置使用相同的种群规模、迭代次数、交叉变异概率避免其它因素干扰。评价指标用一个就够了——HVHypervolume超体积指标它能同时反映收敛性和分布性。参考点可以取所有算法中找到的最劣目标值附近或者根据问题规模设定一个足够大的值。另外我还会画出每个配置最终的帕累托前沿。Matlab绘制时可以用如下代码figure; plot(frontA(:,1), frontA(:,2), o); hold on; plot(frontB(:,1), frontB(:,2), s); plot(frontC(:,1), frontC(:,2), ^); xlabel(Makespan); ylabel(Worker Load Std); legend(A: Earliest, B: LoadBalance, C: Hybrid);从图上能直观看到混合解码得到的解集通常覆盖范围更广两个方向都有极端解而不是只偏向一个目标。6.3 实验结果和结论一个典型的实验结果是配置平均HV最小makespan附近最大工人负荷标准差最均衡解makespanA最早可用优先0.3679430B工人负荷平衡优先0.31105382C混合解码0.4266415从表格看混合解码的HV最高说明整体前沿质量更好。而且混合解码能找到“总完工时间较小且工人负荷相对不差”的中间解这是单一规则很难做到的。更有意思的是工人数量的影响。当工人数量充沛6人时三种配置的差异不大因为工人约束不再是硬瓶颈问题退化成接近经典HFSP当工人数量紧张2人时单一“最早可用优先”规则会经常被迫等待工人makespan急剧恶化而混合解码由于有工人负荷平衡规则兜底表现明显更好。所以我给这个问题的总结是工人约束越强启发式解码混合的价值越明显。如果工人数量远大于机器数量工人约束几乎不起作用单纯用经典解码方案就够了但如果工人和机器数量接近甚至工人比机器少那就必须像本文这样把工人可用性当成解码的重要判断维度并且让多种解码规则在种群内共存。在实现过程中我最大的体会是不要迷信“算法框架”本身。NSGA-II只是外壳编码和解码才是解决这个问题的核心。尤其是带工人约束的HFSP解码器的设计直接决定了算法能搜到什么样的解。如果你只是把工人作为罚函数加到目标里很难得到真正可落地的调度方案。把工人约束吃进解码逻辑再配上多个启发式规则去增加搜索多样性才是这条路走得通的关键。