混合背包:三类物品同台
前三讲(01 背包、完全背包、多重背包)分别处理了三种基础形态。混合背包把它们放进同一道题:N 个物品分为三类——有些每件只能用一次,有些可任意件取用,有些有件数上限。它既不是纯 01,也不是纯完全,也不是纯多重。这不需要设计新算法:本讲的全部内容,就是把这道看似复杂的题拆回三个已解决的子问题,再按物品 type 分发。实际工程问题里物品几乎总是天然带类型(独占 / 共享 / 配额资源;限量品 / 散装品 /
限购品),单一类型的「纯净」背包反而是教学简化——掌握分发模式即可处理几乎所有现实背包。
1 · 三种初始直觉与为何都不通
面对「三类物品同时出现」,读者通常有三种本能反应。它们都看似合理,却各自暴露了对 DP 抽象的不同程度误解。逐一看清「为何不通」,是建立分发模式的最佳起点。
直觉一 · 加维度
「既然物品分三类,那就给状态多加一维 type:。」
为何不通:类型本身不参与转移——
怎么更新,只取决于费用 C 与价值 W,与「它属于哪一类」无关。多加 type 维只会让状态空间膨胀 K 倍,却换不到任何新能力。
直觉二 · 分库存
「那就用三个独立的 F 数组分别跑三种背包,最后把答案合并。」
为何不通:三种解之间无法线性叠加。同一份预算 V 不能既被 01 用完、又被完全用完——总和会超支。三个独立数组只描述「只允许这一类时的最优」,而原题要求三类共享同一份预算。
直觉三 · 重建模
「把多重物品拆成 个 01,把完全物品视为『无限多个 01』,统一按 01 跑。」
为何不通:前半招对多重可行(就是第 3 讲的朴素拆分),代价是 件;后半招对完全则错了——「无限多个 01」不可枚举,即便取上界 件,也丢失了「完全背包只需顺序循环」这一关键洞察。
共性。三种直觉的共同病灶是:试图把「类型差异」塞进 DP 的状态空间或物品集合本身。但回看前三讲的实现,会发现状态与语义其实从未改变:
| 三种背包 | 状态空间 | F[v] 语义 | 真正不同的 |
|---|---|---|---|
| 01 背包 | F[0..V] | 已处理物品中费用 ≤ v 的最大价值 | v 逆序遍历 |
| 完全背包 | F[0..V] | 同上 | v 顺序遍历 |
| 多重背包 | F[0..V] | 同上 | 二进制拆分 + 逆序 |
状态没变, 的语义也没变——变化的只是「把这件物品纳入 F」这个动作的具体实现。
本讲的题眼。变化的不是状态,而是 transition procedure(状态转移过程)。既然状态空间一致,就可以共享同一个 F 数组;既然 transition 因类型而异,就分别封装、按类型在主循环里分发。这正是接下来「三种背包过程」与「按类型分发的运行过程」两节要展开的「独立过程 + 按类型分发」模式。
NOTES · 记号约定「前 i 件」的读法 / 复杂度的可加性
本讲沿用以下惯例:状态
中的 i 是候选集合的上界,即允许从物品 {1,…,i} 中选取(混合背包下按类型应用各自的转移),v 是总费用上限。i 从 0 增至 N 对应「逐步把每个物品纳入候选范围」——这是正向 DP 的视角。把「前 i 件」误读为「剩余未判定的 i
件」对应逆向 DP,两者算法上等价,本讲取前者。
复杂度组合 · 三类成本的可加性。分发模式有一条易被忽视却很实用的性质:它不引入任何额外开销。混合背包的总时间复杂度等于三种 transition strategy 各自成本的线性叠加:
T_mixed = Σ_01 O(V) + Σ_完全 O(V) + Σ_多重 O(V·log Mᵢ)
即 。每件物品的处理成本只取决于其类型,与其他物品无关——这就是「代价可加性」,它解释了为何把三种基础形式混在一起不会让问题更难。这也回答了直觉三遗留的疑问:把完全物品强行视为「 个 01」,会让该类成本从 退化为 ,失去顺序循环的常数级优势。
2 · 三种背包过程
「三种初始直觉」一节的结论告诉我们:三种背包的真正差异在「如何更新 F」,而非状态空间。本讲因此把前三讲各自封装为独立过程,共享相同的接口契约:接收 F 数组与物品参数,在原地更新 F。这一「同构接口」是后续按类型分发的前提。
01 背包 · Lecture Ⅰ — ZeroOnePack(F, C, W)
for (v = V; v >= C; v--) // 逆序
F[v] = max(F[v], F[v-C]+W);
v 逆序 · F[v−C] 是「上层」。
完全背包 · Lecture Ⅱ — CompletePack(F, C, W)
for (v = C; v <= V; v++) // 顺序
F[v] = max(F[v], F[v-C]+W);
v 顺序 · F[v−C] 是「本层」。
多重背包 · Lecture Ⅲ — MultiplePack(F, C, W, M)
// 容量先耗尽 → 退化
if (C*M >= V) return CompletePack(…);
for (k of binarySplit(M))
ZeroOnePack(F, k*C, k*W);
二进制拆分 · log Mᵢ 件 01。
参数语义(三过程统一约定):
F· DP 数组,三过程均原地更新不返回新数组;V· 容量上限(全局常量);C· 当前物品费用(cost);W· 当前物品价值(worth);M· 仅多重过程使用,件数上限(每种至多取 M 件)。
2.1 · 三个过程 = 三种 transition strategy
有必要给三者一个统一命名:ZeroOnePack / CompletePack / MultiplePack 不是「三种不同的算法」,而是同一 DP 框架下的三种 transition strategy。它们共享:同一个状态空间
;同一种「加入物品后取较优」的更新逻辑
。差异仅在「加入」这个动作的合法性约束:
- 01 · 每种至多 1 次 → 逆序枚举保证 是「未含本物品」的上层状态;
- 完全 · 任意次数 → 顺序枚举允许 已含本物品;
- 多重 · 至多 Mᵢ 次 → 二进制拆分后归约为 件 01 物品。
确认这一点后,「分发模式」的真正含义浮出水面:被分发的不是三种算法,而是同一 DP 框架下的三种 transition strategy。这把话题从「如何写代码」提到了「如何抽象 DP 的转移」——一个层级更高的位置。
2.2 · 为什么不写一个「统一」的 Pack 函数?
三个过程的接口都形如
,自然会想:写一个 Pack(F, C, W, M, type),内部用 if 按 type 分支,不就一劳永逸?这是个合理的疑问——答案是可以,但这恰恰违背了抽象的目的。三过程的接口相同,内部语义却本质不同:
- 01 与完全的循环方向相反(v 递减 vs 递增),对 是「上层」还是「本层」的语义需求不同;
- 多重不仅有循环方向,还可能触发二进制拆分这一不同结构的子算法——内层根本不是单层 for 循环。
把这些异质性硬塞进同一个函数,意味着函数内部要多条 if 分支处理本不相关的语义,反而比「分别封装 + 主循环分发」更难读、更难扩展。封装为三个独立过程后,每个过程只关心自己的内层循环规则;主循环只关心「按类型选哪个过程」——这是关注点分离的具体体现。后续的分组背包(第 6 讲)、依赖背包(第 7 讲)将看到,这种封装让「加新类型」的成本始终维持在一行 dispatch。
for (const item of items) {
switch (item.type) {
case '01': ZeroOnePack(F, item.C, item.W); break;
case 'complete': CompletePack(F, item.C, item.W); break;
case 'multiple': MultiplePack(F, item.C, item.W, item.M); break;
}
}
// 完成。无任何新的 DP 推导——这正是抽象与组合的价值。
3 · 按类型分发的运行过程
F 共享的直觉。最常见的疑问是:「既然三类的循环规则不同,凭什么写到同一个 F 数组上?需不需要分别维护三个 F?」——不需要,而且这正是分发模式得以成立的关键。F 不是「01 的 DP 数组」也不是「完全的 DP 数组」——
始终表示「在当前已处理的物品集合中、费用不超过 v 时的最大价值」。每次 Pack(F, item) 无论 item 哪一类,本质都是把这件物品纳入「已处理集合」。三过程的差异不在 F 的语义,而在「加入」的合法性约束(见「三种背包过程」一节)。
分发正确性 · 共同不变式。任意 Pack 过程处理完第 i 个物品后,
都保持同一语义——「在已处理物品 1..i 中、按各自类型规则纳入考虑、费用不超过 v 时的最大价值」。前三讲已分别证明各过程在该语义下正确,故本讲只需依类型分发,无需就组合再作任何 DP 推导。下面单步跑混合背包:当前物品高亮,调用横幅显示它被分发到了哪个子过程、完整签名是什么(多重物品还会展开二进制拆分),F 数组的单元按「由哪个过程更新」持久着色。
NOTES · 抽象的复利 · 加入新背包类型
分发模式具有横向扩展性:每当后续引入一种新的背包形式,主循环只需追加一个分支,无需改动既有过程。例如第 6 讲将引入分组背包(同组至多选一件),设其封装为 GroupPack(F, group),主循环只需:
case '01': ZeroOnePack(F, it.C, it.W); break;
case 'complete': CompletePack(F, it.C, it.W); break;
case 'multiple': MultiplePack(F, it.C, it.W, it.M); break;
case 'group': GroupPack(F, group); break; // 新增一行
第 7 讲的依赖背包(主件与附件)亦可同样并入。每次扩展的代价仅为一行分发,无任何 DP 层面的推导负担。这种「加新功能不动旧代码」的模式,在软件工程中称为开闭原则(Open-Closed Principle)。
4 · 应用案例 · 混合券组合
「按类型分发的运行过程」一节用伪码与不变式论证了分发的正确性。下面看它在真实电商场景的落地——同一笔订单里,用户面对三类来源不同的券,每类恰好对应一种背包:店铺新客券(01,每店首单一次)、平台无门槛叠加券(完全,同种可叠加任意张)、平台限领满减券(多重,每种限领
Mᵢ 张)。三类券同台,正是开篇设想的「既不是纯 01、也不是纯完全、也不是纯多重」的混合情形。把券门槛视为费用 C、单张减免视为价值 W、购物车总额视为容量 V。拖动总额,看 DP 按券的类型采用相应件数上界————给出最优券组合。
观察。同一份 DP 一次完成求解,按券类型分别采用相应件数上界——未引入任何「特殊逻辑」。额度较低时 DP 优先选取性价比 W/C 最高的券(首选 A 店新客 满200-50 性价比 0.25、B 店新客 满300-60 0.20,均 01);这两张在 ¥500 即用完配额,余额让位给多重满减券。额度抬高后多重券逐一达到 Mᵢ(MAXED);额度极宽时余量由平台叠加券(完全,无件数限制)填补——恰对应三类件数上限的本质差异 。
NOTES · 工程实践中的常见误用 · 类型分发错位
反面对照——一类实际项目中容易出现的错误:把「每种限购 Mᵢ 件」的多重物品错误地按「无限供应」(完全物品)处理。这种错误在性能压力下尤其常见——开发者为避开多重背包的二进制拆分实现,可能想「完全背包循环更简单,先用着、容量大概也够」,于是把限量品误当作可无限取。取多重物品
(C,W,M)=(3,5,2)、V=18:
-
正确(分发至
MultiplePack):至多选 2 件,价值 ,剩余容量 12 留给其他物品。 -
错误(误分发至
CompletePack,忽略 M):视为无上限, 件,价值 。这一虚高 200% 的「最优值」对应方案根本不合法——题面只允许 2 件。
更糟的是:错误实现的输出表面上「看起来更优」,且程序不报错——既无运行时异常,也无明显数值溢出,这类 bug 很可能潜伏到生产。启示:物品的「类型」标记是问题语义的一部分,不是可选修饰字段。分发逻辑必须与物品定义严格对应;工程实现中类型字段应在分发入口处显式校验(如 default 分支抛异常),让缺类型 / 错类型立即暴露。
本讲的方法论意义。01、完全、多重背包各自都不难,但把它们组合起来便得到一道看似复杂的题目;只要领会三种基本背包的思想,就能把组合后的题拆分成已解决的子问题。本讲不引入新算法,价值在于揭示一种贯穿系列的方法论:识别正交的子问题、为每个子问题定义清晰统一的接口、组合时仅需调度而无需推导。这一思路将在第 7 讲(依赖背包)的「主件附件视为物品组」中再次出现,并在第 8 讲(泛化物品)中被推到极致——所有背包形式被统一为「对函数 h(v) 的求和」。
NOTES · 关键概念出处 · 延伸阅读 · 通向第 5 讲
关键概念出处。「按类型分发」对应面向对象编程中的多态(polymorphism)与函数式编程中的类型派发(typed dispatch)。其工程学意义最早由 Bertrand Meyer 在 Object-Oriented Software Construction(1988)中以「开闭原则」形式系统阐述:软件实体应对扩展开放、对修改封闭。
延伸阅读。「主循环 + 过程库」模式可推广至更广泛的组合优化问题。例如网络流中「按弧类型选择更新规则」、几何中「按图元类型选择求交算法」,均采用类似结构。核心在于:确认所有子算法在状态空间与接口契约上的一致性,使分发逻辑保持简洁。
通向第 5 讲 · 二维费用背包。第 5 讲把「每件物品占用一种资源」放宽为「同时占用两种资源」,得到二维费用背包。状态空间由 扩展为 ,而循环方向、初始化等核心思想不变——本讲的「分发模式」亦可平滑应用于二维情形。