组合优化这五个字,第一次让我真正重视起来,是在做一套排班系统的时候。当时业务方只提了一个要求:"把每个人每天的班排得尽量公平,还要满足一堆硬性规则。"我第一反应是写个贪心脚本按顺序分配,结果上线第一周就被打回来了——有几天某个时段直接没人,有几天同一个人连着上了三个夜班。问题不在代码写得烂,而在于我压根没意识到:这本质上是一个组合优化问题,选项是离散的、约束是硬性的、目标是全局最优而不是局部看起来不错。这篇文章就把"组合优化问题"这个主题掰开揉碎,从它到底在解什么、怎么建模、算法怎么选,一直讲到用 Python 真刀真枪跑通一个例子,把我自己踩过的坑和总结的技巧都放进来。不管你是刚接触运筹优化的学生、转行做算法工程的开发者,还是需要给业务排产排班做决策的技术负责人,都能从里面找到能直接上手的东西。
1. 组合优化问题的本质:从离散选择里挑出全局最优
1.1 它和连续优化到底差在哪
先把概念钉死。组合优化(Combinatorial Optimization)解决的是这样一类问题:可行解是有限个、离散的组合,我们要在这些组合里找到让目标函数最大或最小的那一个。注意两个关键词——"有限"和"离散"。
这跟连续优化完全不是一回事。连续优化比如求一个凸函数的最小值,变量可以在实数区间里任意取值,靠梯度下降、牛顿法这类基于导数的方法就能收敛。而组合优化里变量是"选或不选""排第几位""走哪条边",你没法对一个"选或不选"求导。这个差异直接导致了方法体系的分道扬镳:连续优化靠微积分,组合优化靠枚举、剪枝、松弛和搜索。
举个最直观的例子。0-1 背包问题:有 n 件物品,每件有重量和价值,背包容量固定,问怎么选价值最大。可行解的数量是 2 的 n 次方。n=10 的时候是 1024 种,随手就能枚举;n=50 的时候是大约 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 个员工被分到某班,则第 i+1 个不能更早被分到),或者直接对同质资源做聚合再展开。
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 解决带容量和时间双重约束的问题,n=200 的时候状态数就上亿了,直接溢出。后来改成滚动数组 + 稀疏存储才压下来。
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 件物品,重量 2+3+4+5=14 超了;选 1、2、4、5 重量 2+3+5+9=19 也超。手工算下来选 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(f"x{i}", cat="Binary") 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(msg=False)) 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 和暴力搜索。预防办法是提前估算状态数,超过一亿就换思路。我吃过一次亏,写了个全排列搜索,n=12 的时候还跑得动,n=13 就直接 MemoryError 了。教训就是——永远先算规模再写代码。
超时无解发生在设置时限太短或者模型太大时。处理方式:设一个合理的时限(工程上通常 5 到 60 秒),并确保求解器在到点前至少给出一个可行解。有些求解器如果连初始可行解都没找到就超时,会返回空结果,所以给一个初始可行解很有必要,哪怕它是贪心随便构造的。
解不稳定指同样输入多次运行结果差异大。元启发式天然有随机性,处理办法是固定随机种子(可复现)、增加多次运行取最优、或者用确定性更强的策略。生产环境我建议固定种子,不然出了问题没法复现排查。
5.3 常见问题速查表
把上面这些经验整理成表,方便你遇到问题时对号入座。
| 现象 | 可能原因 | 排查/解决 |
|---|---|---|
| 求解器返回 infeasible | 约束冲突 | 分组删除法定位,检查大 M 和阈值设置 |
| 求解时间极长 | 松弛太松、模型太大 | 收紧 big-M、加割平面、缩减变量规模 |
| 探索节点数异常多 | 对称性冗余 | 加同质资源排序约束破除对称 |
| DP 内存溢出 | 状态空间膨胀 | 滚动数组、稀疏存储、降维 |
| 解质量波动大 | 元启发式未收敛 | 固定种子、加大时限、增加多次运行 |
| 求解器返回解违反约束 | 数值精度问题 | 独立函数校验,缩放系数到相近量级 |
| 大规模问题精确解跑不动 | NP-hard 本质 | 果断切启发式/近似算法 |
提示:排查问题时,我习惯把整个过程缩放成一个能秒开的小案例。很多 bug 在小规模下一目了然,大规模下反而被各种噪声掩盖。缩放到最小可复现案例,是排查组合优化问题最高效的招数。
5.4 我的几条实操心得
最后说几句掏心窝的经验。第一,先想清楚业务能接受什么,再决定投入多少算力。我见过团队花几周优化一个 2% 的成本差异,结果业务方根本不在乎这 2%,他们真正在意的是方案能不能解释、能不能手动微调。第二,约束宁少勿滥。每加一条约束都要问它是不是真的硬性,很多"应该满足"的规则做成软惩罚更实际。第三,给模型留人工干预的口子。纯黑盒的最优解在现场往往没法执行,能锁定部分变量、能手动调整的方案,落地率反而高得多。
关于这个主题后续还能往下挖的方向,我觉得有三个值得深入:一是把组合优化和机器学习结合,用历史数据学习邻域选择策略或初始解构造;二是针对具体行业做约束标准化封装,把排班、配送、装箱这些高频场景的通用约束做成可配置模块;三是在线优化的场景,数据实时变化时怎么增量重解,而不是每次从头算。这些我后续会继续整理,先把这一篇的基础打扎实再说。要是你在实操中遇到什么特别的坑,欢迎一起交流——组合优化这东西,很多妙招都是在实战里被逼出来的。