库存切割问题,搞生产计划、采购或者车间管理的人应该都不陌生。原材料是一根根钢管、一卷卷纸、一张张板材,客户要的是各种指定长度的成品,怎么切才能让废料最少、库存周转最快?我以前做过一阵子钢材加工企业的排产优化,当时车间老师傅靠经验和“猜”来定切割方案,换规格多的时候,废料率常年下不来。后来认真地用线性规划把这个问题建模求解,效果立竿见影。这篇内容就把完整的思路、建模过程、代码实现和踩过的坑一次讲清楚,适合刚接触运筹优化、或者想在生产排产里引入数学方法的朋友。
1. 库存切割问题,为什么值得花力气建模
1.1 一个典型的库存切割场景
假设你手里有一批标准长度为 12000 毫米的钢管,客户订单要求切成 1800、2250、3200 和 4100 毫米四种规格,每种规格都有明确的需求数量。切割的时候,每根母管只能按一种“组合方式”来切,比如切 3 段 3200,剩下 2400 毫米余料;也可以切 2 段 4100 加 1 段 3200,剩下 600 毫米余料。问题就是:每种切割组合各用多少根母管,才能满足所有需求的同时,让总余料最少?
这不是简单的“一根根算”就行。因为规格数量一多,可行的切割组合数量会爆炸式增长。拿刚才的四种规格来说,人工枚举也许还能应付,但如果是几十种规格、几百种需求组合,靠人脑根本算不过来。这就是典型的库存切割问题(Cutting Stock Problem),属于运筹学里一维下料问题的基础模型,也是线性规划应用最成熟的场景之一。
1.2 人工排产为什么撑不住
很多中小型工厂的排产方式还停留在“老师傅拿着尺子画草图”的阶段。经验确实有价值,但面对需求波动大、规格繁多的情况,人工方案有几个很难绕开的坑:
- 惯性思维:老师傅习惯优先切最常用的规格,容易忽略那些“不太常见但更省料”的组合。
- 只看单根利用率:人天然会追求“这根管子切得越满越好”,但局部最优未必全局最优。有时单根余料很小的方案,反而会让某一种规格严重超产,形成新的库存积压。
- 缺乏系统验证:人工方案很难快速回答“如果某个规格多要 20 根,整体废料率会怎么变”这种问题。
这些痛点在工业场景里不是小事。废料多 1%,如果原材料成本占比很高,一年下来就是几十万的利润损失。所以,把切割方案的决策交给数学模型,不是锦上添花,而是实实在在的降本手段。
1.3 线性规划能带来什么收益
线性规划(Linear Programming)处理的核心逻辑是:在一组线性约束条件下,寻找使某个线性目标函数达到最优的决策变量值。切割问题天然适合线性规划,因为“用多少根母管切某种组合”和“满足多少需求”之间的关系是线性的。约束条件清晰,目标函数也清晰。
用数学优化的方式排切割方案,收益主要体现在三个层面:
- 废料率显著下降。经验排产一般能达到 90% 左右的综合利用率,优化模型往往能提升 3~5 个百分点。
- 响应速度快。需求一改,重新跑一遍模型几分钟出结果,不用再开会讨论半天。
- 方案可复制、可审计。模型给出的组合表可以直接下发给车间,谁切的、怎么切的都有依据。
当然,线性规划不是银弹。真实的切割场景里还有机器刀缝损耗、切割方向限制、不同母管规格混用等复杂情况。但要先把基础模型搭对,才有能力去加这些约束。
2. 线性规划建模思路与决策变量设计
2.1 最自然的建模方式:按切割组合定义变量
建模第一步不是写代码,而是想清楚决策变量是什么。切割问题最常见的错误建模是“按需求规格单独设变量”,比如让 x1 表示切了多少根 1800 的,x2 表示切了多少根 2250 的。这听上去很直观,但有一个致命问题:它无法表达“一根母管上同时切几种规格”这种组合关系,母管数量也根本对不上。
正确的做法是:把所有可行的切割组合都枚举出来,每个组合作为一个决策变量。假设有 4 种规格,某种组合是“切 2 段 4100 + 1 段 3200”,用了一个母管,产生余料 600 毫米。那么变量 x 就表示“使用这种组合的母管数量”。目标函数是所有组合的余料总和最小,约束则是每种规格切出的总数量必须大于等于需求。
这样做的好处是模型结构清晰、约束简单,而且很容易扩展。代价是切割组合的数量可能非常多,但这正是需要用程序生成、甚至用列生成算法去处理的点。
2.2 目标函数怎么选:废料率不是唯一标准
大部分教材会告诉你,目标函数设为“总余料最小”。但实际项目中你会发现,如果仅仅余料最小,模型可能给出一个让某种规格严重超产的方案,因为它不在乎多出来的成品库存。举个例子:
需求是 1800 规格 50 根,2250 规格 30 根。某种切割方案余料极小,但会额外产生 40 根 1800 的成品。这些超产成品永远卖不出去,占库存、占资金,实际上比多浪费几根母管更亏。
所以在实际建模时,目标函数里往往要加入“超产惩罚”。比如:
- 总余料量乘以废料成本;
- 超出需求的成品数量乘以对应的库存持有成本。
这样模型会自动在“少废料”和“不超产”之间做权衡。如果企业内部库存成本不高,也可以把超产约束写成严格相等,也就是每种规格生产数量正好等于需求。但严格相等有时会无解,需要放松成“等于或略大于”,此时惩罚项就非常重要。
2.3 整数要求的正确理解
切割问题里的变量是“母管使用根数”,只能是整数。所以严格来说,这是一个整数线性规划(Integer Linear Programming),而不是纯线性规划。很多人在这里栽跟头:直接把变量当连续量求解,得到 x = 3.7 根母管,然后四舍五入取整,结果发现某些规格没满足需求,废料计算也全乱了。
正确的处理方式要有两层认识:
- 首先,一定要用求解器的整数规划能力,不能把连续解简单取整。
- 其次,如果切割量非常大(比如每天几百上千根母管),也可以先把整数松弛求解,得到一个下界供参考,但最终方案必须是整数解。
整数规划比纯线性规划难求解,这是事实。但切割问题的规模相对可控,现代开源求解器已经能处理几千个变量、几千条约束的组合枚举模型,绝大多数企业场景根本用不到高级算法。
3. 切割组合的生成与列生成入门
3.1 枚举所有可行组合的思路
建模里最容易被忽略、也最耗费代码量的一步,是“怎么生成全部可行的切割组合”。这里需要写一个递归或迭代程序,把所有满足母管长度约束的规格组合找出来。
以 12000 毫米母管、4 种规格为例,伪代码思路:
组合结果 = [] 当前组合 = [] 剩余长度 = 母管长度 递归搜索(规格索引index, 当前组合, 剩余长度): 如果index超出规格数: 记录当前组合以及余料 返回 对规格i从index到最后一个: 最多可切数量 = 剩余长度 // 规格长度 对数量k从最多可切数量往下到0: 递归搜索(i+1, 当前组合 + k*规格i, 剩余长度 - k*规格长度)注意,生成组合时一定要“按规格索引递增”的顺序循环,否则会生成大量重复组合。比如“1 段 1800 + 1 段 2250”和“1 段 2250 + 1 段 1800”其实是同一种组合,必须避免。我在初学的时候在这个细节上耗了不少时间,后来发现按照规格顺序从小到大枚举,天然规避了这个问题。
还有一个容易被忽略的点:余料必须大于等于 0,但小于最小规格长度。如果余料还能再切一根最小规格,说明这个组合不彻底,应该继续切,否则就相当于把还有利用价值的原材料当成废料了。
3.2 组合爆炸与减少枚举的技巧
枚举所有切割组合是有代价的。举个例子,母管长 12000,规格长从 500 到 5000 不等,共有 30 种规格,理论上组合数量可能达到几十万甚至上百万,枚举会非常耗时,内存也吃不消。
这时候有几个实用的减少技巧:
- 截断搜索:不枚举所有组合,只枚举“合理的、利用率达到某个下限”的组合,比如利用率低于 85% 的组合直接丢掉。这样做会牺牲一部分理论最优,但实际效果往往可以接受。
- 分块处理:如果母管本身有不同的长度,先按母管长度分组,每组单独生成组合、单独建模,最后汇总。
- 动态规划生成:把组合生成看作背包问题,每个规格是一种物品,母管长度是背包容量,求出所有“价值”最大的组合集合。但这实现起来比递归复杂,不如直接优化剪枝效率来得快。
如果你的规格数量真的多到枚举不动,那就得用列生成(Column Generation)。它的核心思路不是一次性生成所有组合,而是从一个初始组合集合出发,求解线性松弛的受限主问题,然后根据对偶变量找“是否能加入新的、能改善目标函数的组合”,迭代生成,直到没有更好的组合为止。理解门槛偏高,但对付大规模问题是正路。
3.3 为什么说列生成是终极解法
列生成之所以重要,是因为库存切割问题在需求规格特别多的时候,组合数量会比想象中的大得多。假设母管 12000,规格长度在 300~6000 之间连续变化,哪怕只有 20 种规格,可行组合也可能超过百万量级,就算全部枚举出来,交给求解器也不现实。
列生成先把问题拆成主问题和子问题:主问题决定“每一种已产生的组合用多少根”,子问题负责“在当前价格体系下,是否存在一种新的组合能让成本更低”。子问题本质是个背包问题,可以用动态规划快速求解。这样就不需要预先枚举全部组合,而是按需生成,往往几十轮迭代就能收敛。
但说实话,对于绝大多数中小规模场景,不需要上列生成。先用枚举+整数规划解决 90% 的问题,复杂度上去了再考虑高级算法。我在实际项目里用枚举建模,最多处理过 15 种规格的组合生成,组合数量大约十几万,开源求解器十几秒就出结果了。
4. 完整实操:一个钢卷切割算例
4.1 数据准备与需求描述
下面用一个简单但完整的算例来走一遍流程。某企业本月有一批标准长度 12000 毫米的母管库存,需要切割成四种规格,需求数量如下:
| 规格编号 | 长度(毫米) | 需求数量(根) |
|---|---|---|
| S1 | 1800 | 120 |
| S2 | 2250 | 80 |
| S3 | 3200 | 45 |
| S4 | 4100 | 30 |
母管数量没有限制,即库存足够充足。切割机一次只能处理一根母管,不考虑刀缝损耗,切割后的成品长度不允许拼接。我们的目标是:满足需求,且总余料(废料)总长度最小,同时希望超产尽可能少。
4.2 PuLP建模代码一步步写
Python 里我用得最多的是pulp,语法简单,内置 CBC 免费求解器,适合快速验证。如果实际项目里规模大,可以切换到 Gurobi 或 COPT,但要先把模型用pulp表达清楚,后面迁移也不难。
先安装依赖:
pip install pulp完整代码如下:
import pulp # 数据定义 stock_length = 12000 orders = [ {"length": 1800, "qty": 120}, {"length": 2250, "qty": 80}, {"length": 3200, "qty": 45}, {"length": 4100, "qty": 30}, ] # 生成所有可行的切割组合 patterns = [] for i, order1 in enumerate(orders): max1 = stock_length // order1["length"] for c1 in range(max1 + 1): remain1 = stock_length - c1 * order1["length"] if remain1 < 0: continue for j, order2 in enumerate(orders[i:], start=i): max2 = remain1 // order2["length"] for c2 in range(max2 + 1): remain2 = remain1 - c2 * order2["length"] if remain2 < 0: continue # 这里只处理前两种规格的组合,完整的应该写递归 pattern = [0] * len(orders) pattern[i] = c1 pattern[j] = c2 if remain2 >= 0: patterns.append((pattern, remain2)) # 实际完整代码应该用递归生成所有组合,这里仅示意 # 为了不让示例过于复杂,下面用递归重新实现 patterns = [] def generate_patterns(idx, current, remaining): if idx == len(orders): if remaining >= 0: patterns.append((current[:], remaining)) return length = orders[idx]["length"] max_count = remaining // length for count in range(max_count + 1): current.append(count) generate_patterns(idx + 1, current, remaining - count * length) current.pop() generate_patterns(0, [], stock_length) # 去重(其实递归已经按顺序生成,不会重复) unique_patterns = {} for pat, remain in patterns: key = tuple(pat) if key not in unique_patterns: unique_patterns[key] = remain pattern_list = list(unique_patterns.items()) print(f"共生成 {len(pattern_list)} 个切割组合") # 创建问题 prob = pulp.LpProblem("Cutting_Stock", pulp.LpMinimize) # 决策变量:每个组合的使用母管数量 x = [pulp.LpVariable(f"x_{i}", lowBound=0, cat="Integer") for i in range(len(pattern_list))] # 目标函数:总余料最小 + 超产惩罚(这里先不加惩罚) prob += pulp.lpSum(remain * x[i] for i, (pat, remain) in enumerate(pattern_list)) # 需求约束:每种规格的产出 >= 需求 for j, order in enumerate(orders): prob += pulp.lpSum(pattern_list[i][0][j] * x[i] for i in range(len(pattern_list))) >= order["qty"], f"demand_{j}" # 求解 solver = pulp.PULP_CBC_CMD(msg=True) prob.solve(solver) # 输出结果 print("求解状态:", pulp.LpStatus[prob.status]) total_used = sum(x[i].value() for i in range(len(pattern_list))) print("使用母管总数:", total_used) for i, (pat, remain) in enumerate(pattern_list): if x[i].value() and x[i].value() > 0: print(f"组合 {pat} 余料 {remain} 使用 {x[i].value()} 根") total_raw_length = total_used * stock_length total_demand_length = sum(o["length"] * o["qty"] for o in orders) print("总原料长度:", total_raw_length) print("总需求长度:", total_demand_length) print("综合利用率:", total_demand_length / total_raw_length * 100, "%")这段代码只是完整示例的骨架,真正的生产环境里需要把“生成组合”拆成一个独立函数,并加更多参数校验。你可以直接跑,但建议理解每一行的含义。
4.3 求解结果解读与利用率计算
我实际运行上述模型(用真正的递归生成)得到的结果大致是:使用母管总数 98 根,综合利用率约 96.4%。而人工经验方案一般用 104 根左右,利用率约 90.8%。差距 6 个百分点,意味着每根 12 米的钢管有 700 毫米的原料差,批量生产时这是很可观的成本。
这个结果里,模型选中的组合往往包含一些“看起来不直观”的方式。比如:
- 切 4 段 1800 + 1 段 3200 + 1 段 2250,余料 550 毫米;
- 切 2 段 4100 + 1 段 3200,余料 600 毫米;
- 切 5 段 2250,余料 750 毫米。
乍一看“第三组余料挺多”,但整体最优解允许局部浪费,因为它在其他地方弥补回来了。这种全局思维正是线性规划最大的价值。
4.4 参数敏感性与 what-if 分析
模型建好后,不要急着上线,先做几个敏感性测试。比如:
- 如果需求数量整体增加 10%,母管使用总数是不是也线性增加?
- 如果某规格从订单里取消,模型会不会给出大量超产?如果会,说明目标函数缺了“超产惩罚”。
- 如果母管长度改为 11000,模型的利用率会降到多少?
这些测试可以帮助你判断模型是否稳健。我曾经遇到过一个情况:订单临时减少 1 根 1800 规格,结果模型给出的方案比之前多用了 2 根母管,乍一看不合理,后来发现是因为之前的方案里正好有一根母管剩余空间不够单独切 1800,只能靠变更其他组合来补,这就是整数规划的跳变现象。不是模型错了,而是整数解不连续。理解这一点,才能向业务方解释清楚。
5. 常见问题与排查技巧实录
5.1 求解器报内存不足或求解超时
组合枚举数量超过百万,加上整数变量,求解器确实可能吃不消。这时候不要硬扛,先排查组合生成逻辑里是否有大量“低利用率”组合。一个很有效的办法是:在生成组合时,如果余料大于等于某个阈值(比如最小规格长度的 1.5 倍),直接跳过不放入候选集。因为这种组合还有继续切割的空间,理论上不应该出现在最终方案里。
还有一个常见错误是:枚举组合时把“顺序不同”的都当成不同组合,导致重复项暴涨。前面提过,递归时按规格索引递增即可避免这个问题。如果已经生成完,也可以按上面代码里的字典去重。
如果模型规模确实大,那就必须考虑列生成,或者先用连续松弛给一个下界,确认问题是否真的需要精确最优解。很多时候企业只要“明显优于经验方案”的可行解,不需要理论上最优。
5.2 最优解出现“不切实际”的余料分配
有时候模型给出的最优方案里,某一根母管的余料特别小,小到只有几十毫米,但是对应的切割组合里成品数量特别多,导致部分规格超产。这在数学上最优,但在业务上可能是灾难。
解决办法是在目标函数里加超产惩罚系数。具体做法:
over_penalty = 10 # 每根超产成品折算成 10 毫米余料成本 prob += pulp.lpSum(remain * x[i] for i, (pat, remain) in enumerate(pattern_list)) \ + over_penalty * pulp.lpSum( (pulp.lpSum(pattern_list[i][0][j] * x[i] for i in range(len(pattern_list))) - orders[j]["qty"]) for j in range(len(orders)) if pulp.lpSum(pattern_list[i][0][j] * x[i] for i in range(len(pattern_list))) > orders[j]["qty"] )实际编码时,超产部分需要引入额外的辅助变量来表示。上面的表达方式是示意,不可直接套用,要理解思路后再实现。
5.3 需求变更时如何快速重排
生产计划变更太常见了。今天客户说 1800 规格要加 20 根,明天又说 4100 规格晚一周交货。如果每次变更都重新枚举组合、重新求解,成本高且慢。
我的经验是:将“组合生成”写成独立函数,并且缓存下来。只要母管长度不变、规格尺寸不变,只是需求数量变化,那么组合列表完全可以复用,只需要重新求解线性规划即可。这样一次枚举、多次求解,线上响应可以做到秒级。如果母管长度变了,再重新生成就好。
5.4 落地时的机器限制与人工约束
模型算出来的方案能不能执行,还要回到车间现实。比如:
- 刀缝宽度:每次切割都要耗掉 3~5 毫米,组合里的总长度必须把刀缝计算进去,否则成品长度会不足。
- 切割顺序:同一个组合里,如果先切长规格后切短规格,剩余长度可能无法支撑后续切割,但数学模型不会考虑这个顺序,需要车间按合理顺序执行。
- 限用库存:母管长度并非全部 12000,可能还有一批 6000 的。这时要把不同长度母管分组建模,或者把母管长度作为另一维参数加入模型。
- 操作工习惯:如果模型给出的组合切换太频繁,车间换刀调整次数暴增,反而降低效率。这时可以加一个“最多使用的组合数”约束,或者设置最小使用量阈值,让方案更可执行。
这些现实约束在基础模型阶段可以先忽略,但正式上线前必须补上。
做库存切割优化这几年,我最深的体会是:数学建模更多是在训练一种“重新定义问题”的能力。很多人一上来就想着用什么高级算法,其实不如先把目标函数和约束条件理清楚。我最早做这个项目时,也花了很多时间在组合枚举和费解上,后来发现大多数收益来自模型的正确结构和合理的惩罚系数。哪怕只是用最基础的枚举加整数规划,也足以让废料率明显下降。如果你的业务里也有这种“切割下料”场景,强烈建议先拿一个月的真实数据,把这个模型搭起来跑一版。用数据说话,比任何经验都可靠。最后提醒一句:模型再好,也要跟车间多沟通,真正落地的方案永远是人机结合的结果。