简介:这份资源围绕三维装箱问题(3D Bin Packing Problem)展开,面向物流、仓储与制造业中从事空间优化、组合优化研究的学生、工程师及算法爱好者,重点解决如何在有限箱体内以最小空间浪费装载不同尺寸物品的难题。包内共12个文件,以7个m脚本为核心,配合2个ps图形文件、1个cargo与1个box数据文件及1个xlsx表格,压缩包约26KB,覆盖遗传算法与模拟退火两种求解思路的代码实现与结果可视化。已有2292人学习下载,说明其在同类算法实践中具有一定参考价值。读者可从中获取完整的装箱方案代码框架、适应度评估与布局变换逻辑、退火与遗传策略的对比实现,以及将装箱结果图形化呈现的脚本,便于理解算法运行过程、评估方案效果并调整参数设置,适合作为课程设计、算法实验或相关课题的起步参考。
1. 三维装箱问题遇上退火算法:一个让物流成本直降的硬核组合
电商仓库里,一个托盘上堆着几十种尺寸各异的纸箱,怎么码放才能让托盘利用率从 70% 拉到 90% 以上?这就是三维装箱问题(3D Bin Packing Problem)的日常。它属于 NP-hard 组合优化,箱子数量一多,穷举直接爆炸。退火算法(Simulated Annealing)因为能跳出局部最优、实现相对简单,成了这个领域最常被拿来落地的启发式方法之一。这篇笔记面向两类人:一是想把三维装箱算法真正跑进自己业务系统的后端/算法工程师,二是被“装箱率上不去、运费下不来”折磨的物流技术负责人。我会从建模讲到代码,从参数讲到踩坑,把 Boxing Problem 这条链路走通。
2. 三维装箱问题怎么建模:从物理约束到数学表达
2.1 装箱问题的三个核心约束
三维装箱不是把数字塞进数组那么简单,它有三层硬约束。第一层是几何约束:箱子不能重叠,必须完全落在容器内部。第二层是方向约束:一个纸箱允许 6 种朝向(长宽高全排列),但实际业务里“此面朝上”的标签会砍掉一部分。第三层是支撑约束:箱子不能悬空,底部必须有足够面积的支撑面,否则堆到第三层就塌了。
把这三层翻译成数学语言,目标函数通常是最大化体积利用率:
maximize Σ(vi · xi) / V其中 vi 是第 i 个箱子的体积,xi 是 0/1 决策变量,V 是容器体积。约束条件包括:任意两个已放置箱子的包围盒不相交、每个箱子坐标加尺寸不超过容器边界、支撑面积比例不低于阈值(常见取 0.7~0.8)。
实际工程里很少有人直接解这个整数规划,因为变量数随箱子数平方增长。常见做法是把它转成“放置序列 + 方向序列”的排列优化问题,用启发式搜索去逼近。
2.2 为什么选退火而不是遗传或贪心
贪心算法(比如按体积降序首次适应)跑得快,但遇到“大箱先放导致小箱无处可去”的局面就翻车,利用率波动极大。遗传算法种群大、收敛慢,调参成本高。退火算法的优势在于:单点搜索、内存占用小、通过温度参数控制“接受劣解”的概率,能在早期大胆探索、后期精细收敛。
我一般会这样对比选型:
| 方法 | 实现难度 | 调参成本 | 适合场景 |
|---|---|---|---|
| 贪心/首次适应 | 低 | 极低 | 实时性要求高、箱子种类少 |
| 遗传算法 | 中 | 高 | 离线批量优化、算力充足 |
| 退火算法 | 中 | 中 | 单容器优化、需要平衡质量和速度 |
| 整数规划求解器 | 高 | 低 | 箱子数少于 20 的小规模精确解 |
退火的核心公式是 Metropolis 准则:当前解变差时,以 exp(-ΔE/T) 的概率接受。T 高时几乎全接受,T 低时几乎只接受更优解。这个机制让它在装箱这种“地形复杂”的问题上比贪心稳得多。
2.3 状态表示与邻域动作设计
退火要跑起来,先得定义“一个解长什么样”。我常用的是序列编码:把每个箱子编号,解就是一个放置顺序数组,外加每个箱子的朝向编码。比如[3, 1, 5, 2, 4]表示先放 3 号箱,再放 1 号箱,依此类推。
邻域动作决定搜索能力,常见三种:
- 交换:随机选两个位置互换,适合打散顺序。
- 插入:把一个箱子抽出插到另一个位置,改变局部结构。
- 翻转:改变某个箱子的朝向编码,探索方向空间。
只做交换容易陷入“换汤不换药”,我通常把交换和翻转按 7:3 混合,让顺序和方向同时被扰动。
3. 退火算法跑通三维装箱的最小实现
3.1 环境准备与数据结构定义
用 Python 就能跑通最小版本,依赖只有 numpy。先定义箱子和容器的数据结构:
import numpy as np import random import math class Box: def __init__(self, idx, l, w, h): self.idx = idx self.dims = (l, w, h) # 原始尺寸 self.orientation = 0 # 0-5 六种朝向 self.position = None # 放置后的左下后角坐标 class Container: def __init__(self, L, W, H): self.L, self.W, self.H = L, W, H self.placed = [] # 已放置箱子列表orientation用 0 到 5 表示长宽高的六种排列,position是放置后计算出来的坐标。容器只记录尺寸和已放置列表,不预先划分网格——网格法会浪费空间,也限制了大箱子的摆放。
3.2 放置解码:从序列到实际坐标
给定一个放置顺序和朝向,解码器负责算出每个箱子的坐标。我用的是“极点候选法”的简化版:每个已放置箱子的右、上、前三个面延伸出去,与容器边界和其他箱子求交,得到候选放置点,选第一个能放下的。
def get_orientations(box): l, w, h = box.dims return [(l,w,h),(l,h,w),(w,l,h),(w,h,l),(h,l,w),(h,w,l)] def try_place(container, box, orient_idx): dims = get_orientations(box)[orient_idx] dx, dy, dz = dims # 候选点:原点 + 每个已放置箱子的三个延伸角 candidates = [(0, 0, 0)] for p in container.placed: px, py, pz = p.position pl, pw, ph = get_orientations(p)[p.orientation] candidates.append((px + pl, py, pz)) candidates.append((px, py + pw, pz)) candidates.append((px, py, pz + ph)) for (x, y, z) in candidates: if x + dx > container.L or y + dy > container.W or z + dz > container.H: continue if not overlaps(container, x, y, z, dx, dy, dz): return (x, y, z) return Noneoverlaps做包围盒相交检测,try_place返回第一个可行坐标或 None。候选点只取三个延伸方向是为了控制计算量,实际项目里可以加上“角点组合”提高紧凑度。
3.3 退火主循环与能量函数
能量函数直接取“未放置箱子的总体积”,越小越好。这样退火会自动往“能塞更多”的方向走。
def energy(container, boxes, sequence, orientations): container.placed = [] unplaced_vol = 0 for i, idx in enumerate(sequence): box = boxes[idx] box.orientation = orientations[i] pos = try_place(container, box, box.orientation) if pos: box.position = pos container.placed.append(box) else: unplaced_vol += box.dims[0] * box.dims[1] * box.dims[2] return unplaced_vol def anneal(boxes, container, iters=5000, T0=100.0, T_end=0.1, alpha=0.995): n = len(boxes) seq = list(range(n)) random.shuffle(seq) ori = [random.randint(0, 5) for _ in range(n)] cur_e = energy(container, boxes, seq, ori) best_seq, best_ori, best_e = seq[:], ori[:], cur_e T = T0 for it in range(iters): new_seq, new_ori = seq[:], ori[:] if random.random() < 0.7: i, j = random.sample(range(n), 2) new_seq[i], new_seq[j] = new_seq[j], new_seq[i] else: k = random.randrange(n) new_ori[k] = random.randint(0, 5) new_e = energy(container, boxes, new_seq, new_ori) if new_e < cur_e or random.random() < math.exp(-(new_e - cur_e) / T): seq, ori, cur_e = new_seq, new_ori, new_e if new_e < best_e: best_seq, best_ori, best_e = new_seq[:], new_ori[:], new_e T = max(T * alpha, T_end) return best_seq, best_ori, best_eT0是初始温度,alpha是降温系数,T_end是终止温度。5000 次迭代、0.995 的降温系数大约对应 1500 次有效降温,对 30 个箱子以内的规模够用。箱子再多就要把迭代数提到 2 万以上,或者改用自适应降温。
3.4 参数怎么调:温度、降温率、迭代数的联动
这三个参数不是独立的。初始温度要设得让“最差邻域解”也有 50% 以上接受率,经验公式是T0 ≈ 平均能量差 / ln(0.5)。降温率 alpha 越小降温越快,容易早熟;越大越慢,算力吃得越多。我一般按规模给:
| 箱子数 | 迭代数 | T0 | alpha |
|---|---|---|---|
| < 20 | 3000 | 50 | 0.99 |
| 20~50 | 10000 | 100 | 0.995 |
| 50~100 | 30000 | 200 | 0.997 |
迭代数不够时,退火还没收敛就停了,结果和随机差不多。判断是否收敛可以看 best_e 最近 1000 次有没有变化,没变化就可以提前退出。
4. 三维装箱退火落地的避坑与排查
4.1 现象:利用率忽高忽低,同一批箱子跑两次差 15%
原因:随机种子没固定,且邻域动作里交换和翻转的比例不合适,导致搜索路径方差大。解决:固定random.seed(42)做基准测试,同时把交换比例从 0.7 调到 0.5,增加翻转和插入动作,让搜索更均匀。生产环境可以跑 5 次取最优,用时间换稳定性。
4.2 现象:退火跑完还有一半箱子没放进去
原因:能量函数只惩罚未放置体积,但没有惩罚“放置顺序里大箱被排到后面”。大箱后放时容器已被小箱切碎,放不下。解决:在初始序列里按体积降序排一遍作为退火起点,同时在邻域动作里加一个“大箱前移”的定向扰动,让大箱有更大概率早放。
4.3 现象:箱子坐标算出来重叠,可视化一看穿模了
原因:overlaps函数只比较了包围盒的严格相交,没处理“面贴面”的边界情况,浮点数误差导致x + dx == px被判成相交。解决:在比较时加一个 1e-6 的容差,x + dx <= px + 1e-6视为不相交。另外坐标统一用整数毫米,避免浮点累积误差。
4.4 现象:支撑约束没生效,堆到第三层箱子悬空
原因:try_place只检查了包围盒不重叠,没检查底部支撑面积。解决:在放置前计算候选位置下方与已放置箱子顶面的交集面积,要求支撑面积占底面积比例不低于 0.75。这个检查放在overlaps之后、返回坐标之前,不满足就试下一个候选点。
4.5 现象:箱子数量到 80 以上时,单次退火要跑好几分钟
原因:每次邻域动作都全量重算能量,复杂度 O(n²)。解决:用增量计算——交换两个箱子只影响它们之后的放置,缓存前缀的容器状态,只重算受影响的后缀。实测 100 个箱子能把单次迭代从 80ms 降到 12ms。另一个办法是把退火和贪心结合:退火只优化前 30% 的序列,后面用贪心填充。
5. 让退火装箱真正上生产的两个进阶技巧
第一个技巧是分层退火。把容器沿高度切成若干层,每层独立跑退火,层与层之间用“层高”作为外层退火的决策变量。这样问题规模从三维降到二维加一维,100 个箱子的求解时间能从分钟级压到 10 秒以内。代价是牺牲一点全局最优性,但实际业务里 2% 的利用率损失换 10 倍速度,账算得过来。
第二个技巧是用真实数据校准能量函数。纯体积利用率在业务里不够——重箱不能压轻箱、易碎品要单独区域。我一般会在能量函数里加惩罚项:重箱在轻箱上方加罚、易碎品周围留空加罚。惩罚系数从 0.1 开始试,太大退火会只盯着约束忽略利用率,太小约束形同虚设。
验证方法上,别只看最终利用率。我会记录退火过程中 best_e 的下降曲线,如果曲线在 30% 迭代数处就平了,说明降温太快或邻域动作不够;如果曲线一直抖到结束,说明 T_end 设高了,最后阶段还在乱跳。这条曲线是调参的黑匣子,比最终数字有用得多。
说个我自己的教训:早期做装箱项目时,我花了两周调退火参数,利用率从 82% 提到 85%,结果业务方说“箱子方向不能随便转,有条码面必须朝外”。加上方向约束后利用率掉回 78%,但方案能落地了。后来我养成习惯,先花半天把业务硬约束全部列出来写进能量函数,再开始调参。参数调得再漂亮,约束漏一个就是白干。希望帮到你。
本文还有配套的精品资源,点击获取