前阵子一直在折腾带工人约束的混合流水车间调度问题,把整个实现过程整理一下。这个问题的背景很常见:车间里有若干个生产阶段,每个阶段又有若干台并行机,工件按同样的工艺路线依次流过这些阶段,这就是经典的混合流水车间调度问题(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-II
2.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(k+1, :) = 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 对比设置与评价指标
我对比了三个算法配置:
- A:NSGA-II + 单一“最早可用资源优先”解码;
- B:NSGA-II + 单一“工人负荷平衡优先”解码;
- C:NSGA-II + 三种启发式解码混合(本文方案)。
三个配置使用相同的种群规模、迭代次数、交叉变异概率,避免其它因素干扰。评价指标用一个就够了——HV(Hypervolume,超体积指标),它能同时反映收敛性和分布性。参考点可以取所有算法中找到的最劣目标值附近,或者根据问题规模设定一个足够大的值。
另外,我还会画出每个配置最终的帕累托前沿。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附近最大工人负荷标准差 | 最均衡解makespan |
|---|---|---|---|
| A:最早可用优先 | 0.36 | 79 | 430 |
| B:工人负荷平衡优先 | 0.31 | 105 | 382 |
| C:混合解码 | 0.42 | 66 | 415 |
从表格看,混合解码的HV最高,说明整体前沿质量更好。而且混合解码能找到“总完工时间较小且工人负荷相对不差”的中间解,这是单一规则很难做到的。
更有意思的是工人数量的影响。当工人数量充沛(6人)时,三种配置的差异不大,因为工人约束不再是硬瓶颈,问题退化成接近经典HFSP;当工人数量紧张(2人)时,单一“最早可用优先”规则会经常被迫等待工人,makespan急剧恶化,而混合解码由于有工人负荷平衡规则兜底,表现明显更好。
所以我给这个问题的总结是:工人约束越强,启发式解码混合的价值越明显。如果工人数量远大于机器数量,工人约束几乎不起作用,单纯用经典解码方案就够了;但如果工人和机器数量接近,甚至工人比机器少,那就必须像本文这样,把工人可用性当成解码的重要判断维度,并且让多种解码规则在种群内共存。
在实现过程中我最大的体会是:不要迷信“算法框架”本身。NSGA-II只是外壳,编码和解码才是解决这个问题的核心。尤其是带工人约束的HFSP,解码器的设计直接决定了算法能搜到什么样的解。如果你只是把工人作为罚函数加到目标里,很难得到真正可落地的调度方案。把工人约束吃进解码逻辑,再配上多个启发式规则去增加搜索多样性,才是这条路走得通的关键。