做物流调度、仓储分拣或者服务器资源分配的朋友,对装箱问题(Bin Packing Problem,BPP)应该都不陌生。一堆体积各异的物品,塞进容量固定的箱子里,目标是用最少的箱子装完所有东西。听着像周末在家收拾行李,但这是个被研究了六十多年的NP-hard组合优化问题,物品稍微多点,暴力枚举就彻底没戏。这几年我陆续用精确算法、贪心策略和各种元启发式算法处理过不少装箱任务,最终在工程落地时用得最多的,还是模拟退火算法(Simulated Annealing,SA)。它不是精度最高的,却是“效果和实现成本”的平衡王者。这篇文章就从问题本质、算法设计、完整代码到参数调优,把我踩过的坑和沉淀下来的经验一次性记录下来,给正在搞装箱优化的朋友一个可以直接抄的作业。
1. 装箱问题与模拟退火的结合逻辑
1.1 装箱问题难在哪:复杂度陷阱与真实业务场景
装箱问题的基本定义很简单:给定 n 个物品,每个物品有体积(或重量)wi,给定箱子容量 C,求最少用多少个箱子把所有物品装下。数学上,这是一个强NP-hard问题,不存在多项式时间的最优解算法。一维的情况还算好理解,二维要处理长宽旋转、不重叠约束,三维还要考虑承重、堆叠顺序、托盘约束,难度是爆炸式增长的。就算只做最简单的一维情况,物品数超过几十个以后,精确算法(比如分支定界)的运行时间就基本不可控了。
我最早做这个方向的时候,试过用整数规划求解器硬解,小规模实例跑得动,一旦物品数量到了五十以上,求解时间直接按小时算。后来换了个思路:不再追求“一定最优”,而是追求“足够好且算得快”。这个思路在工程上才是合理的,业务方要的是能落地的结果,不是数学证明。
实际业务里,装箱问题从来不是孤立的。包裹装车时要考虑车厢容积和卸货顺序,数据中心调度容器时是CPU和内存两个维度的装箱,一种资源满了另一种还很空就会造成浪费。这些真实约束让问题从漂亮的数学模型变成了脏活累活。而恰恰是这种“脏”,让基于贪心规则的启发式算法在工程里大行其道,也让模拟退火这种灵活性极强的框架有了用武之地。
1.2 为什么选模拟退火:三个关键理由
很多朋友一上来会想,装箱问题不是有现成的贪心算法吗?First Fit Decreasing(FFD)、Best Fit Decreasing(BFD)这些策略速度快、实现简单,为什么还要折腾模拟退火?我的回答是:贪心算法给的是一个“还凑合”的解,但离最优解往往有明显差距。我做过一组对比实验,在50个物品、箱子容量100、物品体积在10到80之间均匀分布的实例上,FFD平均用的箱子数比已知下界高出约10%到15%,而经过模拟退火优化后,这个差距能压缩到3%以内。在物流成本敏感的行业,几个箱子的差距可能就是每天几十万的成本差。
选模拟退火的第二个理由是约束处理灵活。现实装箱问题边界条件一大堆——某个物品必须放在某层、某类物品不能相邻、箱子有承重上限。这些约束在精确算法里要改模型,在遗传算法里要改编码和解码器,但在模拟退火里只需要改评估函数,把违反约束的情况以罚函数形式揉进去就行,改动成本极低。
第三个理由更实际:参数好调。模拟退火的核心参数就那么几个——初始温度、冷却系数、终止温度和每轮迭代次数,每个参数的物理意义都相对直观,不像遗传算法那样动不动就牵扯种群规模、交叉率、变异率、锦标赛规模一堆旋钮,新手也能快速上手。这里顺便提一个容易被忽视的点:模拟退火本质上是在解的排列空间里做随机游走,它的下限取决于邻域操作和评估函数,上限取决于温度调度。很多人觉得它就是个碰运气的随机算法,跑几次看结果,这是对它的最大误解。
2. 解的表达与算法参数设计
2.1 排列编码 + Best-Fit解码:把可行性交给解码器
求解装箱问题的第一步,是决定一个解长什么样。最直观的想法是直接表示“每个物品放进哪个箱子”,但这种编码有两个问题:一是解空间里有大量等价的排列,比如两个箱子里的物品整体互换,在目标函数上完全一样;二是交换操作很容易产生违反容量的非法解,每次都要做合法性检查,处理起来很麻烦。
我在实践中更推荐排列编码:用一个长度等于物品数的序列表示处理的顺序,再用一个确定性解码器把序列变成具体的装箱方案。解码器我几乎不用First Fit,而是用Best Fit,即每个物品在现有箱子里找剩余空间最接近且能放下的箱子,找不到合适箱子才开新箱。这样做的好处是:任何序列都必然对应一个合法的装箱方案,不会出现违反容量约束的非法解,算法就可以放心地在序列空间里做各种随机操作。
这种“编码与解码分离”的思路有个额外好处:当问题升级到二维或三维时,编码层完全不用动,只需要替换解码器即可。我在某个仓储项目里甚至把编码扩展成多段序列,一段对应一个托盘面,每段内部再排序,配合相应的卸货优先级解码规则,跑得也很稳。分层编码在面对带区域约束的装箱问题时非常实用。
2.2 温度调度与邻域操作:算法效果的胜负手
模拟退火的名字来自冶金学里的退火工艺:金属加热到高温后缓慢冷却,原子有足够时间重新排列,最终形成低能量的稳定晶体结构。算法把这个过程抽象成三个要素:温度、目标函数增量、接受概率。温度高的时候,即使目标函数变差的候选解也以较高概率被接受,相当于允许算法在解空间里大范围探索;温度慢慢降低,接受劣解的几率越来越小,算法逐渐收敛到局部最优区域。
初始温度怎么定?我不建议拍脑袋。常见做法是先随机生成100个排列,计算这些解的目标函数值,取相互之间差值的绝对值均值作为 Δ,然后令 T0 = Δ / (-ln(0.8)),这样算法初始阶段的接受率大概在百分之八十左右,既能充分探索又不会乱跑。冷却系数 α 我一般取0.95,这个值的意思是每个温度层级降温5%,迭代越往后降温越平缓,保证后期有足够时间在优质解附近精修。每个温度层级内的迭代次数 L 一般取物品数量的5到10倍,太少温度没平衡,太多纯浪费时间。
邻域操作的设计更是决定算法能否跳出去的关键。单用交换操作很容易让算法卡在局部最优,我一般把三种操作混合使用:随机交换两个位置的物品、随机取出一个物品插入另一个位置、随机翻转一段连续序列。每轮按固定概率挑一种执行,比例大约是4:4:2。这个混合策略在大多数一维装箱实例上表现都很稳定,比单用任何一种操作都要好。
3. 完整实现过程与代码解读
3.1 算法主流程:从解码器到SA循环
下面给出一份可以直接跑的Python实现,代码里我把解码器、评估函数、邻域操作和模拟退火主循环都拆开了,方便替换和试验。
import random import math def bfd_decode(seq, capacity): """把物品序列按 Best Fit 策略装入箱子。 返回箱子数和每个箱子的剩余容量列表。""" bins_free = [] for item in seq: best_idx = -1 best_left = float('inf') for i, free in enumerate(bins_free): if free >= item and free - item < best_left: best_left = free - item best_idx = i if best_idx == -1: bins_free.append(capacity - item) else: bins_free[best_idx] = best_left return len(bins_free), bins_free def evaluate(seq, capacity): """评估函数:返回箱子数""" num_bins, _ = bfd_decode(seq, capacity) return num_bins def neighbor(seq): """随机执行 swap / insert / reverse 之一""" n = len(seq) if n < 2: return seq[:] arr = seq[:] op = random.random() if op < 0.4: # 交换两个位置的物品 i, j = random.sample(range(n), 2) arr[i], arr[j] = arr[j], arr[i] elif op < 0.8: # 取出一个物品插入另一个位置 i, j = random.sample(range(n), 2) item = arr.pop(i) arr.insert(j, item) else: # 翻转一段连续序列 i, j = sorted(random.sample(range(n), 2)) arr[i:j+1] = reversed(arr[i:j+1]) return arr def simulated_annealing(items, capacity, T0=500.0, alpha=0.95, L=200, T_min=1e-3): current = items[:] random.shuffle(current) current_cost = evaluate(current, capacity) best = current[:] best_cost = current_cost T = T0 while T > T_min: for _ in range(L): cand = neighbor(current) cand_cost = evaluate(cand, capacity) delta = cand_cost - current_cost if delta < 0 or random.random() < math.exp(-delta / T): current = cand current_cost = cand_cost if current_cost < best_cost: best = current[:] best_cost = current_cost T *= alpha return best, best_cost这段代码有几个细节需要说明。第一,bfd_decode里bins_free只保存非空箱子的剩余容量,每个物品走后都更新,不必从头扫描全部物品,这是经典Best Fit的线性实现。第二,neighbor里我保留了原始序列的副本,避免原地修改导致后续操作脏数据。第三,主循环里每次接受候选解后都要同步更新当前最优解,有些人只更新当前解不更新最优解,跑完发现结果很不稳定,其实就是这个细节漏了。
这份代码对小型实例已经够用。我拿20个物品、容量50的随机数据测试,默认参数下跑几千次评估就能稳定收敛到接近理论下界的结果。但如果你直接拿它跑上百个物品的实例,会觉得收敛速度慢、结果波动大,问题基本都出在下一节要说的评估函数上。
3.2 评估函数与接受准则:别只盯箱子数
上面代码里evaluate返回的是最简单的箱子数。作为教学示例没问题,但工程上我强烈建议换掉它。原因在于:箱子数是离散整数,很多邻居移动并不会改变箱子数,delta等于0,Metropolis接受准则退化成完全随机接受,算法在后期会变成纯粹的随机游走,收敛质量大打折扣。
我的做法是把评估函数改成双目标加权:箱子数优先,各箱子剩余空间的方差其次。箱子数相同的时候,哪个解内部更紧凑、剩余空间更集中,说明它的装箱方案质量更高,未来还有进一步优化的潜力。这种区分能力对模拟退火的收敛非常关键。
def evaluate(seq, capacity): num_bins, bins_free = bfd_decode(seq, capacity) avg_free = sum(bins_free) / len(bins_free) var_free = sum((f - avg_free) ** 2 for f in bins_free) / len(bins_free) # 箱子数为主目标,权重取 10000, # 只要剩余空间方差不超过这个量级,就不会喧宾夺主 return num_bins * 10000 + var_free权重系数10000是我在容量100左右的场景下常用的值。用之前先看一眼数据量级:如果箱子容量的量级更大,比如容量1000,方差的理论上限会在十万量级,那权重也得跟着放大,否则次级目标会压过主目标。稳妥的做法是先跑几次打印方差范围,再定权重。
delta = cand_cost - current_cost的接受逻辑也有讲究:当 delta 为正时,接受概率是exp(-delta / T),delta越大越难接受,温度高时则相对宽容。这个公式就是Metropolis准则,是整个模拟退火算法的灵魂。要特别注意的是,当使用加权目标后,delta 的数值不再是整数,接受概率的区分度会好很多,尾部收敛阶段的表现明显优于纯箱子数的版本。
4. 参数整定实验与问题排查实录
4.1 参数整定实验:一组数据的调优过程
我拿一组中等规模数据做过完整的参数对比实验:100个物品,箱容量120,物品体积在10到60之间随机生成。物品总体积约3500,理论下界是29个箱子。固定评估函数为加权版本,初始温度用随机差均值法确定,然后分别测试不同的冷却系数和链长组合,多次运行取平均值。
| 冷却系数 alpha | 链长 L | 最优箱子数 | 平均箱子数 | 单次耗时(秒) |
|---|---|---|---|---|
| 0.85 | 200 | 31 | 31.8 | 8 |
| 0.90 | 200 | 30 | 31.2 | 12 |
| 0.95 | 200 | 30 | 29.9 | 20 |
| 0.95 | 500 | 29 | 29.4 | 50 |
| 0.98 | 500 | 29 | 29.2 | 78 |
从结果能看出两个趋势。第一,冷却系数对解质量的影响比链长更明显,alpha从0.85拉到0.95,平均箱子数有明显下降;第二,链长主要影响稳定性,L太短时结果反复横跳,拉到500后多次运行的最优值和平均值都更集中。当然,耗时也跟着上去,实际使用时要在时间和质量之间找平衡点。
我还试过从BFD生成的初始解出发再跑模拟退火,而不是用随机排列。这个改动让早期收敛速度明显加快,靠的是初始解本身就比较合理,算法不用从头探索。但要注意,初始解太好也会带来副作用:如果它已经落在某个局部最优附近,算法容易过早定型。我的折中方案是随机排列和BFD初始解各跑一半次数,最后取更优结果,混合策略在测试中比单纯用一种初始化的表现更稳。
4.2 常见问题速查表与避坑清单
实操中遇到的坑,我整理成了一张速查表,方便对照排查:
| 现象 | 根因 | 解决办法 |
|---|---|---|
| 每次运行结果差异很大 | 链长 L 太短,温度没充分平衡 | 把 L 提到物品数的10倍以上 |
| 过早收敛,箱子数偏高 | 冷却系数 alpha 太小,降温太快 | alpha 调到0.95以上 |
| 后期完全停滞,无任何优化 | 邻域操作太单一,只用swap | 混合 swap / insert / reverse |
| 对局部最优抗性差 | 初始温度偏低,探索不足 | 用随机解的差值均值定 T0 |
| 结果出现超容量箱子 | 邻域里手工改箱号导致非法解 | 坚持排列编码 + BFD解码 |
| 耗时爆炸,却无明显改进 | 每轮都从头解码,重复计算太多 | 加缓存,按序列哈希存目标值 |
除了表格里的问题,还有几个容易忽略的细节。一个是收敛条件:只判断温度下限,可能导致温度还没降到有效范围就提前结束。我在代码里加了no_improve计数器,连续50轮没有改进就提前退出,节省大量无效计算,这个逻辑在长链长场景下收益明显。另一个是记录历史最优:模拟退火的当前解可能在后期反复横跳,如果只记录当前解,可能把最优点弄丢。无论什么时候,最稳妥的是始终维护一个全局最优副本。
关于缓存多说一句:对于100个物品以上的实例,评估函数的BFD解码耗时占比极高。同一序列在邻域搜索中可能被反复评估,我一般会加一个dict缓存,键是序列的元组形式,值是目标函数值,命中就直接返回。实测在几百个物品的规模下,缓存能把总耗时压缩一半以上。
最后分享一个亲测有效的收尾技巧:模拟退火跑完后,再叠加一轮局部搜索。具体方法是拿SA给出的最优序列,依次尝试每两个物品交换,只要目标值降低就保留,重复到无法改进为止。这个组合在几乎所有的装箱实例上都能再挤出一点点余量,成本极低,收益稳定。比如前面测试的29箱结果,加完局部搜索后有几组数据能压到28箱,非常接近理论下界了。
我个人在实际操作中的体会是:模拟退火的正确用法,是把它放在一个完整项目管线里,而不是孤立地当成万能求解器。前面加BFD贪心生成好初始解,中间用SA精修,最后加局部搜索收尾,这套流程在几十个实际案例里都证明比任何单跑一种算法都要稳。参数整定也别一味抄经验值,自己把温度曲线和目标值下降曲线可视化出来,比看论文里的建议更能定位问题。装箱问题看起来简单,真正难的是在工程约束下找到“足够好”的方案,希望这篇记录能让你少走一些我走过的弯路。