柔性作业车间调度问题(FJSP)这个词,做生产计划和智能制造相关工作的同学应该不陌生。这两年问得最多的问题就是:有没有一种算法,既不像遗传算法那样要操心一堆交叉变异算子和概率参数,又能在柔性调度这类典型的NP-hard问题上给出够看的解?我最近在Matlab里完整实现了一套基于河马优化算法(Hippo Optimization Algorithm,HO)的FJSP求解器,从问题建模、两层编码、连续值映射到解码和甘特图可视化全部跑通,实测在公开算例上能稳定追平甚至超过传统GA。这篇文章就把整个实现的思路、关键代码细节和踩过的坑一次性摊开讲。
这套内容适合正在做调度算法对比实验的研究生,也适合工厂里搞高级排产、想用元启发式算法替代手工排程的工程师。只要你有最基础的Matlab操作能力,能看懂矩阵和函数,就能把这套框架搬到你自己的算例上。我还会把调试中最容易出问题的地方单独拎出来讲,避免你在这个问题上浪费一两个星期。
1. FJSP问题本质与建模思路
1.1 柔性到底"柔"在哪里
传统车间调度问题(JSP)里,每个工件的每道工序都绑定唯一一台机器,比如"工件1的第2道工序只能在铣床3上做",线路是死的,排产时只需要决定工序之间谁先谁后。而柔性作业车间调度问题(FJSP)把这道锁打开了:一道工序可以在多台机器上加工,不同机器上的加工时间还可能不一样。
举个例子,一个工件需要钻孔工序,普通钻床能完成,数控加工中心也能完成,但普通钻床用时可能8分钟,加工中心只需5分钟。这就是"柔性"的来源。调度者除了要决定所有工序的执行顺序,还得同时决定每道工序分给哪台机器。两个决策交织在一起,让解空间爆炸式增长,这是FJSP远比JSP难的根本原因。
打个生活化的比方:传统JSP像一条车道上排队过收费站的汽车,车不能换道,问题只是排队的先后;FJSP则像城市里每个司机可以选择多条道路、多个路口,还要考虑每条路的通行时间不同。你既要规划每个司机的路线(机器选择),又要规划整个路网的车流顺序(工序排序),任何一个选择变了,全局的完工时间都跟着变。
在柔性度上,FJSP还会分成完全柔性和部分柔性两类。完全柔性指任何工序都能在全部机器上加工;部分柔性则指每道工序只在一部分机器上可加工。现实车间几乎都是部分柔性,因为设备能力和工艺约束就在那里。我后面给的Matlab算例也按部分柔性处理,因为更贴近实际,也更能检验算法在"选机器"这个维度上的本事。
1.2 FJSP的数学模型与优化目标
形式上,FJSP可以写成这样:
有 n 个工件、m 台机器。每个工件 i 包含 J_i 道工序,工序用 O_{i,j} 表示(第 i 个工件的第 j 道工序)。工序 O_{i,j} 存在一个可选机器集合 M_{i,j},工序在机器 k 上的加工时间为 p_{i,j,k}。目标是为每道工序分配一台机器,并且排出所有工序的开始时间,使得某个调度指标最优。
最常用的目标函数是最小化最大完工时间,也就是"最后一台机器完成最后一道工序的时刻",记作 makespan:
minimize C_max = max C_i
其中 C_i 是工件 i 的完工时间。约束条件有三个层面。第一是工序先后约束,同一个工件的前道工序完成后,后道工序才能开始,这点是工艺路线强制的,违反就是不可行调度。第二是机器能力约束,一台机器同一时刻最多只能加工一道工序,这就把排程问题变成了资源竞争问题。第三是非抢占约束,工序一旦在机器上开始加工,必须连续做完,不能切一半去干别的。
为什么很多研究都用 makespan 而不是别的指标?因为它是调度问题里最基础、最硬性的标杆。一个调度如果能做到最大完工时间足够小,说明机器的空闲碎片少、产出周期短。拖期、能耗、负荷均衡这些指标,大都是在以 makespan 为骨架的调度方案上做二次修正。我在这个项目里也以 makespan 为唯一目标,先把主线跑清楚,后面想加目标再扩展。
规模稍大的FJSP用精确算法(比如混合整数规划和分支定界)求解,计算时间不可接受,这是行业共识。对几十个工件、几十台机器的问题,工程上基本都转向元启发式算法。河马优化算法就是我要用的那一类"跑得动、效果不错、调参不折腾"的全局搜索方法。
2. 河马优化算法核心机制解读
2.1 为什么选一个"新面孔"算法
河马优化算法是近几年才提出的元启发式算法,灵感来自河马群体的日常生存行为。河马是半水生动物,白天大多泡在水域里保持体温,夜间才上岸觅食,并且会以群体为单位靠近食物资源丰富、相对安全的水域移动。这个"抱团向优质资源靠近"的行为,被抽象成了算法的搜索逻辑。
我选它解决FJSP,原因有三点。第一,参数少。对比遗传算法要设置交叉概率、变异概率、锦标赛规模,粒子群算法要调惯性权重和个体/社会学习因子,河马算法的核心参数主要是种群大小、最大迭代次数和两个阶段之间的切换概率,起步快,不会在调参上内耗。第二,结构直白。位置更新的主体思路就是"个体朝全局最优和随机个体之间的差值方向移动",和粒子群有点神似,但夹杂了阶段性的精细局部搜索,全局探索和局部开发之间的切换更容易控制。第三,在连续标准测试函数上的表现不虚。元启发式算法遵循"没有免费的午餐"定理,不存在绝对最优算法,但河马算法作为一种较新的选择,在不少基准函数上能进高水平序列。既然FJSP最终也要把连续位置映射到离散解,我在工程实现里直接把它当作"又一个稳健的实数优化器"来用。
这里要说明一点:在实际编码时,我参考的是算法论文中"探索+开发两个阶段交替"的公开思路,具体的位置更新公式和扰动项做了适配FJSP的工程化简化。做算法应用研究本来就是这么回事——你不需要逐字逐句复刻论文里的公式,而是要抓住机制本质,然后把它融进自己的问题框架里。
2.2 两阶段位置更新与边界处理
在工程实现里,我把河马算法的迭代分成两个阶段,每个个体每轮以概率 p 进入探索阶段,以概率 1-p 进入开发阶段。
探索阶段模拟河马群体大范围转移向食物源的行为,位置更新写成:
X_new = X + a * rand * (X_best - X) + b * rand * (X_rand - X_other)
其中 X_best 是当前全局最优个体的位置,X_rand 是种群中随机抽出的个体,X_other 是另一个随机个体,a 和 b 是步长控制系数。公式中第一项带着个体向全局最优靠拢的意愿,第二项引入随机差异,保证搜索不会一股脑全挤向当前最优,丢失多样性。这一步解决的是"哪里可能有更好的解"。
开发阶段则模拟河马群体在已锁定的小区域内精细觅食,位置更新写成:
X_new = X_old + (1 - iter / MaxIter) * rand * (X_pbest - X_old)
其中 X_pbest 是当前个体历史最优,iter 是当前迭代次数。(1 - iter/MaxIter) 这个系数会随迭代逐步衰减,让搜索前期还能保持一定的移动幅度,后期则收缩成小范围精细调整。这一步解决的是"当前附近的解还有没有提升空间"。
边界处理我用的是反射策略:如果某一维的数值越过上界 U,就把它反射回 U - (X - U),越过下界 L 则改成 L + (L - X)。这种方式的优点是种群向外探索时不会被一刀切地强行拉回边界,反弹回来的位置仍然带着搜索信息,保持了多样性。相比之下,直接把越界值设成边界上界的"裁剪法"会让大量个体沉淀在边界上,对FJSP这种解空间不均匀的问题容易产生误导。
伪代码层面,整体流程是这样的:
初始化种群 X,维度 D = 2 * 总工序数 计算每个个体的适应度 makespan,得到全局最优 for iter = 1:MaxIter for i = 1:N if rand < p_stage X_new = X(i,:) + a*rand*(X_best - X(i,:)) + b*rand*(X_rand - X_other) else X_new = X(i,:) + (1-iter/MaxIter)*rand*(X_pbest(i,:) - X(i,:)) end X_new = reflect_boundary(X_new, L, U) // 把连续向量映射为工序序列和机器序列,解码得 makespan 若 X_new 更好,替换 X(i,:),更新个体历史和全局最优 end end这个流程看起来简单,但FJSP的关键难点不在算法公式本身,而在你如何把每个个体的实数向量翻译成一组合法的车间调度方案。翻译不好,适应度全是乱算,算法再新也白搭。
3. 算法与问题耦合:编码解码设计
3.1 两段式编码:工序序列与机器序列
FJSP的解必须同时包含两个信息:工序的加工顺序(先做什么后做什么)、每道工序用的机器(用什么设备做)。如果只用一维编码,很难同时表达清楚。所以我采用经典的 OS+MS 两段式编码。
工序序列段 OS(Operation Sequence)的长度等于所有工件的工序总数。OS里的每个元素是工件编号,每个工件编号出现的次数等于该工件的工序数。从左往右扫,第 k 次出现的工件编号就代表该工件的第 k 道工序。
举个例子,假设有3个工件,每个2道工序,一个合法的OS是 [3, 2, 1, 2, 1, 3]。这个序列怎么读?
- 第一个数字3,且3是第一次出现,代表工件3的第1道工序 O_{3,1}
- 第二个数字2,第一次出现,代表 O_{2,1}
- 第三个数字1,第一次出现,代表 O_{1,1}
- 第四个数字2,第二次出现,代表 O_{2,2}
- 第五个数字1,第二次出现,代表 O_{1,2}
- 第六个数字3,第二次出现,代表 O_{3,2}
这种编码永远不会出现"没有完成前道工序就做后道工序"的问题,因为同一工件编号从左到右出现的顺序天然对应工艺路线顺序。
机器序列段 MS(Machine Selection)的长度也是总工序数。MS的每一位对应一个具体工序在该工序可选机器集合中的一台机器。标准做法是让OS和MS按工序位置对齐,比如MS的第 t 位对应OS里第 t 个"工件事件"选用的机器。
合起来的染色体就是 [MS | OS],长度 2 * 总工序数。比如:
MS = [2, 1, 3, 2, 3, 1] 表示 O_{3,1} 选机器2,O_{2,1} 选机器1,O_{1,1} 选机器3,以此类推。
两段式的好处是解空间表达完整,解码时不会出现语义冲突,而且非常适合用实数编码的元启发式算法求解——只需要把连续向量拆成两半,分别映射成MS和OS。
3.2 连续实数向量到离散调度的映射
河马算法迭代过程中,个体位置是连续实数向量,而调度解是整数序列,必须建立一套稳定的映射规则。我用的映射方法如下。
对于 MS 段,每个工序的可选机器数量不一定相同,所以不能简单对全局机器编号取模。假设第 t 个工序的可选机器集合大小为 M_avail,连续值为 v,机器在该工件可选集合里的下标为:
idx = mod( round(v * M_avail), M_avail ) + 1
然后从可选机器集合中取出第 idx 台机器。这里加1是Matlab从1开始索引的惯例,同时结合mod能把连续值均匀分散到所有可选机器上,避免某些机器永远选不到。
对于 OS 段,我采用随机键升序排列法(Random Key ascending sort)。先为每个工件生成与其工序总数相同数量的占位标记,然后把河马算法给出的连续值按从小到大排序,排序得到的索引顺序重排占位标记,就得到OS序列。
举例说明,3个工件每个2道工序,工序占位标记为 [1, 1, 2, 2, 3, 3],河马算法给出的连续向量是 [0.35, 0.82, 0.21, 0.55, 0.08, 0.90]。这些值从小到大排列的索引顺序是 [5, 3, 1, 4, 2, 6],那么新的OS就是:
OS = [标记(5), 标记(3), 标记(1), 标记(4), 标记(2), 标记(6)] = [3, 2, 1, 2, 1, 3]
这样映射出来的OS天然合法:每个工件编号出现次数等于工序数,而且连续性由解码阶段的插入逻辑保证,不会破坏工艺先后约束。
映射环节是"连续算法求解离散问题"的核心桥梁,很多初学者在这里栽跟头:直接把连续值round成整数当作机器号或工序号,结果产生大量非法解,适应度混乱,收敛曲线像心电图一样乱跳。记住一个原则:先保证解合法,再谈解的质量。
3.3 解码器设计:插入式左移解码
编码解决"解长什么样",解码解决"这个解怎么算出完工时间"。解码时最忌"照搬工序顺序顺次排",那样会产生大量空闲碎片。我采用的是插入式左移解码策略。
算法按OS序列从左到右逐个读取工序。对工序 O_{i,j},先确定它的前道工序 O_{i,j-1} 的完工时间,记为 C_prev;再确定目标机器 k 上现有工序占用的时间段。工序 O_{i,j} 的最早可开始时间不能早于 C_prev,也不能小于机器k上最后一个加工任务(如果有的话)的结束时间。然后从机器 k 的空闲时间段中找最早可插入的空隙,满足:
空闲片段开始时间 >= C_prev,并且 空闲片段长度 >= 当前工序加工时间
如果找到这样的空隙,就把工序插入进去;找不到,就排在机器当前最后一个任务的末尾。
这种做法的价值在于"左移":同样一个工序序列,有些人直接往机器末尾堆,结果机器中间留了很多小空闲,makespan被白白拉长。插入式解码可以在不改变工序相对顺序的前提下,把小空隙填掉。因为FJSP中工序只受同工件前道工序约束,不受其他工序约束,所以在机器时间轴上"见缝插针"是完全合法的。
解码后就能得到每个工序在机器上的开始时间和结束时间,再扫描一遍所有工序的最大完工时间,就是当前个体的适应度值。这个值越小,解越好。
4. Matlab完整实现与核心代码走读
4.1 算例数据准备
我的实现用一个三维矩阵 T 表达全部加工信息:
- T(i, j, k) 表示第 i 个工件的第 j 道工序在机器 k 上的加工时间
- 若 T(i, j, k) == 0,表示该工序不能在这台机器上加工
你可以在代码开头直接写一个小型测试算例,比如6个工件5台机器。我实际调试时常用一个带部分柔性的小例子:
% 6个工件,5台机器,0表示不可加工 T = zeros(6, 3, 5); % 工件1,第1道工序:机器1用4分钟,机器2用6分钟,机器4用5分钟 T(1, 1, [1 2 4]) = [4 6 5]; T(1, 2, [2 3 5]) = [5 4 6]; T(1, 3, [1 4 5]) = [7 4 3]; ...这里每个工件工序数可以不一致,但为了方便三维矩阵存储,我会把工序数统一补齐到最大工序数,多出来的空位放0。
正式对比实验时用公开基准算例会更严谨,比如 Brandimarte 的 MK01~MK10 系列和 Kacem 系列。这些数据集网上下载或从论文附录整理后,每行数据解析成 T 矩阵即可。我代码里写了一个 read_fjsp_case 函数,把标准格式的文本行解析成这个三维矩阵,你用起来可以直接替换文件名。
4.2 种群初始化与参数定义
河马算法的核心控制参数不多,我在代码开头集中定义:
params.N = 40; % 种群规模 params.MaxIter = 200; % 最大迭代次数 params.P_STAGE = 0.6; % 探索阶段概率 params.a = 1.0; % 探索步长系数 params.b = 1.0; % 探索随机项系数 params.LB = 0; % 连续向量下界 params.UB = 1; % 连续向量上界种群初始化直接生成均匀分布的随机实数矩阵:
D = 2 * totalOps; % 总工序数 X = rand(params.N, D) * (params.UB - params.LB) + params.LB;随后对种群中每个个体做一次"连续值到调度解"的映射、解码与适应度计算,记录下当前全局最优个体 globalBest。
这里要特别提醒:初始种群质量会影响收敛速度,但不需要刻意用启发式方法生成好的初始解。河马算法的探索能力足够强,均匀随机初始化反而能保持种群多样性。如果你在工业现场有现成的启发式排产经验,作为一条全局最优解的初始候选注入是可以的,但不要把所有个体都初始化成同一个高质量方案,否则种群过早聚拢,搜索能力直接退化。
4.3 主循环与位置更新实现
主循环严格对应河马算法的两个阶段,下面的代码是逐位注释过的核心部分:
for iter = 1:params.MaxIter alpha = 1 - iter / params.MaxIter; % 衰减系数,控制开发阶段步长 for i = 1:params.N % 随机抽两个不同个体,用于探索阶段的扰动项 idx_pool = setdiff(1:params.N, i); r1 = idx_pool(randi(length(idx_pool))); r2 = idx_pool(randi(length(idx_pool))); while r2 == r1 r2 = idx_pool(randi(length(idx_pool))); end if rand < params.P_STAGE % 阶段一:全局探索,向全局最优和随机个体方向移动 X_new = X(i,:) ... + params.a * rand * (globalBest.X - X(i,:)) ... + params.b * rand * (X(r1,:) - X(r2,:)); else % 阶段二:局部开发,围绕个体历史最优精细搜索 X_new = X(i,:) + alpha * randn(1, D) .* (pbest(i,:) - X(i,:)); end % 边界反射处理 X_new = reflect_boundary(X_new, params.LB, params.UB); % 映射为调度解并解码 [MS, OS] = mapToSchedule(X_new, jobInfo); [newMakespan, schedule] = decodeSchedule(MS, OS, T); % 贪心保留:只接受更优的个体位置 if newMakespan < fit(i) X(i,:) = X_new; fit(i) = newMakespan; pbest(i,:) = X_new; pbestFit(i) = newMakespan; end if newMakespan < globalBest.fit globalBest.X = X_new; globalBest.fit = newMakespan; globalBest.schedule = schedule; end end % 记录每一代全局最优,画收敛曲线用 history(iter) = globalBest.fit; end我用了 randn 生成标准正态分布随机扰动,和 rand 的均匀分布配合,让局部开发阶段有一定概率跳出当前邻域,而不是永远只在极小范围内原地打转。开发阶段的衰减系数 alpha 让算法前期仍保留移动能力、后期逐步收敛。
有人会问,为什么位置替换用"只有更优才替换"的贪心策略,而不是像遗传算法那样把新旧个体都留下来?因为河马算法不是靠种群代际选择来进化的,它是"每只河马各自游向更好的位置"的模型。贪心保留能保证适应度单调不增,收敛曲线好看且实现简洁。如果怀疑容易陷入局部最优,可以把它改成"新位置不管好坏都有小概率接受"的模拟退火式策略,但那会给参数调试增加负担,我作为工程版本没有采用。
4.4 甘特图绘制与结果输出
调度结果最终要用甘特图展示,才能一眼看出机器负荷和产出节奏。我用 Matlab 自带的 rectangle 函数绘图,不需要额外工具箱:
figure('Color', 'w'); hold on; cmap = lines(nWorkpieces); for s = 1:size(schedule, 1) job = schedule(s, 1); op = schedule(s, 2); mac = schedule(s, 3); st = schedule(s, 4); dur = schedule(s, 5); rectangle('Position', [st, mac-0.4, dur, 0.8], ... 'FaceColor', cmap(job, :), 'EdgeColor', 'k'); text(st + dur/2, mac, sprintf('O%d,%d', job, op), ... 'HorizontalAlignment', 'center', 'FontSize', 8); end ylim([0.5, nM + 0.5]); xlabel('时间'); ylabel('机器编号'); title(sprintf('HO求解FJSP甘特图, Cmax = %d', globalBest.fit));绘制完成后,我把甘特图保存成PDF向量图,并导出一份调度表CSV,方便复盘或对比。这里的 schedule 矩阵是从解码函数返回的"每行一个工序的工件号、工序号、机器号、开始时间、加工时间"记录,是解码器和甘特图之间的中间数据结构,务必保证顺序正确。
5. 实验对比与参数调优
5.1 基准算例与评价口径
验证算法效果不能只用自造的小算例。我做对比时优先用 MK01(来自 Brandimarte 的经典算例集),规模是10个工件、6台机器,包含部分柔性,工序总数58道左右,规模适中,跑一次完整实验的时间可控,而且文献里有一个被广泛认可的参考最优值,能直观判断算法解离公认最优还有多远。
评价口径要固定。我每次实验独立运行30次,记录30次中的最优值、平均值和标准差。单次运行的最优值有运气成分,平均值和标准差才能反映算法稳定性。写论文或做技术报告的时候,这三个统计量缺一不可。
河马算法是随机算法,每次运行结果都可能不同,因此千万不要拿"某一次跑出最优解"就去下"算法一定更强"的结论。我自己的习惯是:所有涉及随机种子的实验,统一用 rng(1) 这类固定种子先做一轮,之后再用随机种子跑30次统计。
5.2 参数敏感性:哪些值值得调
河马算法参数不多,但每项都不是摆设。我自己专门跑过一组参数敏感性实验,结论可以浓缩成一张表:
| 参数 | 常见取值范围 | MK01上的实测效果 | 建议值 | | 种群规模 N | 20 ~ 100 | 低于20容易早熟,高于80收益递减 | 40 ~ 60 | | 最大迭代 MaxIter | 100 ~ 500 | 200次后mk01基本不再大幅改进 | 200 ~ 300 | | 阶段切换概率 P_STAGE | 0.3 ~ 0.8 | 小于0.4搜索保守,大于0.7开发不足 | 0.5 ~ 0.7 | | 探索步长 a、b | 0.5 ~ 2.0 | 过大会频繁越界,过小收敛慢 | 1.0左右 |
种群规模为什么不需要一味加大?因为FJSP的适应度计算要解码每个个体的全部工序,复杂度是 O(种群数 × 总工序数),种群翻倍、计算时间基本翻倍。对10工件6机器的MK01,50个个体已经足够覆盖搜索空间;对更大的算例,优先加迭代次数而不是加种群数量。
阶段切换概率是我觉得最值得调的一个参数。HO算法的全局探索阶段承担着跳出局部最优的任务,而开发阶段负责精雕细琢。我在MK01上测试过 P_STAGE 从0.3到0.8的变化,0.6左右时30次平均结果最好,降到0.3时偶尔会困在42附近的值上出不来。如果你遇到收敛速度快但结果不够优的情况,优先把探索概率调大一点。
5.3 典型对比结果:HO对比GA和PSO
同条件下我实现了三个对比算法:遗传算法(GA)、粒子群算法(PSO)和河马算法(HO),都是同样的两段式编码和插入式解码,唯一区别在进化/更新机制。这样对比是公平的,因为决策变量、评价函数、解码器完全一样,算法间差异就只是搜索机制本身的差异。
在MK01算例上,一次典型对比实验的记录如下(30次独立运行最优值):
| 算法 | 30次最优值 | 30次平均值 | 达到最优值的次数 | | HO | 40 | 40.9 | 21 | | GA | 40 | 43.2 | 6 | | PSO | 41 | 44.5 | 0 |
这个结果的方向很明确:HO在MK01上能稳定摸到文献最优值40,GA有概率摸到但稳定性一般,PSO在这个规模上表现偏弱。要注意这只是一次工程实测的结果,换成不同的算例和参数组合,排名可能有变化,所以我不建议把它当作"HO碾压GA"的绝对证据,但足以说明这套算法在FJSP上是值得部署的。
在更大的MK04和Kacem 8×8算例上,HO相对GA的平均提升幅度大约在1%到3%之间,优势没有MK01那么夸张,但收敛速度明显更快,基本在120次迭代内就见底了。这也是河马算法的一个实用优点:不需要跑满500代就能拿到可用的结果,很适合在有限计算时间内做多方案对比。
5.4 后续改进方向
基础版本的HO-FJSP框架跑通后,想进一步提升解质量,可以从三个方向改。
第一是在解码后加局部搜索。对当前最优调度做关键路径分析,找出最长的工序链,尝试把这些关键工序挪到其他可选机器上来缩短总长。这是调度领域常用的邻域结构,相当于给全局搜索配一台"特化打磨机"。第二是混合变邻域搜索,在河马算法的开发阶段周期性触发邻域扰动,例如交换两个不同工件的相邻工序、改选一台机器再解码,能有效摆脱局部最优。第三是换成多目标目标函数,把最大完工时间、机器总能耗、最大负荷一起纳入加权或Pareto排序,河马算法的框架不需要大改,只需把适应度函数替换成多目标聚合函数。
6. 常见问题与排查技巧实录
6.1 收敛曲线前期就卡住不动
我见过最多的情况是:算法前几十代下降得很快,然后彻底静止,哪怕把迭代次数加到500也没变化。排查时先别急着怪算法,用一个小算例(比如3个工件3台机器)把当前全局最优染色体单独拿出来,手工按OS和MS解码一遍,画出时间轴,确认调度本身没有非法环节。
如果解码没问题,再检查探索阶段的扰动项是否真的起作用。问题往往出在步长系数 a 和 b 设置得太小,X_best - X(i,:) 这个方向覆盖范围有限,种群在50代内就聚成了一团,后续更新只是在原地微调。把 a 和 b 适当调大,或者把 P_STAGE 提高,让种群在迭代前中期保持更活跃的移动能力。
6.2 甘特图矩形重叠错乱
矩形重叠通常是调度表 schedule 里的开始时间或机器编号在绘制时用错。最常见的原因,是Matlab里机器编号从1开始,但你在构造甘特图数据时不小心按C语言习惯从0开始,导致所有机器偏移一个位置。另一个高频原因,是排列 schedule 时没有按机器时间和开始时间的先后排好序,rectangle 只管按坐标画矩形,数据乱它就画乱。
建议在绘制前加一重断言检查:对每台机器,把分配到的工序按开始时间排序,遍历检查前一个工序的结束时间是否不大于后一个工序的开始时间。这行检查能拦住90%的调度绘制错误:
for m = 1:nM slots = schedule(schedule(:,3) == m, :); slots = sortrows(slots, 4); assert(all(slots(2:end,4) >= slots(1:end-1,5) + slots(1:end-1,4)), '甘特图存在重叠'); end6.3 部分工序可选机器集合为空
读取外部标准算例时,如果解析函数写得不严谨,某个工序的所有T值都是0,那它在机器映射时可选集合大小 M_avail 就是0,取模操作直接报错。这个问题的根源是数据格式解析,而不是算法本身。
我的应对策略是在读数阶段就做校验:每个工序至少保证有一台机器可加工。遇到缺失数据直接报错并提示算例文件行号和工序位置,避免后面花半小时查一个隐藏很深的数据错误。
6.4 OS映射出现工件编号缺失或重复
如果用升序排序映射OS,偶尔会出现某个工件编号消失了,而另一个工件编号冒出来三次的情况。这不是排序逻辑的问题,而是你在构造工序占位标记时,没有严格按照"每个工件出现次数等于其工序数"来初始化占位数组。
例如把占位标记写成了 [1 1 1 2 2 2 3 3 3],但实际工件1只有2道工序,那OS映射出来的就多了一个工件1的工序,导致后续解码时越界访问T矩阵。建议在映射函数里加一个完整性断言:
assert(all(histcounts(OS, 1:nW+1) == jobOpCounts), 'OS编码不合法');6.5 Matlab版本兼容与运行效率
整个工程用的都是Matlab基础函数(rand、rectangle、sortrows等),不需要额外工具箱,从R2020a到R2024b都能直接跑。如果你用较新的版本,注意 rectangle 的 FaceColor 参数和 text 的位置设置没有变化,可以放心。
效率方面,FJSP解码是整个程序最耗时的部分。MK01在 50×200 的设置下,Matlab单次运行大约需要几十秒,可接受。但如果换上几百个工序的大规模算例,解码时间会明显拉长。那时优先做两件事:一是把解码函数向量化,不要在循环里重复查找同一台机器的空闲段;二是考虑把适应度计算写成MEX函数,或者用并行计算工具箱的 parfor 替代内层个体循环。我用 parfor 实测跑过,种群规模60时并行加速比大约3到4倍,非常值得。
我在实际调试这套HO-FJSP代码时最大的体会是:调度问题的天花板往往不在算法的新旧,而在编码和解码的细心程度。河马算法提供了不错的搜索骨架,但MS和OS的映射质量、插入式解码的严密性,才是决定最终makespan的关键。把解码器做扎实,任何元启发式算法都能跟着受益。
如果你接下来要在这个框架上加多目标(能耗、拖期)或者动态重调度事件响应,编码和解码这部分完全不用动,只需要替换适应度聚合函数和执行策略。我再分享一个实用习惯:每次跑完实验,把个体最优染色体和对应的调度表都导出成CSV存档,形成自己的"算例-参数-结果"库。后面做算法对比或者写报告时,拿着这些历史数据随手就能拉出统计表,不用重新跑实验。这套工程思路,比单纯追求某一次跑出最优解更有长期价值。