简介:在物流装载、仓储码放、集装箱配载等场景中,如何将不同尺寸的矩形物品高效放入有限容器,是典型的3D箱包装难题。这份C#实现的三维容器包装库为开发者提供了可直接使用的求解方案,核心基于EB-AFIT启发式算法(源自美国空军技术学院2001年硕士论文),支持物品整体旋转,兼顾运行速度与空间利用率,在运输与仓储优化领域有实际参考价值。资源共42个文件、约455KB,其中16个cs源文件构成算法与容器管理的核心实现,配套csproj、sln工程文件便于直接编译运行;另含演示应用、单元测试项目、README与LICENSE文档,以及少量前端展示辅助文件,整体结构清晰,适合二次开发。已有469人学习浏览。无论你是需要解决装箱优化问题的C#开发人员,还是想理解启发式装箱算法的学习者,都能从中获得完整源码、算法对照实现与可扩展的工程骨架。
1. 3DContainerPacking:把三维装箱问题变成一行调用的 C# 库
三维装箱(3D Bin Packing)不像排序算法那样人人写过,但它在仓储和物流系统里几乎天天出现:一批纸箱要放进车厢,怎么放能最少用车;一批货要装进周转箱,怎么摆才不会溢出。3DContainerPacking 正是面向这个场景的 C# 类库,它把车厢抽象成容器,把货品抽象成带长宽高和数量的 Item,用启发式算法在几秒内给出一组放置坐标和朝向,并告诉你哪些货没装下。它适合正在做 TMS、WMS、包装方案评估的开发人员,也适合在报价环节快速估算装载量的团队。这篇文章不展开公式推导,重点讲它是“什么思路”、怎么接入现有系统、参数怎么调,以及我实际接项目时踩过的坑。
2. 看懂 3DContainerPacking 的算法原理,再动手调库
2.1 三维装箱问题的输入、输出与约束
先建立一个统一认知:这个库解决的问题不是“怎么塞进去”,而是在给定约束下尽量“塞满”。输入层通常只有两类数据。一类是容器(Container),描述一个长方体空间,至少有 ID、长度、宽度、高度三个尺寸;另一类是货物(Item),描述某种规格货品的长宽高和数量,ID 用于回写业务单据。输出则是一组有坐标的结果:每个被装下的货物都有一个起点坐标(CoordX、CoordY、CoordZ)以及它实际占用的三维尺寸,有时还附带容器剩余体积和未装下货物列表。
约束是所有装箱算法的前提。常见约束有三条:任意两个货物不能重叠;每个货物的六个外表面必须与容器壁平行,也就是只能整体旋转,不能斜放;所有货物必须完整落在容器边界内。前两条听起来自然,但在实现里直接影响数据结构——库需要把剩余空间切成长方体空腔,如果允许斜放,整个剩余空间模型就变成无解的多面体切割,工程上几乎没法做近似搜索。
三维装箱是 NP-hard 问题,这意味着货物数量稍大,穷举所有“先放谁、放哪、什么朝向”的组合就会爆炸。实际项目里几百件货物很常见,指望一次深度搜索给出全局最优解并不现实。所以 3DContainerPacking 这类库普遍走启发式路线:按某种规则决定先放哪件,放到哪个空腔,放完后如何更新剩余空间。启发式不保证最优,但它能保证很快给出一个可用的解。
了解这一点之后,你就知道为什么调用库之前要先做数据预处理:库不会替你判断“这个货能不能倒放”,也不会知道“易碎品要放上面”。它的职责边界是几何搜索,业务约束必须在调用前转换成参数或者过滤规则。
2.2 启发式算法与最大剩余空间策略
3DContainerPacking 的内部实现,最常见的思路是维护一个“空腔列表(free spaces)”。初始时容器整个是一个空腔,放入第一个货物后,剩余部分被切割成几个互不重叠的长方体空腔;放入第二个货物时,遍历所有空腔,找出能装下它且“切割后浪费最少”的位置;每放一件,空腔列表更新一次。这种策略换个名字就是“最大剩余空间优先”,它尽量把货物塞进剩余体积最大、形状最贴合的空腔里,减少碎料。
不同的实现差别主要在两个地方:一是放置顺序,是按体积降序、按最长边降序,还是按数量聚拢后的顺序;二是空腔切割方式,可以是沿货物面一刀切到底,也可以是按最大可用矩形去切。库在枚举里公开的多种算法,本质上是这些策略的不同组合。有的是快速近似,适合在线计算;有的会多跑几轮甚至小幅回溯,适合离线优化方案。
对使用者来说,不必纠结每个算法内部怎么切,但要理解一个规律:算法越复杂,越可能在包裹量大的场景里提高装载率,同时也越可能把计算时间从毫秒拉到秒级甚至分钟级。第一批接入时,我会同时跑两三组算法各算一遍,记录容器数量和耗时,再决定默认用哪组,而不是一上来就信“最复杂那个”。
还有一个细节容易被忽略:货物数量的表示方式。库通常会为 Item 提供 Quantity 字段,而不是要求把同一规格的货物重复展开成几十个对象。这个字段能大幅降低输入量,但也会影响内部排序——有些实现按单个货物的体积参与排序,数量只是最后循环展开的数量,有些实现会把数量乘以体积再参与排序,效果差别很大。接入前建议在源码里确认这一点,或者干脆把同规格货物按块拆分(详见第 5 章)。
注意:如果库不允许按 Item 控制旋转,宁可把货物拆成多个规格,也不要全局放开旋转。
2.3 第一个最小 C# 调用示例
先跑通最简单的场景:一个 120×80×60(单位 cm)的容器,规格 30×20×15 的货物 40 件。不同版本类名和属性名略有差异,这里按最常见的 API 形态来写:
var container = new Container { ID = "CT-001", Length = 120, Width = 80, Height = 60 }; var sku = new Item { ID = "SKU-1", Length = 30, Width = 20, Height = 15, Quantity = 40 }; // 常见 API 形态:容器列表、货物列表、算法枚举作为三个入参 // 算法枚举名因版本而异,先取库默认值 var algorithm = AlgorithmType.Default; var result = PackingService.Pack(new[] { container }, new[] { sku }, algorithm); foreach (var packed in result.PackedItems) { // 坐标是货物最小点坐标,不是中心点坐标 Console.WriteLine($"{packed.Item.ID} @ ({packed.CoordX}, {packed.CoordY}, {packed.CoordZ})"); } // result.UnPackedItems 或等价字段里能看到没装下的货物这段代码做了三件事:构造容器、构造货物、调用 Pack。Container 的尺寸单位要全局统一,建议统一到整数 mm,避免 cm 和 m 混用导致所有货物都被判定超界。Item 里的 Quantity 是这种规格的数量,而不是重复创建 40 个对象。算法类型先用一个默认值跑通,再逐个切换对比结果。
打印出来的坐标是货物在容器坐标系里的最小点,也就是靠近左下后角的那个顶点;货物实际占用的空间,是“最小点 + 它自己的长宽高”。知道这一点,后续做可视化时才不会把货物画得悬空或者陷进容器壁。如果结果里 PackedItems 数量小于 40,就需要去未装箱列表里看哪些没放进去,通常是尺寸组合导致剩余空腔不够,这时候换算法或者调整容器尺寸会有帮助。
最后提醒一句:如果这段示例代码编译报错,先检查引用的是哪个分支。这个库在社区里被反复改造过,字段命名有时会是 Container.Length 和 Container.Depth,有时 Item 里还会带 Weight 属性,但整体模型不会偏离容器和货物这两个核心对象。返回结构可能是单个结果,也可能是容器维度的结果列表,按你引用的源码调整一层即可。
3. 接入业务系统:从订单数据到装载方案的完整流程
3.1 把订单和容器数据转成 Item 与 Container
实际业务里没有现成的 Container 和 Item,只有订单明细和箱型表。常见做法是写一层转换器(Adapter),把数据库里的行记录聚合成库能吃的对象。聚合有两个好处:减少装箱计算量,也让结果更容易回写业务单据。
public static List<Item> BuildItems(IEnumerable<OrderLine> orderLines, double containerLength, double containerWidth, double containerHeight) { var dict = new Dictionary<string, Item>(); foreach (var line in orderLines) { // 超大件直接外置,不进入装箱计算 if (line.Length > containerLength || line.Width > containerWidth || line.Height > containerHeight) continue; var key = $"{line.Length}|{line.Width}|{line.Height}"; // 相同尺寸的货物聚合数量,降低后续计算规模 if (dict.TryGetValue(key, out var item)) { item.Quantity += line.Quantity; } else { dict[key] = new Item { ID = line.SkuCode, Length = line.Length, Width = line.Width, Height = line.Height, Quantity = line.Quantity }; } } return dict.Values.ToList(); }这个方法的原理是先过滤超大件,再按“长宽高完全相同”做聚合。为什么要先过滤?因为库处理超大件时会返回“未装下”,本身没问题,但会让业务还抱一丝希望去调参数,浪费排程时间。直接在转换层把它分到“需另派车/另开包装”的池子里更干脆。聚合键用长宽高拼接,不要用 SKU 编码,因为同一个 SKU 可能在不同订单里有不同包装尺寸。
容器数据一般来自箱型表:厢车、高栏车、周转箱、托盘都有各自的长宽高上限。注意有些业务场景里“容器”不是运输工具,而是出货纸箱本身——你要把一堆商品装进一个 60×40×35 的纸箱,算法逻辑完全一样,只是结果要用来选纸箱尺寸,那就反过来:先固定货物集合,尝试多个候选容器,选出装载率最高且成本最低的箱型。
3.2 解析结果,生成装载清单与简易可视化
库返回的结果对象通常包含已装物品的坐标,但没有业务系统直接认这种结构。我会再包一层 DTO,把内部命名映射成团队约定的字段,然后序列化成 JSON 交给前端。
public class LoadedBox { public string ItemId { get; set; } public double X { get; set; } // 容器坐标系最小点 X public double Y { get; set; } public double Z { get; set; } public double Length { get; set; } public double Width { get; set; } public double Height { get; set; } } public static List<LoadedBox> ToLoadedBoxes(PackingResult result) { // 结果元素类型名按实际版本调整 return result.PackedItems.Select(p => new LoadedBox { ItemId = p.Item.ID, X = p.CoordX, Y = p.CoordY, Z = p.CoordZ, Length = p.Length, // 实际版本可能叫 PackDimX 之类 Width = p.Width, Height = p.Height }).ToList(); }映射过程中最容易犯错的就是“长宽高到底对应 x 轴还是 y 轴”。建议在 DTO 注释里写明:坐标原点在容器左下后角,X 沿容器长度方向,Y 沿宽度方向,Z 沿高度方向。前端拿到 JSON 后画半透明容器外壳和一个个小矩形,几分钟就能看到装箱效果,这比看数字清单直观得多。
可视化不只是给领导看,它还是排查问题的拐杖。当算法报告装载率超过 90%,但视觉上明显有一大片空间没用上,说明剩余空腔被切碎了,需要换算法策略;当货物显示悬空,说明 Z 坐标解析或坐标系转换有误。先渲染,再改参数,比直接改代码看数字高效很多。
3.3 多容器和分组约束的处理
多数业务的真实诉求不是“装进这个容器行不行”,而是“这批货要几个容器、每个怎么装”。3DContainerPacking 的 Pack 接口通常收容器列表,常见实现是依次尝试:先尽量装满第一个容器,装不完再开第二个。但要注意容器列表的顺序会直接影响结果,排在前面的一旦装上超大件,后续装载率可能被拖累。
如果业务要求按客户分组、按线路分组,就不能把全量货物一次性丢进去。常见做法是两层循环:外层按分组遍历,每组内调用一次 Pack,得到该组的容器方案。这样得到的装载计划天然满足“同组不拆车”,代价是总体装载率可能低于全量混装。分组是业务硬约束,这个取舍要在需求阶段说清楚。
// 按线路分组装箱,保证同组货物不拆车 foreach (var group in orders.GroupBy(o => o.RouteId)) { var groupItems = BuildItems(group.SelectMany(o => o.Lines), container.Length, container.Width, container.Height); var result = PackingService.Pack(new[] { container }, groupItems, algorithm); SavePlan(group.Key, result); }这里要提醒一个血泪教训:不要把“不可倒置”“易碎品要朝上”这类约束寄托在库参数上。库即使提供朝向开关,也只能限制它不被旋转,不会替你考虑承重关系。处理方式是在输入层为每类货物打标签,把允许的旋转方向单独存一张表,调用库前生成对应的 Item 或直接拆成不同的包装任务。几何数学交给库,业务责任留在自己的代码里,这样出了问题也容易定位。
4. 参数调节与性能边界:算法、尺寸与精度的取舍
4.1 算法选型对比
同一个输入,不同算法可能得出相差好几个点的装载率。把算法想象成三种策略:一种是快速分层法,把货物按层一排一排码,计算快但顶部空间容易浪费;一种是自由空腔法,每次从剩余空间里找最合适的“坑”,利用率更好但空腔列表管理复杂;还有一种是多次迭代型,会尝试多种放置顺序取最佳,耗时和装载率都在高位。
我一般在项目里准备一张表,记录三类策略在真实数据上的表现:
| 策略 | 典型耗时 | 装载率 | 适合场景 |
|---|---|---|---|
| 快速分层 | 毫秒级 | 中等 | 在线报价、容器多且要快速试算 |
| 自由空腔 | 秒级 | 较高 | 仓配方案、固定车型装载优化 |
| 多次迭代 | 秒到分钟级 | 最高 | 离线优化、大件定制装车方案 |
这里不写具体算法枚举名,因为不同版本命名差别很大。实际项目里不需要背名,先在各策略里选一个代表跑真实数据,把耗时和装载率记录下来再定默认值。我的选型做法是固定四步:先用默认算法跑一遍看耗时,再跑更复杂的算法对比装载率,接着把候选组合放进 10 组真实订单里算平均值,最后选一个“算得快且不比最优值差太多”的算法做默认,把更耗时的留给后台离线任务用。
有一个容易误判的地方:装载率不能只看单次结果。同一种算法在不同数据分布下表现可能完全不同,比如全部是小件时,分层法可能比自由空腔更好;而大件小件混合时,自由空腔又明显占优。所以选型不是一次性的,业务数据分布变了,默认策略也要重新评估。
4.2 数量级与耗时:何时需要降维
很多团队把库接好后,第一反应是“为什么我的 5000 件货要算好几分钟”。这不是库慢,而是数据没有降维。启发式算法在放置每个货物时都要遍历候选空腔,复杂度随货物数量近似平方增长;如果开了全向旋转,每个货物反复试多种朝向,耗时倍数跟着旋转选项一起放大。
经验数量级是这样:几十件货物几乎无感,百件量级在秒级,千件以上就需要认真对待。几千件的订单建议先聚合再按块拆分,而不是把所有明细直接展开。比如同规格 60 件货物,可以拆成 15 个“4 件块”,块的长宽高按 2×2×1 排列计算,装箱时把块当成一个整体,算完再把块内坐标展开到每件货。
如果业务确实需要把几千件货全部参与计算,那就把长计算放到后台异步任务里,不要让 API 请求干等。我在一个模拟项目 X 里处理过类似场景:把计算任务放进一个离线队列,任务完成后再推送结果,前端展示进度。用户等的是结果而不是接口返回,这种体验差异非常明显。
还要提一下并发。如果库的 Pack 方法内部没有共享静态状态,多容器场景可以并行计算;但如果实现里用了静态缓存或共享随机数,并行反而会出现结果不稳定。接入前在源码里确认一下,或者干脆让每个容器线程各持一个独立实例。
4.3 精度与容差:浮点坐标翻车的根源
几何算法最怕浮点误差。容器长度 120,三件货各 40,数学上刚好装满,但 double 运算里 40+40+40 可能得到 119.9999999999,于是库认为第三件装不进去。这不是库的问题,是精度阈值设置的问题。常见做法是把单位收敛到整数:对外部传给库的尺寸统一取整到 0.1mm 或者 1mm,内部计算只做整数运算,从根上避免误差累积。
还有一种更隐蔽的误差来自 float 到 double 的隐式转换。数据库的尺寸字段如果是 float,直接传给库会让 120 变成 120.000001 这类数。我一般会在转换层先 round 一次再进容器对象:
public static double NormalizeDimension(double raw) { // 统一到 1mm 精度,避免 float->double 误差 return Math.Round(raw * 1000) / 1000; }库内部如果支持容差参数,记得设一个合理的物理值,比如 0.5mm 或 1mm。容差设太大可能让货物实际上一只角伸出界,设太小又回到浮点误判的老路上。输出端的取整同样要统一策略。坐标算出来是 19.999999 或者 20.000001,写进业务单据前要明确“显示取整”和“实际放置位置”不能混淆。实际放置仍用原坐标,只是展示和打印时做四舍五入,否则下一个环节按取整后的坐标复核空间,会得出错误结论。
5. 避坑指南:三维装箱库落地中常见的 5 个坑
3DContainerPacking 本身不复杂,复杂的是把它塞进一个已经存在的业务系统。下面 5 个坑是我和同事在不同项目里反复踩过的,每条按“现象、原因、解决”展开。它们大多不是算法跑错,而是输入口径、坐标系、业务约束在转换层出了偏差。
5.1 长宽高填反,结果“看着能装,现场装不了”
现象:库输出全部装下,装载率很高,但实际装车时有一批箱子塞不进去,现场被迫拆箱重码。
原因:业务系统里的长宽高定义和库的坐标轴不一致。比如货物数据录入时习惯把最长边放到 Length,库却要求 Length 必须沿容器 X 轴,而旋转开关又被限制,导致某些组合实际朝向进不去。
解决:在转换层做“朝向归一化”。对每个规格按物理允许的朝向生成候选,调用库前用单件试装校验一下。单件试装指的是把这个规格单独放进空容器,只要能放进且不溢出,再进入正式批量计算。这个校验逻辑固定跑在单元测试里,每次改数据映射都能及时发现。
5.2 旋转开关放太开,计算量直接爆炸
现象:默认只允许平放时,订单几百件货秒级出结果;把“允许任意旋转”打开后,同样数据要几十秒甚至几分钟。
原因:每件货从 1 种朝向变成最多 6 种朝向,同一件货在不同空腔里的匹配次数大增;而且旋转后剩余空腔更碎片化,后续匹配更慢。
解决:默认只允许水平面内旋转,也就是长宽互换,高度方向保持不变。只有业务确认货物可以倒放、侧放时,才放开其他朝向。如果库支持按 Item 设置旋转标记,就逐个规格设置,千万别全局打开。真的需要高装载率时,先聚合再开旋转。
5.3 坐标原点不一致,可视化里货物悬空或陷进墙壁
现象:前端渲染后,货物不是半个悬空就是嵌进容器壁,怎么看都对不上。
原因:库返回的坐标是“最小点”,也就是货物左下后角的坐标;前端渲染时习惯用中心点,直接把最小点当中心点用,位置整体偏移半个货物尺寸。另一个常见原因是坐标系朝向上,库的 Z 轴是高度,前端场景的 Y 轴才是高度,没有做轴映射。
解决:在 DTO 层明确写注释“坐标类型为最小点,原点在容器左下后角,X 沿长、Y 沿宽、Z 沿高”,前端拿到后加半个货物尺寸得到中心点,再做一次 Y/Z 轴对调。这个规则写进接口文档,每次前后端联调按同一套约定走。
5.4 同规格货物被算法拆散,装载率反而低于手工码垛
现象:40 个同规格箱子在结果里分成 8 处堆放,每个位置都有不少细小空缝,整体装载率比仓库老师傅手工码垛低了 5 个点。
原因:启发式按“当前剩余空腔”逐个匹配,没有把同规格货物当成一个整体块来考虑。箱子们被塞进不同空隙,虽然单个位置都合理,但整体空间碎片化。
解决:预处理时把同规格货物聚成“块”。比如 2×2×2 的块尺寸是单箱长宽高的两倍,数量按块数传给库,算完后再展开成单箱坐标。块还附带一个好处:减少参与计算的“物品数”,顺带提升性能。块尺寸不一定 2×2×2,按实际纸箱强度和人工搬运动线来定。
5.5 只盯着装载率,忽略可操作性和承重
现象:算法算出装载率 92% 的完美方案,作业时发现底层货物被上层大件压住,或者最里面的缝隙窄到人手伸不进去,方案被现场直接否掉。
原因:装箱库只保证几何上不重叠、不越界,不保证承重合理、取货顺序可用、留有人工作业余量。装载率数字好看,不代表方案能执行。
解决:两头同时下手。一头在容器输入时预留内缩边距,比如长宽高各减 10~20mm,让库在“容器内更小的长方体”里计算,给搬运留出手部空间;另一头在包装结果上跑一个压住校验脚本,检查哪些货物正上方被其他货物压着且不允许压放,把这些货物标记出来,实在不行就重新算。我的习惯是永远把“可执行”排在“装载率”前面。
6. 进阶用法:自定义排序和装载率验证
6.1 用顺序控制启发式走向
多数实现的启发式都维护一个“待放货物列表”,按列表先后尝试放置,所以自定义排序是成本最低的调优手段。
// 按体积降序,让大件优先落位 items.Sort((a, b) => b.Volume.CompareTo(a.Volume));按体积降序是最常见的默认排序,让大件先落位、小件填空隙。但这不是万能规则,当货物规格很分散时,有时先放小件反而能拼出整齐的层。要做的不是猜,而是固定数据集,跑不同排序对比。
6.2 建立自己的装载率基准
我给团队定的规矩:任何改动上线前,必须在同一批真实订单上跑对比,记录四个指标——容器数量、装载率、耗时、未装下货物数。数据集长期固定不随意更换,算法升级或参数调整才看得出真实效果。注意按同一条件对比,比如容器尺寸、旋转策略都要一致。
这个基准脚本可以放在离线任务里,每次改动自动跑一遍并输出对比表。跑的次数多了会发现,某些订单数据组合下算法 A 优于 B,换一组订单就反过来,根本没有全局最优,只有“当前业务数据下更合适的方案”。
6.3 把输出物变成作业指令
坐标结果不要停在“可视化预览”这一层。把每个货物按 Z 轴从低到高排序,就是卸货顺序的参考;把朝向字段打印到装箱单上,现场就知道哪个箱子要侧放。真正用起来之后,这个库才不是演示 Demo,而是业务里能减少装载误差的作业指令。
我在接入这类库时学到的教训是:不要一上来就追求库能算到的最高装载率,先把输入口径、坐标系、业务约束三个转换层守住,再谈优化。这套顺序反过来,后面每一步都在返工。希望帮到你。
本文还有配套的精品资源,点击获取