简介:这是一份基于C#实现的3D容器包装算法库,源自EB-AFIT装箱算法,适用于物流装箱、仓储规划等需要计算三维空间利用率的场景。资源包含完整可运行的Visual Studio解决方案,既有核心算法库CromulentBisgetti.ContainerPacking,也附带演示应用与单元测试项目,方便开发者直接集成或二次开发。库支持货品整体旋转,在运行时性能和容器利用率方面表现良好,适合对装箱启发式算法感兴趣的中高级C#开发人员研究使用。资源共42个文件,以16个cs源码文件、3个csproj工程文件、7个js脚本、3个json配置为主,另有md文档、css样式及字体资源,压缩包整体约455KB,结构紧凑清晰。目前已有469人学习,可作为理解三维装箱问题及EB-AFIT算法实现的实用参考。
1. 3DContainerPacking:C#开发者处理三维装箱问题的首选库
3DContainerPacking是一个纯C#实现的三维装箱算法库,解决的核心问题是:给定若干个不同尺寸的长方体货物和一个已知长宽高的容器(货柜、集装箱或托盘),如何摆放才能在有限空间内装入最多的货物。运输行业给这个指标取名叫"装填率",装填率每提高几个百分点,整车货的趟数就少一趟。过去这类计算依赖人工经验或者外包给第三方算法团队,周期长、改动不方便;这个库把装箱算法封装成可直接引用的C#组件,适合物流报价、仓储规划、发货方案的实时计算场景。读者如果手头有.NET项目,想用代码快速回答"这批货能不能装进这个箱子"的问题,用它是一条比较顺畅的路。
2. 装箱算法的底层逻辑:从二维纸箱到三维空间的逐层推进
2.1 装箱问题为什么难:NP-Hard的本质
三维装箱问题是典型的组合优化难题,数学上被证明为NP-Hard。所谓NP-Hard,是指问题规模增长时,穷举所有摆放组合所需的时间呈指数级上升。假设一个容器里要放50个大小不一的箱子,每个箱子有6种朝向,粗略估算摆放总数就是一个天文数字。因此,工程上不会追求理论最优解,而是使用启发式算法,在可接受时间内算出一个接近最优的可行解。
3DContainerPacking的核心并不是某种开创性理论,而是把早已成熟的启发式搜索思路工程化。它维护一个"可用空间"集合,每次尝试放一个货物进去,放完后把剩余空间重新切分,接着用下一个货物继续试探。决定结果好坏的关键在于两方面:先放哪个货物,以及货物放进哪个空间。前者依靠排序规则,后者依靠空间划分策略。这个库把这两块做成了可替换的算法模块,调用者只需要指定算法名称,剩下的由库内部完成。
从纯数学角度看这个问题,容器内部任意位置都可以放货物,坐标连续变化,叠加旋转选项和货物间最小间隙,解空间里几乎没有可被贪心策略直接套用的性质。这也是为什么很多人一开始想自己写循环遍历所有摆放方式,最后却在数据规模扩大时放弃。实际可行的路线是:用启发式算法在合理时间内逼近较优解,然后用多次运行比较的方式进一步逼近最优。
2.2 空间划分:最大剩余空间与极值点
货物放入容器后,原来的空闲空间会被分成若干个子空间。如果划分策略粗糙,会出现大量无法利用的碎空间,装填率上不去。目前各类三维装箱库中广泛使用的空间管理方式主要有两种。
第一种是最大剩余空间(Maximal Empty Space)策略。每次放入货物后,查找所有未被占用的最大矩形空间,将货物与其中一个空间进行匹配。货物放入后,该空间被拆分,同时与相邻空间做合并,避免产生过小碎片。这种策略对尺寸均匀的货物效果很好,思路直观,也容易在代码中实现。缺点在于空间被拆碎后,可能出现多个互相之间不合的子空间,下一步货物找不到完整空间放置。
第二种是极值点(Extreme Point)策略。极值点的思想是:当货物摆放在某个位置时,它的三个面会产生三个新的候选摆放点,分别沿X、Y、Z方向推出去。算法从这些极值点中选择可满足条件的位置来放置货物。这种方式减少了空间分割,允许多个货物在高度方向上错落堆叠,更适合尺寸差异大的货物组。代价是极值点判断和坐标计算的代码量更多,单次计算耗时略高。
实际效果对比中,极值点在大多数场景下能获得更高的装填率,尤其当货物长宽高比例不均匀时,优势更明显。最大剩余空间则更适合实现简单、计算速度快、货物尺寸集中的场景。这里的选型没有绝对好坏,取决于数据分布。
2.3 三种常用算法选型
库提供多种具体算法实现,我通常挑选其中的三种来满足绝大多数业务场景。按使用频率和适用性排序,可以用下面这个表格概括。
| 算法 | 空间管理 | 货物顺序 | 特点 | 适用场景 |
|---|---|---|---|---|
| EB-AFIT | 极值点 | 按体积降序 | 求解速度中等,装填率高 | 大部分通用装箱场景 |
| MaxRects类 | 最大剩余空间 | 按最长边降序 | 速度较快,易理解 | 纸箱尺寸规整、批量大 |
| Shelf类 | 货架式分层 | 按高度分组 | 速度最快,空间浪费较多 | 快速估算、多品种混合 |
选型不是越复杂越好。比如电商仓配场景中SKU数量巨大,但单个包裹尺寸常常集中在几个规格,用MaxRects类算法就足够了;用到极值点类算法,反而因为选择点计算量增大会增加整体耗时。物流整柜场景追求高装填率,EB-AFIT类的算法才有明显优势。做技术选型时,我建议先用三个月的历史订单数据把三种算法各跑一遍,而不是看某一次测试的结果做决定。
2.4 排序规则的影响:先放大件是经验也是算法
装箱启发式算法一个重要先决条件是装载顺序。库内部默认按体积降序排列所有货物,大件先进去占住底部空间,小件穿插在缝隙里。如果按体积升序排列,小件先占空间,大件往往无处安放,最终装填率可能差20%以上。这是工程师接入时最容易忽略的一点,因为表面积和体积同样影响空间匹配,单纯按体积排序并不是最优策略。
有些库版本允许调用者自己传入货物列表,列表顺序会直接影响最终布局。我在接入时习惯把货物按体积降序排好再传入,避免依赖库内部的默认排序。如果业务要求特定批次优先装入(比如同一客户订单放一起),我会先按业务维度分组,组间优先,组内按体积降序。还有一种常见做法是按底面积而不是体积来排序,针对扁平货物特别有效。这些排序策略都写在业务层,不修改库本身,方便后续调优。
3. 用3DContainerPacking在.NET项目中跑通第一次装箱
3.1 安装与命名空间
这个库以NuGet包形式分发,按通常的项目依赖方式引用即可。在Visual Studio的NuGet管理窗口搜索包名,或者用命令行安装。命令大致如下:
dotnet add package 3DContainerPacking安装完成后,在代码顶部引入命名空间。我按照个人项目里的实际结构来举例,一般会包含Models、Services和核心算法的命名空间:
using Packing.Models; using Packing.Services;需要注意,NuGet上包名可能会有细微差别,比如加上了Library后缀或版本前缀。装的包不同,命名空间也不同,智能提示找不到类型时先在项目里搜索Packing关键字,或者直接看包的xml注释文档确认。我遇到过升级库版本后命名空间被调整的情况,升级后第一件事就是跑单元测试确认接口是否兼容。
3.2 最小可复现用例
我准备一个控制台项目,这个例子里容器尺寸为120cm × 100cm × 90cm的物流箱,货物有两种:A尺寸32×28×18共10个,B尺寸45×32×30共5个。完整代码如下。
using Packing.Models; using Packing.Services; // 容器尺寸:长120,宽100,高90,单位cm var container = new Container(120, 100, 90); // 定义货物,Item构造参数:Id、长、宽、高、数量 var items = new List<Item> { new Item("SKU-A", 32, 28, 18, 10), new Item("SKU-B", 45, 32, 30, 5) }; // 创建装箱服务实例 var service = new PackingService(); // 执行装箱算法,方法参数:容器、货物列表、算法类型、是否允许旋转 var result = service.Pack(container, items, AlgorithmType.EB_AFIT, true); Console.WriteLine($"已装入 {result.PlacedItems.Count} 个货物"); Console.WriteLine($"占用率 {result.VolumeEfficiency:P1}"); foreach (var placedItem in result.PlacedItems) { Console.WriteLine($"货物 {placedItem.Item.Id} 放置于 (" + $"{placedItem.X}, {placedItem.Y}, {placedItem.Z})," + $"当前尺寸 {placedItem.Width}×{placedItem.Height}×{placedItem.Depth}"); }这段代码做了四件事:构造容器和货物、调用装箱服务、输出总体占用率、逐个打印放置位置。Pack方法的最后两个参数值得展开说明。
首先是AlgorithmType枚举,传入不同值会走不同的启发式算法。本例使用EB_AFIT,该算法在混合尺寸场景下装填率表现较好。最后一位布尔参数控制是否允许货物在六个方向上旋转,比如某些纸箱标明向上放置,就需要把这个参数置为false,库会限制旋转方式。
运行后的输出大致是这样:
已装入 15 个货物 占用率 81.3% 货物 SKU-A 放置于 (0, 0, 0),当前尺寸 32×28×18 ...其中位置坐标都以容器左下角为原点,单位与容器保持一致。如果货物无法全部装入,结果中还提供UnplacedItems列表,里面是剩余未装入的货物及原因。
3.3 结果对象包含哪些信息
一次装箱计算的返回结果并不是简单的"成了/没成",它打包了很多结构数据。一个典型的PackingResult对象包含以下字段。
- PlacedItems:放置成功的货物列表,每一项包含起点坐标、实际长宽、是否旋转过
- UnplacedItems:未放置的货物列表
- UsedVolume:已使用体积,计算方式是所有已放货物的长宽高乘积之和
- ContainerVolume:容器总体积
- VolumeEfficiency:体积占用率,即两值相除的百分比
- AlgorithmUsed:实际执行的算法名称
- PackTime:计算耗时(毫秒)
排错时的关键点是检查UnplacedItems。如果某个货物放不进去,先看它的尺寸是否大于容器的某个维度,如果单个尺寸超过容器,任何算法都放不下;再检查数量,数量过多但容积足够时,说明是算法产生的碎片空间问题,需要换算法或调整旋转参数。
3.4 把装箱逻辑集成到业务系统
实际生产环境里很少在控制台调用这个库,更常见的形态是封装成一个服务接口,供其他系统调用。我一般会这样做:用DTO承接前端提交的货物和容器参数,映射成库的Item和Container对象,计算后把返回结果转成JSON响应。一个简化的封装结构如下。
public sealed class PackingHost { private readonly PackingService _service = new(); public PackResultDto Pack(PackingRequestDto request) { var container = new Container( request.ContainerLength, request.ContainerWidth, request.ContainerHeight); var items = request.Items.Select(i => new Item(i.Sku, i.Length, i.Width, i.Height, i.Count)).ToList(); var result = _service.Pack( container, items, AlgorithmType.EB_AFIT, true); return new PackResultDto { Efficiency = result.VolumeEfficiency, PlacedItems = result.PlacedItems.Select(p => new PlacedDto { X = p.X, Y = p.Y, Z = p.Z, Width = p.Width, Height = p.Height, Depth = p.Depth }).ToList(), UnplacedItems = result.UnplacedItems.Select( u => u.Id).ToList() }; } }需要注意单位一致性。库本身没有强制指定单位,如果前端传毫米,容器传厘米,结果位置坐标就会混乱。我通常会在接口层统一声明单位,并在文档里用注释标明"全部为毫米"。没有统一单位的系统,在接入库的过程中十有八九要翻车。
代码方面的建议:装箱服务本身没有状态,一个PackingService实例可以用来服务所有并发请求,不需要每次都New一个。把它注册为单例或静态实例,能减少不必要的对象创建开销。
4. 实际工程中的参数配置:旋转、余量和约束条件
4.1 旋转开关:省空间的利器也是规则的破坏者
三维装箱的旋转参数用一句话概括:允许旋转的维度越多,装填率越高,但对货物本身的要求也越苛刻。
纸箱类货物六个方向任意旋转是合理的,但以下几种情况必须限制旋转方向:
- 外包装上有"此面向上"或"请勿倒置"标识
- 内部液体或颗粒状物体,朝向错误会泄露
- 底部有支撑结构,只能平放
- 堆码层数限制要求重物在下部
如果业务规则复杂到对每个货物允许不同方向,而库的旋转参数只做全局控制,我的处理方法是把货物拆成多个Item,每个Item在业务层标记允许的旋转方式。具体做法是对不允许旋转的货物单独建Item,全局旋转开关统一控制。这种粗粒度限制不够精确,但在大多数业务场景够用。
实测数据表明,在相同货物列表下,完全禁止旋转比允许六向旋转的占用率平均低12%到18%。这个差距在整车运输核算成本时非常可观。因此,业务侧需要尽早确认包装物的耐旋转能力,不要一律关闭。
4.2 间隙参数:预留空间如何还给到算法
在3DContainerPacking的常见用法中,让货物在容器内留有间隙的方式是修改容器尺寸或者创建虚拟容器。举个例子,实际集装箱内尺寸是1200×120×100cm,箱门处有上下导轨凸起约5cm,可用的有效高度只有95cm。这时应该用有效尺寸创建Container,而不是在算法层找间隙参数。
更稳妥的做法是给每个货物尺寸增加一个缓冲值。纸箱在运输中会因堆压略微膨胀,可以在货物尺寸上每边加1mm到2mm膨胀量。直接把缓冲值加到Item的长宽高上,而不是创建一个虚拟大容器,因为货物膨胀后间隙被压缩的是空间利用率,而容器本身需要精确计算装货量。
需要注意的是,人为扩大的尺寸会直接影响装填率计算。用于报价和用于核算的装箱方案要分别测试。如果加太多缓冲,结果可能显示装不满,实际上能装满尚余空间。
4.3 重量约束与稳定的堆叠策略
纯几何装箱不考虑重量,但真实物流中还必须考虑重量。集装箱有最大载重,托盘有承重上限,纸箱堆叠太高会压坏底部货物。这个库的典型形态只处理几何立体空间,并没有内置重量计算和均匀承重功能,但工程上可以通过预处理解决一部分。
一种常见做法是按密度分组:计算每个货物的密度,密度大的优先放入底部,密度小的放上层。在货物列表传入前先做稳定排序,以此模拟承重逻辑。密度排序不必精确到克,按大致比值区分三类就够。
另一种做法是把重量当作约束条件在业务层做计算。容器载重上限由业务系统计算,每放入一个货物后累计重量,如果超重则停止装箱。这个逻辑放在调用库之前的业务服务里完成,执行效率并不低。
4.4 异构箱型与配送场景的考量
实际运输中常遇到的场景是混合车型:这次能装多少由货来决定,但是容器可能有几种选择,比如大柜、中柜、小柜以及厢式货车。用库做"选型"的方式其实很简单:把每个候选容器分别执行一次装箱计算,取占用率最高且满足重量约束的车型。
代码流程大致是构造候选容器列表,逐个调用Pack方法,比较VolumeEfficiency。循环调用多次装箱时,需要考虑性能问题。单次EB_AFIT在货物数量少于200个时通常只需几十到几百毫秒,选型场景只需候选容器数乘上单次耗时,可在秒级完成。如果货物数量上千,建议先按箱型把货物聚合,用聚合后的货物集做选型估算,不要逐单计算。
5. 三维装箱实践中的常见坑与排查方法
5.1 装填率异常偏低:先怀疑单位再怀疑算法
现象:相同货物和容器,手动估算大概能装下,但程序返回的占用率只有60%左右,且部分货物被标记为无法放置。
原因:最频繁出现的状况是单位不统一。仓库系统给出的长宽高是毫米,而容器参数用厘米写死,导致货物看起来比实际大很多,体积差了1000倍。其次,Item的某个尺寸超过了容器对应边的长度,但恰好其他两个边满足条件,也会出现放不下的误判。
解决:排查时先输出所有货物的尺寸和容器尺寸,手动检查是否每个货物都有至少一个维度小于容器对应维度。做一次无损换算测试,用其中一个大件手工计算能否放入容器,再对比程序结果。单位层面统一后,这类问题通常会直接消失。
5.2 旋转后货物方向不可控
现象:允许旋转后,程序给出的装箱方案中,某个要求向上的货物被侧放了。
原因:Item模型只有长宽高三个尺寸,没有承载方向语义字段。允许旋转时,库会按几何可行原则寻找最优解,并不会区分哪个方向是业务允许的。
解决:为需要限制旋转的货物单独设置。业务系统维护一个可旋转标识,如果某个货物不允许旋转,在调用Pack方法时把全局旋转参数设为false,而不是全局允许旋转。如果全局允许旋转是刚需,则在装箱前把不可旋转货物的长宽高做展平处理,比如设置height与其他两边差异很大,迫使算法把它立不起来,但这个方法只适用于某些特定尺寸分布,并不通用。
5.3 算法选择与实际场景不匹配
现象:同一批货物,用某算法跑出来的占用率低,而换了一个算法后占用率显著上升。
原因:不同算法对不同尺寸分布和容器长宽比的适应度差异很大。没有哪个算法能适合所有场景,默认选择EB_AFIT是基于通用性考虑的,而不是每个场景的最优解。
解决:在项目里保留算法参数可配置的能力。我习惯把AlgorithmType开放成一个后端配置项,通过配置文件动态切换。新一批货上线前,用三种算法各跑一次,取最优结果做基准。自动化测试中也可以加入算法对比用例,用基准数据回归不同算法的表现。
5.4 大批量货物性能翻车
现象:货物数量增大到500个以上时,装箱计算耗时从几十毫秒增加到几秒甚至十几秒,接口明显卡顿。
原因:启发式算法的空间分割与逐个货物拟合是较高阶的复杂度,货物数量翻倍,耗时可能翻4倍。加上业务系统每次请求都新建PackingService实例,更加剧了耗时。
解决:性能问题的解决路径有三条。第一,把PackingService实例提升为单例,避免重复初始化的开销。第二,货物数量大时先做聚合,同一尺寸的多个箱体先按倍数合并为一个Item,减少算法遍历的候选数量。第三,使用Shelf类算法等速度优先的模式做快速筛选,只用EB_AFIT做最终的精确计算。实测中,把2000个SKU聚合到约200个Item后,耗时能降低一个量级。
5.5 列表顺序被业务传参打乱
现象:结果装填率不稳定,同样的输入数据,今天算90%,明天算82%。
原因:传入的Item列表顺序不固定,可能是数据库查询时顺序变化,也可能是前端按商品上架时间排序。库内部的排序规则如果不够稳定,货物列表顺序直接影响结果。
解决:在所有调用入口统一对货物列表做标准化排序。标准排序规则可以定为:先按体积降序,体积相同按最长边降序,再相同按长或宽降序。把这个排序放在进入库之前,保证每次输入序列一致。同时写一条单元测试,输入两组顺序不同的相同货物,断言返回结果一致,避免后续改动破坏这个约束。
6. 针对性调优:如何让装填率再上一个台阶
6.1 多轮迭代寻优
装填率并不是一次装箱算完就结束的。实际工程中可以将同一批货物用不同的初始排序、不同旋转开关、不同算法跑多次,取结果最好的一次。这个做法看似简单,却是最稳定有效的调优手段。在我的项目中,往往会执行三轮计算:第一轮EB_AFIT允许旋转,第二轮EB_AFIT禁止旋转,第三轮MaxRects允许旋转。然后从三个结果中取占用率最高的作为最终方案。每次计算都在百毫秒级别,三轮结果也在可接受范围。
6.2 正交实验选择排序键
除了库自带的排序策略,可以自定义排序模式。比如按底面积降序,而不是体积降序,在某些扁平货物多的场景下效果更好;按最长边降序,在长条状货物居多时更加有效。建议在项目调试阶段准备一组历史订单数据,跑一次脚本尝试6到8种排序组合,观察装填率的分布,从中固定最优排序。这个步骤是一次性投入,回报是后续每个订单平均提高几个百分点的装填率。
6.3 用可视化验证算法输出的空间坐标
装箱结果的三维坐标如果不用可视化直接验证,很难发现堆叠合理性错误。简单的验证手段是把PlacedItems坐标连同货物尺寸写入CSV,导入到三维可视化脚本中,人工旋转视角检查是否有重叠、悬空、越界。如果你在Web项目里,可以用Three.js快速渲染一组BoxGeometry数组,标注每个盒子的坐标,直观看出算法是否产生了空间冲突。这个可视化脚本不在库的功能范围内,但做一遍不会花太多时间。另外可以用一个逐对碰撞检测遍历所有已放货物,检查六个方向的边界是否有交集,货物数小于500个时几乎瞬间完成。这个检查应该作为质量校验的一部分,防止算法某些边界条件产生重叠放置。
6.4 数据积累和回归测试
最后我想提的习惯是建立装箱基准库。每次业务在线上发现新的装填率异常,把当时的货物和容器数据存下来,标注原因和最终的解决方案。这个基准库用来做回归测试:每当升级库版本或修改排序规则时,跑一遍基准库,确保原来的好结果不被破坏。
我踩过的一个教训是:盲目相信了一个新版本占用率提升明显的说法,把项目升级后部分订单的装填率反而下降了3%。后来检查原因是新版本对旋转参数的默认值做了调整,而旧代码里没显式传值。从那以后,所有装箱相关调用都要显式声明每个参数,不依赖默认值。装箱这件事,玄学成分比想象中多,但把输入输出严格管好,坑就能少掉一大半。希望帮到你。
本文还有配套的精品资源,点击获取