
组合优化这五个字第一次让我真正重视起来是在做一套排班系统的时候。当时业务方只提了一个要求把每个人每天的班排得尽量公平还要满足一堆硬性规则。我第一反应是写个贪心脚本按顺序分配结果上线第一周就被打回来了——有几天某个时段直接没人有几天同一个人连着上了三个夜班。问题不在代码写得烂而在于我压根没意识到这本质上是一个组合优化问题选项是离散的、约束是硬性的、目标是全局最优而不是局部看起来不错。这篇文章就把组合优化问题这个主题掰开揉碎从它到底在解什么、怎么建模、算法怎么选一直讲到用 Python 真刀真枪跑通一个例子把我自己踩过的坑和总结的技巧都放进来。不管你是刚接触运筹优化的学生、转行做算法工程的开发者还是需要给业务排产排班做决策的技术负责人都能从里面找到能直接上手的东西。1. 组合优化问题的本质从离散选择里挑出全局最优1.1 它和连续优化到底差在哪先把概念钉死。组合优化Combinatorial Optimization解决的是这样一类问题可行解是有限个、离散的组合我们要在这些组合里找到让目标函数最大或最小的那一个。注意两个关键词——有限和离散。这跟连续优化完全不是一回事。连续优化比如求一个凸函数的最小值变量可以在实数区间里任意取值靠梯度下降、牛顿法这类基于导数的方法就能收敛。而组合优化里变量是选或不选排第几位走哪条边你没法对一个选或不选求导。这个差异直接导致了方法体系的分道扬镳连续优化靠微积分组合优化靠枚举、剪枝、松弛和搜索。举个最直观的例子。0-1 背包问题有 n 件物品每件有重量和价值背包容量固定问怎么选价值最大。可行解的数量是 2 的 n 次方。n10 的时候是 1024 种随手就能枚举n50 的时候是大约 1.1×10^15 种一台机器每秒试一亿种也得试三百多天。这就是组合优化的爆炸性也是它让人又爱又恨的根源。注意很多人一上手就把组合优化问题当成写个 for 循环遍历所有方案小规模确实能跑但规模一上去就会撞墙。判断分水岭的方法很简单——先估一下可行解的大致数量级超过 10^8 就别指望暴力枚举了。1.2 三个必须写清楚的要素任何一个组合优化问题拆到最后都逃不过三件套决策变量、目标函数、约束条件。这三样写不清楚后面算法再花哨都是白搭。决策变量是你要决定的东西必须是离散的。比如第 i 个人是否安排在 j 时段的班就是 0-1 变量第 i 个城市在路径中排第几位就是整数变量。变量定义得合不合理直接决定模型规模。我见过有人把某台机器在某个时刻的状态定义成一个超大的三维 0-1 矩阵结果光变量就有几百万个模型还没求解就内存爆了。目标函数是你要优化的指标通常写成变量和系数的加权和比如总成本最小、总收益最大、总延误最短。这里有个坑现实业务的目标往往不止一个既要成本低又要公平又要准时但标准求解器只认单目标。处理方式要么加权合成权重怎么定是个学问要么做字典序优化先保证最重要目标再在其最优解集合里优化次目标。约束条件是硬性规则比如每人每天最多一个班总重量不超过容量每个客户必须被访问一次。约束写得松解不可行约束写得紧可能无解。无解是组合优化里最常见的翻车现场后面第五节我会专门讲怎么排查。用一句话概括这三者的关系在满足所有约束的可行解集合里找到让目标函数最优的那个组合。听着简单难就难在可行解集合大到没法穷举而且约束之间往往互相牵制。1.3 复杂度那点事P、NP 与 NP-hard 别被吓住聊组合优化绕不开计算复杂度。简单说P 类问题是能在多项式时间内求出最优解的问题比如最短路Dijkstra、最小生成树Kruskal/Prim、二分图最大匹配这些都有漂亮的经典算法。NP-hard 问题则是目前没人找到多项式时间精确算法的难题典型代表就是旅行商问题TSP、背包问题、图着色、集合覆盖。这里要澄清一个特别容易被误传的点NP-hard 不代表没法解它只代表在最坏情况下随着规模增长精确求解的时间会爆炸。实践中我们有三条出路思路适用场景代价精确求解规模小或结构特殊如背包有伪多项式 DP规模受限于时间/内存近似算法有理论保证比值的场景解质量有上限但可控启发式/元启发式大规模工程问题、实时性要求高无最优保证靠调参我个人在实际项目里的心态是这样的业务方要的是足够好且稳定不是理论上最优。绝大多数排产、排班、路径规划场景能拿到接近最优的可行解、并且每次跑出来的波动可控就已经是巨大胜利了。死磕最优解往往不是技术问题没解决而是根本没搞清业务到底能接受什么。2. 常见问题类型与建模套路2.1 资源分配类背包、装箱、选址资源分配是组合优化里最接地气的一类核心是把有限的资源分给不同的对象让收益最大或成本最小。0-1 背包是入门必学的模型物品要么全拿要么不拿。它的经典解法是动态规划状态定义成dp[i][容量] 前 i 件物品在容量限制下的最大价值转移就是拿或不拿两个选择取最大。这个模型虽然简单但它背后的思想——用状态压缩可行解的搜索——能推广到很多问题。**装箱问题Bin Packing**则反过来有一定数量的物品和若干容量相同的箱子问最少用几个箱子装完。它和背包长得像但目标不同背包最大化价值装箱最小化箱子数而且装箱是实打实的 NP-hard规模稍大就得靠启发式首次适应、最佳适应降序或者整数规划。设施选址是我做过的最有业务价值的一类要在若干候选地点里选几个开仓库让建设成本 运输成本最小同时每个客户必须被某个仓库覆盖。这个模型里 0-1 变量和连续变量运输量混在一起属于混合整数规划用 PuLP 或 OR-Tools 建模非常顺手。建模这几类问题有个通用套路我总结成三步先确定选择的粒度——是选物品、选箱子还是选地点为目标里的每一项找到对应的系数尤其是成本类的隐藏项运输成本、切换成本经常被漏掉把必须满足的规则全部翻译成线性不等式特别注意至少至多恰好的区分。2.2 路径规划类TSP 与车辆路径问题旅行商问题TSP一个推销员要走遍所有城市再回到起点问怎么走总路程最短。别看它描述朴素它是组合优化里最经典、研究最透的 NP-hard 问题也是无数实际场景的抽象原型——配送路线、巡检路线、电路板钻孔顺序、物流揽收顺序。TSP 的建模难点在于消除子回路。如果你只写每个城市入度和出度都为 1会得到一堆互不连通的环而不是一条完整路线。常见的处理办法有 MTZ 约束、子回路消除割平面等。实操中除非必须精确求解我一般直接用 OR-Tools 的 Routing 库它内置了子回路处理省心得多。**车辆路径问题VRP**是 TSP 的升级版多辆车、有容量限制、有时间窗。这才是真实物流场景的样子。VRP 的复杂度比 TSP 高一个量级工程上几乎都用元启发式如引导局部搜索、大邻域搜索来解OR-Tools 的默认求解器就是这类。我做过一个带时间窗的配送路径项目最大的体会是约束每加一条求解难度不是线性增加而是指数级增加。一开始只带容量约束10 秒能解 200 个点加了时间窗之后同样规模得跑好几分钟才收敛到一个可接受解。所以建模时一定要问这条约束是真硬性的还是可以软化的很多最好满足的规则做成软约束加惩罚项求解速度会快很多。2.3 排班与调度时间维度上的组合难题排班和调度是我认为最考验建模功底的一类。它们的共同特点是对象人/机器和时间两个维度交织约束多且互相耦合。排班的典型约束包括每人每天最多一班、连续夜班不超过 N 天、每人每周休息不少于 M 天、某些岗位必须有资质的人、班次之间的最小间隔等。调度的典型约束包括工序先后顺序、机器互斥、切换时间、交货期。这类问题建模时我最常犯也最常被提醒的错误是对称性没破除。举个例子如果有三个完全相同的员工模型会把张三上早班、李四上晚班和李四上早班、张三上晚班当成两个不同的解来搜索白白浪费大量时间。破除对称性的常用手段包括给同质员工加序号排序约束第 i 个员工被分到某班则第 i1 个不能更早被分到或者直接对同质资源做聚合再展开。2.4 建模工具选型别一上来就上商业求解器工具选型这件事我的建议是按问题和预算分档不要盲目上重器。工具类型适合场景我的评价手写动态规划/贪心自研小规模、结构清晰、实时性要求高可控但通用性差OR-Tools开源TSP/VRP/CP-SAT/一般 MIP免费、文档全、路由场景首选PuLP开源建模层线性/整数规划建模语法简洁适合学习和中小规模SciPy.optimize.linprog开源纯线性规划不带整数变量只能做松弛Gurobi/CPLEX商业大规模、对求解质量和速度苛刻强但贵小团队慎选提示如果你只是想验证建模思路对不对先用 PulP 或 OR-Tools 的免费求解器CBC、SCIP跑通逻辑别一上来就买商业 License。90% 的中小规模业务问题开源工具完全够用真正卡住你的往往不是求解器性能而是模型本身建得不够紧。3. 解法全景精确、近似、启发式怎么选3.1 精确算法分支定界与动态规划分支定界Branch and Bound是整数规划精确求解的主力思想。它先求解问题的线性松弛把 0-1 变量放松成 [0,1] 连续变量得到一个最乐观的界如果某个分支的界已经差于当前已知的最好可行解就整枝剪掉不再往下搜。这套逻辑之所以高效关键在于松弛要够紧也就是松弛解要尽可能接近整数解。松弛松了剪枝就剪不动搜索树会爆炸。实践中提升分支定界效率的几个手段我经常用加割平面cutting planes收紧可行域、设置合理的最优性间隙gap tolerance比如 1% 就停、给出一个初始可行解帮助上界快速下降、调整变量分支顺序优先分支取值接近 0.5 的变量。动态规划DP则适用于有最优子结构和重叠子问题的问题。背包、最短路、编辑距离都是 DP 的经典场景。DP 的坑在于状态空间容易膨胀如果状态维度设计不当内存会瞬间被吃光。我曾写过一个三维 DP 解决带容量和时间双重约束的问题n200 的时候状态数就上亿了直接溢出。后来改成滚动数组 稀疏存储才压下来。3.2 启发式与元启发式大规模场景的现实选择当精确算法解不动的时候启发式就是救命稻草。我按简单到复杂的顺序介绍一下常用的几类。贪心Greedy每一步都选当前看起来最好的。实现最快但对很多问题解质量一般容易掉进局部最优。它的价值在于快速产出一个初始可行解给后续算法当起点。局部搜索Local Search从一个解出发通过邻域动作交换两件物品、调换两个班次、翻转某条边不断改进直到邻域里找不到更好的解。核心在于邻域设计邻域越大越容易跳出局部最优但每步代价也越高。模拟退火Simulated Annealing在局部搜索基础上允许以一定概率接受更差的解概率随温度下降而减小。它的妙处是前期敢乱走、后期逐渐收敛能有效跳出局部最优。参数主要就是初始温度、降温系数和迭代次数。禁忌搜索Tabu Search记录最近走过的动作放进禁忌表短期内禁止重复强制搜索跳出局部最优。禁忌表长度是关键参数设太短起不到作用设太长会限制探索。遗传算法GA模拟自然选择用种群、交叉、变异来迭代。适合解的表达天然是序列或集合的问题但参数多、调参麻烦且收敛速度不稳定。大邻域搜索LNS/ALNS先破坏移除一部分解再修复重新插入非常适合 VRP 这类路径问题OR-Tools 里的引导局部搜索就很接近这个思路。3.3 选型决策一张表帮你下判断选算法没有银弹但我有一套自己的判断流程供你参考判断维度倾向精确算法倾向启发式问题规模解空间 10^8解空间 10^8实时性要求离线批处理可等几分钟到几小时在线/秒级响应最优性要求必须最优如财务结算、合规接受近似最优模型结构松弛紧、约束少约束复杂、非线性多实现成本团队有运筹背景快速上线优先我的实际选择顺序通常是先试精确解卡住了再上元启发式。具体做法是先设一个短的求解时限比如 10 秒看精确求解器能推进到什么程度如果 gap 已经很小加大时限如果 gap 死活降不下来果断切启发式不要跟求解器硬耗。4. 实操用 Python 把一个组合优化问题跑通4.1 0-1 背包先手写动态规划打底理论说再多不如跑一遍。我们从最经典的 0-1 背包开始先手写 DP 理解状态转移。def knapsack_dp(weights, values, capacity): 0-1 背包动态规划求解 weights: 物品重量列表 values: 物品价值列表 capacity: 背包容量 n len(weights) # dp[i][c] 表示前 i 件物品、容量为 c 时能获得的最大价值 dp [[0] * (capacity 1) for _ in range(n 1)] for i in range(1, n 1): w, v weights[i - 1], values[i - 1] for c in range(capacity 1): if c w: # 装不下只能不拿 dp[i][c] dp[i - 1][c] else: # 拿或不拿取价值更大的 dp[i][c] max(dp[i - 1][c], dp[i - 1][c - w] v) return dp[n][capacity]这个实现的时间复杂度是 O(n × capacity)空间也是。当容量很大比如十万级时二维表会吃掉大量内存。用滚动数组可以把它压成一维def knapsack_dp_1d(weights, values, capacity): dp [0] * (capacity 1) for w, v in zip(weights, values): # 注意容量必须从大到小遍历保证每件物品只被用一次 for c in range(capacity, w - 1, -1): dp[c] max(dp[c], dp[c - w] v) return dp[capacity]注意一维 DP 里容量必须倒着遍历。正着遍历会让同一件物品被重复使用那就变成完全背包了。这个细节我当年面试被问过也踩过务必记牢。跑个例子验证一下weights [2, 3, 4, 5, 9] values [3, 4, 5, 8, 10] capacity 10 print(knapsack_dp(weights, values, capacity)) # 输出 15 print(knapsack_dp_1d(weights, values, capacity)) # 输出 15验证逻辑很简单选第 1、2、3、4 件物品重量 234514 超了选 1、2、4、5 重量 235919 也超。手工算下来选 1、2、3、4 中的前几件再加……这里最优组合重量刚好 10价值 15两个实现结果一致说明代码正确。4.2 用 OR-Tools 求解旅行商问题TSP 手写精确解会非常痛苦直接上 OR-Tools。下面是一个完整的可运行示例。from ortools.constraint_solver import routing_enums_pb2 from ortools.constraint_solver import pywrapcp def solve_tsp(distance_matrix): num_nodes len(distance_matrix) # 创建路由索引管理器节点数、车辆数(1)、起点(0) manager pywrapcp.RoutingIndexManager(num_nodes, 1, 0) routing pywrapcp.RoutingModel(manager) # 距离回调告诉求解器任意两点之间的距离 def distance_callback(from_index, to_index): from_node manager.IndexToNode(from_index) to_node manager.IndexToNode(to_index) return distance_matrix[from_node][to_node] transit_callback_index routing.RegisterTransitCallback(distance_callback) routing.SetArcCostEvaluatorOfAllVehicles(transit_callback_index) # 搜索参数 search_parameters pywrapcp.DefaultRoutingSearchParameters() # 初始解策略从最近邻开始 search_parameters.first_solution_strategy ( routing_enums_pb2.FirstSolutionStrategy.PATH_CHEAPEST_ARC) # 元启发式引导局部搜索兼顾质量和速度 search_parameters.local_search_metaheuristic ( routing_enums_pb2.LocalSearchMetaheuristic.GUIDED_LOCAL_SEARCH) # 求解时限 search_parameters.time_limit.seconds 10 solution routing.SolveWithParameters(search_parameters) if not solution: return None, None # 还原路径 route [] index routing.Start(0) while not routing.IsEnd(index): route.append(manager.IndexToNode(index)) index solution.Value(routing.NextVar(index)) route.append(manager.IndexToNode(index)) # 回到起点 return route, solution.ObjectiveValue() # 测试6 个城市的距离矩阵对称 dist [ [0, 10, 15, 20, 25, 30], [10, 0, 35, 25, 30, 20], [15, 35, 0, 30, 20, 25], [20, 25, 30, 0, 15, 35], [25, 30, 20, 15, 0, 10], [30, 20, 25, 35, 10, 0], ] route, cost solve_tsp(dist) print(路径:, route) print(总距离:, cost)这段代码有几个关键点值得说。PATH_CHEAPEST_ARC是初始解策略它决定从哪里出发构造第一条路线GUIDED_LOCAL_SEARCH是元启发式负责在初始解基础上不断改进。time_limit.seconds 10是硬性时间预算到点就返回当前最好解——这个机制很实用能让你的服务稳定在可控时间内响应。4.3 用 PuLP 做整数规划建模如果你要解的是带各种约束的分配问题直接写数学模型更清晰。PuLP 让建模像写数学公式一样直观。下面还是背包但用整数规划的方式表达。import pulp def knapsack_ilp(weights, values, capacity): n len(weights) # 最大化问题 prob pulp.LpProblem(knapsack, pulp.LpMaximize) # 0-1 决策变量 x [pulp.LpVariable(fx{i}, catBinary) for i in range(n)] # 目标函数 prob pulp.lpSum(values[i] * x[i] for i in range(n)) # 容量约束 prob pulp.lpSum(weights[i] * x[i] for i in range(n)) capacity # 求解关闭求解器日志 prob.solve(pulp.PULP_CBC_CMD(msgFalse)) chosen [i for i in range(n) if pulp.value(x[i]) 0.5] return chosen, pulp.value(prob.objective) weights [2, 3, 4, 5, 9] values [3, 4, 5, 8, 10] capacity 10 chosen, total knapsack_ilp(weights, values, capacity) print(选择的物品索引:, chosen) print(总价值:, total)用这种写法当你需要新增约束比如至少选两件某两件物品不能同时选时只需加一行prob 可维护性远高于手写算法。这也是我在工程中优先用建模语言的原因——需求是会变的模型比代码更容易改。4.4 结果验证与参数调整的实际做法求解完不代表结束验证环节特别关键。我一般做三件事第一手工小规模对照。构造一个 5 到 8 个元素的小案例用暴力枚举算出真实最优解跟求解器结果比对。这一步能抓出大部分建模错误比如约束写反、目标漏项。第二可行性校验。不管求解器信不信得过自己写个独立函数遍历解逐条检查约束是否满足。求解器偶尔会因为数值精度问题返回看起来可行其实违约的解尤其是系数差距很大的时候。第三稳定性测试。同样的输入改一下随机种子或者求解时限跑十次看结果分布。如果波动特别大说明算法没收敛需要加大时限或者换策略如果每次都一样说明可能过早收敛到局部最优得加扰动。参数调整上我通常按这个顺序动刀先调求解时限最直接再调初始解策略影响起点质量最后调元启发式类型和邻域参数。别一上来就盲目加迭代次数很多时候换个初始解策略比堆时间有效得多。5. 踩坑记录组合优化实操中的常见问题与排查5.1 模型层面的坑无解、松弛太松、对称性冗余模型无解是最让人抓狂的情况。求解器返回 infeasible但不告诉你错在哪。我的排查顺序是先把所有约束按是否可能冲突分组逐组暂时删掉看删到哪组就变可行了再用软约束思路把可疑的硬约束改成带惩罚的软约束看求解器返回的解违反哪条重点就浮出水面了。实战中无解的原因通常是约束之间互相矛盾比如每人每周最多工作 40 小时和每天至少 8 小时加上每周至少工作 6 天组合起来一周就是 48 小时直接自相矛盾。松弛太松是精确求解跑不动的元凶。判断方法很简单看线性松弛的目标值和当前最好整数解之间的差距gap。如果初始 gap 就超过 30%基本说明模型松弛质量差。改进手段包括用更紧的 big-M 值很多人生成约束时把 M 设成天文数字这是大忌、加有效的割平面、用变量上界替代大 M。对称性冗余前面提过。判断方法看求解器日志里的探索节点数异常多、但解质量提升很慢。处理手段是给同质资源加排序约束。这个技巧特别值我做过一个排班模型加了一条对称性破除约束后求解时间从 20 分钟降到 40 秒。5.2 求解层面的坑内存、超时与解不稳定内存爆炸常见于 DP 和暴力搜索。预防办法是提前估算状态数超过一亿就换思路。我吃过一次亏写了个全排列搜索n12 的时候还跑得动n13 就直接 MemoryError 了。教训就是——永远先算规模再写代码。超时无解发生在设置时限太短或者模型太大时。处理方式设一个合理的时限工程上通常 5 到 60 秒并确保求解器在到点前至少给出一个可行解。有些求解器如果连初始可行解都没找到就超时会返回空结果所以给一个初始可行解很有必要哪怕它是贪心随便构造的。解不稳定指同样输入多次运行结果差异大。元启发式天然有随机性处理办法是固定随机种子可复现、增加多次运行取最优、或者用确定性更强的策略。生产环境我建议固定种子不然出了问题没法复现排查。5.3 常见问题速查表把上面这些经验整理成表方便你遇到问题时对号入座。现象可能原因排查/解决求解器返回 infeasible约束冲突分组删除法定位检查大 M 和阈值设置求解时间极长松弛太松、模型太大收紧 big-M、加割平面、缩减变量规模探索节点数异常多对称性冗余加同质资源排序约束破除对称DP 内存溢出状态空间膨胀滚动数组、稀疏存储、降维解质量波动大元启发式未收敛固定种子、加大时限、增加多次运行求解器返回解违反约束数值精度问题独立函数校验缩放系数到相近量级大规模问题精确解跑不动NP-hard 本质果断切启发式/近似算法提示排查问题时我习惯把整个过程缩放成一个能秒开的小案例。很多 bug 在小规模下一目了然大规模下反而被各种噪声掩盖。缩放到最小可复现案例是排查组合优化问题最高效的招数。5.4 我的几条实操心得最后说几句掏心窝的经验。第一先想清楚业务能接受什么再决定投入多少算力。我见过团队花几周优化一个 2% 的成本差异结果业务方根本不在乎这 2%他们真正在意的是方案能不能解释、能不能手动微调。第二约束宁少勿滥。每加一条约束都要问它是不是真的硬性很多应该满足的规则做成软惩罚更实际。第三给模型留人工干预的口子。纯黑盒的最优解在现场往往没法执行能锁定部分变量、能手动调整的方案落地率反而高得多。关于这个主题后续还能往下挖的方向我觉得有三个值得深入一是把组合优化和机器学习结合用历史数据学习邻域选择策略或初始解构造二是针对具体行业做约束标准化封装把排班、配送、装箱这些高频场景的通用约束做成可配置模块三是在线优化的场景数据实时变化时怎么增量重解而不是每次从头算。这些我后续会继续整理先把这一篇的基础打扎实再说。要是你在实操中遇到什么特别的坑欢迎一起交流——组合优化这东西很多妙招都是在实战里被逼出来的。