流水作业调度不求人:从状态压缩DP到Johnson规则的完整实践指南

发布时间:2026/10/15 22:35:05
流水作业调度不求人:从状态压缩DP到Johnson规则的完整实践指南
流水作业调度这个名字听起来像是车间主任才会头疼的事情但做生产计划、供应链管理或者研究算法的人都绕不开它。很多教材把它放在“动态规划”章节导致不少初学者以为流水作业调度只能拿DP硬啃其实这个问题的核心决策并不复杂有一堆工件每个工件都要依次经过两台甚至多台机器加工机器同一时刻只能做一个工件怎么排列工件的加工顺序能让最后一台机器完成所有工件的时间最早。今天这篇文章我就把这个场景拆开揉碎从建模、动态规划推导、代码实现到工业边界完整过一遍。这篇文章适合几类人正在准备算法面试或竞赛的开发者刚接触生产调度问题的工业工程从业者以及任何想通过一个具体例子理解“状态压缩DP为什么有用”的人。我会先用一个生活化场景把问题描述清楚再用动态规划给出一个精确可复现的解法最后聊一聊为什么这个DP在多机器场景下会失效以及业界实际是怎么应对的。1. 问题到底在描述什么流水作业调度的建模1.1 从车间排产的直觉读懂数学模型先想象一个小型机械加工车间。车间里有两道关键工序数控车床加工然后是磨床精加工。每批工件都必须先上车床再上磨床顺序不能颠倒。车床一次只能加工一个工件磨床也一次只能磨一个工件。现在有5个工件各自的加工时间不同问题来了按什么顺序安排这些工件才能让磨床完成最后一个工件的时间最早用算法术语翻译一下有n个工件每个工件i包含两道工序分别需要两台机器M1和M2加工的时间为p1[i]和p2[i]。每个工件必须先完成M1上的加工再进入M2。同一台机器同一时刻只能处理一个工件不同机器之间可以并行工作。我们的目标是最小化所有工件在M2上完成的最大完工时间Cmax也就是makespan。这个问题最先让我觉得有意思的地方是它不是一个“求资源分配方案”的问题而是一个纯粹“求顺序”的问题。我们不需要决定每个工件什么时候开始加工——只要知道加工的顺序每台机器上每个工件的开始时间、结束时间就完全确定下来了。这意味着问题的解空间是n个工件的全排列规模是n!。1.2 为什么这个顺序问题不简单n!的增长速度相当恐怖。n等于10时全排列有362万种n等于15时就已经是1.3万亿种。暴力枚举在n大于10后就没有工程意义了。更关键的是工件之间是相互耦合的你先加工一个耗时长的工件可能让M2上的一段空闲时间被填满也可能让M2干等、机器利用率下降。每个工件的完工时间不仅取决于自己的加工时间还取决于前面所有工件的加工进度这种耦合关系让“简单排序”直觉失效。很多第一次接触这个问题的人会以为“把M1上耗时短的排在前面就行”但这个直觉是错误的。举个反例工件A在M1耗时1、M2耗时100工件B在M1耗时50、M2耗时1。如果按M1加工时间升序先A后B总耗时是1 100 1 102这里需要小心计算实际是A在M1完成时间是1A在M2完成时间是1100101B在M1完成时间是15051B在M2完成时间max(51,101)1102Cmax是102。如果换序先B后AB的M1完成时间50B在M2完成51A的M1完成时间50151A在M2完成时间max(51,51)100151Cmax反而变成151。这个例子说明单纯的局部排序规则往往会踩坑。1.3 目标函数的前向递推计算给定一个加工顺序如何计算总完工时间假设顺序是工件a1, a2, ..., an我们可以用一个简单的递推公式描述工件ak在M1上的完成时间F1[k] F1[k-1] p1[a_k]M1连续加工没有等待工件ak在M2上的完成时间F2[k] max(F1[k], F2[k-1]) p2[a_k]其中F1[0]F2[0]0。第二行公式的含义是工件ak在M2上真正开始加工的时间取决于ak在M1上的完成时间和M2加工完上一个工件的时间中的较大者。这个公式是所有后续推导的地基也是动态规划状态转移的雏形。2. 为什么要用动态规划而不是排序法则2.1 Johnson规则的一招鲜与局限聊流水作业调度绕不开经典算法——Johnson规则。对于两台机器、最小化makespan的问题Johnson给出了一个非常漂亮的排序法把工件分成两组p1[i] p2[i]的放一组按p1升序排p1[i] p2[i]的放另一组按p2降序排把第一组接在第二组前面就是最优顺序。这个规则的时间复杂度是O(n log n)最优性也严格成立。那既然Johnson规则这么高效为什么还要研究动态规划解法一个原因是通用性。Johnson规则只解决“两台机器、makespan目标”这个特定模型一旦目标函数换成最小化加权完工时间、或者增加工件准备时间这个排序法则就失效了而动态规划作为一种通用精确算法框架可以在很多变体上做局部调整后继续工作。另一原因是教学意义DP解法能让我们从状态压缩的角度重新理解调度问题这对进一步理解其他组合优化问题很有帮助。2.2 动态规划的核心思路用集合压缩状态动态规划解决这个问题的关键在于找对状态。最直接的想法是“当前处理到第几个工件”作为阶段但由于我们不知道已经选了哪些工件仅靠个数无法确定机器时间。流水作业的耦合关系很强机器M2上的进度不仅取决于已选工件的数量还取决于它们各自在M1、M2上的用时分布。所以这里要借鉴子集DP用一个位掩码集合S表示“已经安排过加工顺序的工件集合”。比如n5时S0b10110表示第2、3、5个工件已经排完从低位起算。我们不去枚举完整顺序而是把所有工件逐步加入一个集合每一步只考虑“最后一个加入的工件是谁”这样就能把n!个顺序压缩成2^n个集合状态。2.3 为什么状态里只需要记录M2的完成时间这是整个DP最精妙的一个点。我们要算的是对于某个已选工件集合S如果已经按照某种最优顺序安排完了S中的工件那两台机器现在处于什么状态结论是M1的完成时间完全由集合S决定等于sum_{i∈S} p1[i]和加工顺序无关。因为M1从0时刻开始一台机器不停歇地加工S里的所有工件无论顺序如何它加工完S中全部工件的总时间就是p1时间之和。但M2的完成时间不一样它和顺序强相关因为M2上会遇到机器空闲和等待M1的交替情况。因此对一个集合S如果我们希望它作为一个子问题被后续状态复用需要记录的信息只有一个在最优顺序下M2加工完S中所有工件的最早完成时间记作dp[S]。M1完全不需要记录——它就是那个固定的总和。这一步压缩直接把状态维度从二维降到一维非常精妙。3. 核心推导状态设计与转移方程3.1 状态定义与集合编码正式定义dp[S]集合S中的工件全部加工完毕后M2的最早完成时间。S是一个位掩码。sumP1[S]集合S中所有工件在M1上的加工时间总和。当S为空集时dp[0] 0sumP1[0] 0。最终答案是dp[(1n) - 1]即所有工件都在集合中时M2的最早完成时间也就是我们要求的最小makespan。可能有人会问dp[S]只存了M2的最早完成时间可某个顺序下M2的完成时间更晚难道没用吗确实没用。因为我们追求全局最优对于同一个集合S以其内部最优方式完成S中的工件然后再考虑加入新工件的扩张路径一定不会比内部次优的路径更差。这是典型的“最优子结构”论证也是DP成立的必要条件。3.2 状态转移加入一个新工件会发生什么假设当前状态是S我们想加入一个还没处理的工件jj不在S中。我们需要计算新的状态S S | (1j)下的M2完成时间。已知当前S的M1完成时间F1 sumP1[S]当前S的M2完成时间dp[S]新增工件j后M1完成时间变成F1 F1 p1[j]工件j在M2上真正开始时间max(F1, dp[S])因为一方面要等工件j在M1上加工完另一方面要等M2加工完S中最后一个工件M2完成时间变成F2 max(F1, dp[S]) p2[j]于是转移方程就是dp[S] min(dp[S], F2)一个容易踩的细节这里对dp[S]取min因为同一个集合S可以由多个不同的S通过加入不同的最后一个工件到达我们必须取最小的M2完成时间。sumP1数组的转移更简单sumP1[S] sumP1[S] p1[j]它和顺序无关所以可以直接通过位运算预计算或者转移时累加。为了方便常用一个数组preSum来处理。3.3 手工推演一个完整例子光看公式还是有点抽象我拿一个具体的随机小规模例子走一遍。假设n3三个工件的时间表如下工件p1p2123241312初始dp[0] 0sumP1[0] 0。先处理所有只含一个工件的集合只放工件1S0b001即数字1F1sumP1[0]22F2max(2,0)35dp[1]5sumP1[1]2。只放工件2S0b010即数字2F1044F2max(4,0)15dp[2]5sumP1[2]4。只放工件3S0b100即数字4F1011F2max(1,0)23dp[4]3sumP1[4]1。然后处理两个工件的集合。以S{1,2}为例它可以由两种方式到达从S{1}加入2当前F1sumP1[{1}]2dp5加入2后F1246F2max(6,5)17。从S{2}加入1当前F1sumP1[{2}]4dp5加入1后F1426F2max(6,5)39。 取较小值dp[{1,2}] 7。sumP1[{1,2}] 246。类似地算{S{1,3}}从S{1}加入3F12dp5加入3后F1213F2max(3,5)27从S{3}加入1F11dp3加入1后F1123F2max(3,3)36。取二者较小值6。sumP13。{S{2,3}}从S{2}加入3F14dp5加入3后F1415F2max(5,5)27从S{3}加入2F11dp3加入2后F1145F2max(5,3)16。取较小值6。sumP15。最后处理全集合S{1,2,3}从{1,2}加入3F1sumP1[{1,2}]6dp7加入3后F1617F2max(7,7)29。从{1,3}加入2F13dp6加入2后F1347F2max(7,6)18。从{2,3}加入1F15dp6加入1后F1527F2max(7,6)310。所以dp[{1,2,3}] 8。也就是说最优顺序下三台工件的总完工时间是8。实际最优顺序是1、3、2从{1,3}加入2那条路径也可以验证工件1在M1用时2在M2完成5工件3在M1完成3在M2完成max(3,5)27工件2在M1完成7在M2完成max(7,7)18。这个手算过程很值得自己用纸笔走一遍能深刻理解状态转移中max的含义“M2开始加工新工件的时间是它空闲可用时间与工件从M1释放时间的交集。”3.4 复杂度分析与状态空间优化技巧这个DP的时间复杂度是每个状态转移都要枚举一个不在集合中的工件所以总复杂度是O(n乘以2^n)次转移空间复杂度是O(2^n)。对于n202^20约100万乘以20是2000万次操作现代机器毫秒级完成n25时2^25约3300万乘以25是8亿多已经比较吃力需要优化实现和内存布局。几个工程上的优化小技巧使用数组而不是字典存储dp。Python里可以用[inf] * (1 n)避免哈希开销。预处理sumP1数组从sumP1[S]转移得到sumP1[S | (1j)] sumP1[S] p1[j]或者直接按位掩码预计算能省不少重复加法。枚举子集时利用掩码的二进制特性避免使用bit_count之类的操作直接用~S fullMask来取不在集合中的工件。如果n稍大可以剪枝如果当前dp[S]已经大于已知的某个可行上界比如Johnson规则解提前跳过。这些优化对于竞赛和教学场景都够用。再大规模的实例就不该用纯DP硬扛了后面我会讲到工业上的替代方案。4. 代码实现从伪码到可运行版本4.1 一个干净高效的Python实现下面这段是我在本地写过很多次的模板已经去掉多余装饰只保留核心逻辑。这是用Python实现的标准子集DPdef flow_shop_dp(p1, p2): n len(p1) inf float(inf) size 1 n dp [inf] * size sum_p1 [0] * size dp[0] 0 full_mask size - 1 for mask in range(size): # 可以加一个小剪枝dp[mask] 是 inf 就跳过 if dp[mask] inf: continue f1 sum_p1[mask] # 枚举不在 mask 中的工件 remaining full_mask ^ mask while remaining: bit remaining -remaining # 取最低位的1 j bit.bit_length() - 1 # 工件编号 f1_new f1 p1[j] f2_new max(f1_new, dp[mask]) p2[j] new_mask mask | bit if f2_new dp[new_mask]: dp[new_mask] f2_new sum_p1[new_mask] f1_new # sum_p1 其实可以这样更新 remaining ^ bit # 移去已处理的位 return dp[full_mask]注意sum_p1[new_mask]的更新方式因为f1_new就是原集合M1完成时间加上新增工件的p1它和以何种顺序加入工件无关所以可以在转移时顺带写入。但需要小心如果new_mask已经以另一种顺序被更新出更大的sum_p1我们其实只需要最小M2完成时间的那条路径对应的sum_p1。由于sum_p1与顺序无关无论从哪个前序状态到达new_masksum_p1[new_mask]都一样所以直接给值即可。4.2 用Johnson规则做验证实验我强烈建议写完DP之后做一次随机对比验证。因为Johnson规则对于两机makespan是理论最优的我们可以用随机数据验证DP和Johnson结果一致。这既是对DP实现的检查也能加深对两个算法等价性的认识。Johnson规则的实现其实很简单先按p1 p2分桶组内排序后拼接。def johnson_order(p1, p2): n len(p1) group_a [] # p1 p2 group_b [] # p1 p2 for i in range(n): if p1[i] p2[i]: group_a.append((p1[i], p2[i], i)) else: group_b.append((p1[i], p2[i], i)) group_a.sort(keylambda x: x[0]) # p1 升序 group_b.sort(keylambda x: x[1], reverseTrue) # p2 降序 order [x[2] for x in group_a] [x[2] for x in group_b] return order def makespan_for_order(p1, p2, order): f1 0 f2 0 for i in order: f1 p1[i] f2 max(f1, f2) p2[i] return f2然后写一个随机验证import random for n in range(1, 11): for _ in range(200): p1 [random.randint(1, 20) for _ in range(n)] p2 [random.randint(1, 20) for _ in range(n)] dp_ans flow_shop_dp(p1, p2) order johnson_order(p1, p2) johnson_ans makespan_for_order(p1, p2, order) if dp_ans ! johnson_ans: print(不一致, n, p1, p2, dp_ans, johnson_ans) break else: continue break else: print(全部随机数据下 DP 与 Johnson 结果一致)我跑了一次数据全部通过。这种“两个算法互相验证”的手法很实用。当DP不是用来求解两机makespan而是求解带有其他约束的变体时我们也可以用Johnson结果作为初始上下界来剪枝效果非常明显。4.3 实现中的几个坑关于实现有几个细节真的会坑到自己我挨个说。第一初始化问题。dp数组如果忘了初始化成inf或者把dp[0]设成了某个非零值整个转移就全错了。这属于最弱智但也最容易在疲劳时犯的错。第二位运算优先级。在Python里mask | 1 j的优先级经常让人迷惑因为的优先级高于|所以mask | 1 j实际是mask | (1 j)看着没问题但如果你写mask ^ 1 j可能就不是想要的。强烈建议遇到位运算就加括号不要吝啬。第三枚举剩余工件的写法。上面代码用remaining full_mask ^ mask拿到未加入工件的掩码然后通过bit remaining -remaining和remaining ^ bit迭代。这个写法比j in range(n)再判断mask j 1要快不少而且代码也更紧凑。如果你第一次看不太适应可以先用简单的循环写法功能等价但n20及以上建议用位操作枚举。第四Python的bit_length()在bit0时会返回0但我们的bit最低位至少是1所以不会出错。第五如果n很大比如n301 n会直接内存爆炸这时候就不要用Python数组了往往需要换成记忆化搜索加剪枝或者干脆放弃精确解法转用启发式算法。这部分我在第5节展开。5. 从双机到多机动态规划的适用范围与扩展5.1 为什么三台机器会让状态爆炸很多人看完双机DP会想当然地认为可以扩展到三台机器状态里记录M3的完成时间不就行了答案是远远不够。三台机器时M3的完工时间不仅取决于M2的完工时间还取决于当前正在M2上加工的工件、M2能否及时释放等更细的机器状态。如果把每台中间机器的完成时间都纳入状态那么状态会变成一个多维向量而且相互之间不再像双机那样可以简单消掉任何一维。更直白地说双机DP能用一个一维数组搞定是因为M1的历史被顺序无关的累加量sumP1[S]完全代替了。在m2的流水车间中每台机器的历史与顺序都强相关导致需要记录的“调度指纹”包括多个机器完成时间、甚至包括当前机器正在处理的工件编号这些信息组合起来状态数量很快就会超过内存限制。对于一般m台机器的流水作业调度这是个NP难问题经典文献里已经证明这也是为什么动态规划只能作为特定小规模实例的精确解法而无法推广到通用生产环境。5.2 在变体问题中DP的适度扩展虽然多机通用问题难但双机DP可以在很多变体上直接扩展比如增加准备时间某些工件在M2上加工前需要设定时间s[i]转移公式改成f2_new max(f1_new, dp[mask]) s[j] p2[j]即可。目标函数改成最小化最大延误每个工件有一个交货期d[i]可以在状态中额外记录当前所有工件的延误最大值但状态会膨胀一般只能处理非常小的n。分批问题把若干个工件合成一批批间有setup时间也可以用类似的状态压缩DP处理只是工件数会因分批减少仍然可行。这些扩展是DP的价值所在因为Johnson规则这种精巧的排序法很难平移到这些变体上而只要n不太大DP几乎是你最省心的精确求解工具。5.3 工业调度中为什么常用启发式方法现实中的流水线调度工件数常常是几百上千机器数也可能超过三台这时候精确DP完全无法落地。业界走的是另外两条路一条是优先规则比如先到先加工、最短加工时间优先、关键比法则等等计算快但结果远非最优另一条是元启发式比如遗传算法、模拟退火、禁忌搜索它们能在合理时间内找到接近最优的解。我在实际项目中见过不少情形先用Johnson规则或NEH启发式生成一个不错的初始解再用本地搜索改善配合仿真验证最后排产结果已经能满足交付要求。但这并不意味着DP白学了。调度问题里有很多子问题规模很小比如一条生产线的派工窗口只有8~10个订单或者一台瓶颈设备只需要在十几个备选订单中选最优顺序这时候DP就能直接上场而且给出的是严格最优解能作为评估其他启发式算法质量的基准。我在某公司做排产模块时就是把DP包成一个“小规模精确求解器”专门处理瓶颈工序的短窗口排序剩下的长流程交给启发式。这个组合效果远好于只用其中任何一种。6. 常见问题与调试现场实录6.1 dp数组总是不收敛答案偏大最容易出现的情况就是dp[new_mask]没有被正确取min而是直接用dp[new_mask] f2_new覆盖。这样后到达的转移会把之前更优的结果冲掉最终答案会偏大。排查方法很简单打一个小规模用例手推一遍对比每一步dp数组的变化或者用前面的随机对比脚本跑一遍立刻能发现问题。6.2 状态编码错位导致越界或结果诡异位掩码的位序必须和工件编号一致。如果工件编号从1开始而掩码位从0开始很容易错位。我习惯统一使用0-based编号并在一开始写一个编码注释。调试时如果结果诡异先打印mask和j的对应关系检查bit_length()是否减1。6.3 验证DP正确性的三板斧我自己的验证流程固定三步缺一不可。第一边界测试n1时答案必然等于p1[0]p2[0]如果DP算出来不是这个值转移公式可能写错了。n2时只有两种顺序可以直接手算比较。所有p1、p2都相等时答案是p1*np2这也是个强检验。第二随机对照随机生成大量小规模数据用暴力全排列算出最优解与DP对比。n不超过9时全排列很快暴力结果可以直接当作基准。第三与Johnson规则对照虽然DP理论上和Johnson给出相同的最优值但实现中可以用Johnson结果做交叉验证。如果结果不一致先怀疑DP实现其次怀疑自己对问题的建模比如是否忽略了某个加工约束。这三板斧下来基本能排除90%以上的实现错误。等代码稳定后这些验证脚本还能作为回归测试留下来后面改其他功能时随时跑一遍。6.4 从算法竞赛到生产调度的经验迁移最后说说经验。我最早接触这个DP时是在算法题里求最短调度那时候只觉得状态压缩很精巧真实世界并不会只有两台机器。后来做实际项目才发现这种“状态压缩最优子结构”的思想能延伸出更多方法比如基于状态空间的动态规划可以与剪枝搜索结合用来快速评估一批候选调度的上界甚至在启发式算法中充当“repair算子”——当启发式解中出现局部回路时用DP对子序列做一次精确重排往往能显著提升解质量。还有一点很实际理解了DP的复杂度瓶颈后你会对人说“精确解只适合小规模大规模必须用启发式”这种话有真实的认同而不是纸上谈兵。碰过调度项目的人都知道数据量一上来理论最优解不再现实但用DP确定“小窗口最优”再拼接全局往往是一条工程上真正可落地的路径。我在排产系统里最常用到它的场景反而是“当瓶颈工位出现临时插单时如何只调整局部订单顺序而不影响整体工期”这种实时调度场景下小规模DP加启发式组合响应快、结果稳比重新跑一遍全厂优化要实用得多。两机流水作业调度的DP解法讲到这里已经完整闭环了从问题建模、状态压缩推导、代码实现、正确性验证到工业应用边界该聊的都聊了。如果你只是想应付面试掌握第3节的转移方程和第4节的代码就够如果是为了解决实际排产问题那我建议你一定亲手跑几组随机数据把Johnson验证脚本写进自己的代码库以后遇到变体问题时这个基础框架会是你最快的起点。