← 背包问题九讲 · 动态规划的思考艺术 / 混合背包:三类物品同台 待审核 4 / 10
Ⅳ · 混合背包 · dispatch 分发

混合背包:三类物品同台

前三讲(01 背包完全背包多重背包)分别处理了三种基础形态。混合背包把它们放进同一道题:N 个物品分为三类——有些每件只能用一次,有些可任意件取用,有些有件数上限。它既不是纯 01,也不是纯完全,也不是纯多重。这不需要设计新算法:本讲的全部内容,就是把这道看似复杂的题拆回三个已解决的子问题,再按物品 type 分发。实际工程问题里物品几乎总是天然带类型(独占 / 共享 / 配额资源;限量品 / 散装品 / 限购品),单一类型的「纯净」背包反而是教学简化——掌握分发模式即可处理几乎所有现实背包。

1 · 三种初始直觉与为何都不通

面对「三类物品同时出现」,读者通常有三种本能反应。它们都看似合理,却各自暴露了对 DP 抽象的不同程度误解。逐一看清「为何不通」,是建立分发模式的最佳起点。

直觉一 · 加维度

「既然物品分三类,那就给状态多加一维 type:F[i][v][type]F[i][v][type]。」

为何不通:类型本身不参与转移——F[v]F[v] 怎么更新,只取决于费用 C 与价值 W,与「它属于哪一类」无关。多加 type 维只会让状态空间膨胀 K 倍,却换不到任何新能力。

直觉二 · 分库存

「那就用三个独立的 F 数组分别跑三种背包,最后把答案合并。」

为何不通:三种解之间无法线性叠加。同一份预算 V 不能既被 01 用完、又被完全用完——总和会超支。三个独立数组只描述「只允许这一类时的最优」,而原题要求三类共享同一份预算

直觉三 · 重建模

「把多重物品拆成 MiM_i 个 01,把完全物品视为『无限多个 01』,统一按 01 跑。」

为何不通:前半招对多重可行(就是第 3 讲的朴素拆分),代价是 ΣMiΣM_i 件;后半招对完全则了——「无限多个 01」不可枚举,即便取上界 V/C\lfloor V/C\rfloor 件,也丢失了「完全背包只需顺序循环」这一关键洞察。

共性。三种直觉的共同病灶是:试图把「类型差异」塞进 DP 的状态空间或物品集合本身。但回看前三讲的实现,会发现状态与语义其实从未改变:

三种背包 状态空间 F[v] 语义 真正不同的
01 背包 F[0..V] 已处理物品中费用 ≤ v 的最大价值 v 逆序遍历
完全背包 F[0..V] 同上 v 顺序遍历
多重背包 F[0..V] 同上 二进制拆分 + 逆序

状态没变,F[v]F[v] 的语义也没变——变化的只是「把这件物品纳入 F」这个动作的具体实现。

本讲的题眼。变化的不是状态,而是 transition procedure(状态转移过程)。既然状态空间一致,就可以共享同一个 F 数组;既然 transition 因类型而异,就分别封装、按类型在主循环里分发。这正是接下来「三种背包过程」与「按类型分发的运行过程」两节要展开的「独立过程 + 按类型分发」模式。

NOTES · 记号约定「前 i 件」的读法 / 复杂度的可加性

本讲沿用以下惯例:状态 F[i][v]F[i][v] 中的 i候选集合的上界,即允许从物品 {1,…,i} 中选取(混合背包下按类型应用各自的转移),v 是总费用上限。i 从 0 增至 N 对应「逐步把每个物品纳入候选范围」——这是正向 DP 的视角。把「前 i 件」误读为「剩余未判定的 i 件」对应逆向 DP,两者算法上等价,本讲取前者。

复杂度组合 · 三类成本的可加性。分发模式有一条易被忽视却很实用的性质:它不引入任何额外开销。混合背包的总时间复杂度等于三种 transition strategy 各自成本的线性叠加:

T_mixed = Σ_01 O(V) + Σ_完全 O(V) + Σ_多重 O(V·log Mᵢ)

O((N01+Nco)V+NmuVlogMˉ)O((N_{01}+N_co)\cdot V + N_mu\cdot V\cdot \log \bar{M})每件物品的处理成本只取决于其类型,与其他物品无关——这就是「代价可加性」,它解释了为何把三种基础形式混在一起不会让问题更难。这也回答了直觉三遗留的疑问:把完全物品强行视为「V/C\lfloor V/C\rfloor 个 01」,会让该类成本从 O(V)O(V) 退化为 O(VV/C)=O(V2/C)O(V\cdot \lfloor V/C\rfloor ) = O(V^2/C),失去顺序循环的常数级优势。

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。它们共享:同一个状态空间 F[0..V]F[0..V];同一种「加入物品后取较优」的更新逻辑 F[v]max(F[v],F[vC]+W)F[v] \leftarrow \max (F[v], F[v-C]+W)。差异仅在「加入」这个动作的合法性约束:

  • 01 · 每种至多 1 次 → 逆序枚举保证 F[vC]F[v-C] 是「未含本物品」的上层状态;
  • 完全 · 任意次数 → 顺序枚举允许 F[vC]F[v-C] 已含本物品;
  • 多重 · 至多 Mᵢ 次 → 二进制拆分后归约为 logMi\log M_i 件 01 物品。

确认这一点后,「分发模式」的真正含义浮出水面:被分发的不是三种算法,而是同一 DP 框架下的三种 transition strategy。这把话题从「如何写代码」提到了「如何抽象 DP 的转移」——一个层级更高的位置。

2.2 · 为什么不写一个「统一」的 Pack 函数?

三个过程的接口都形如 Pack(F,C,W,)Pack(F, C, W, \dots ),自然会想:写一个 Pack(F, C, W, M, type),内部用 if 按 type 分支,不就一劳永逸?这是个合理的疑问——答案是可以,但这恰恰违背了抽象的目的。三过程的接口相同,内部语义却本质不同:

  • 01 与完全的循环方向相反(v 递减 vs 递增),对 F[vC]F[v-C] 是「上层」还是「本层」的语义需求不同;
  • 多重不仅有循环方向,还可能触发二进制拆分这一不同结构的子算法——内层根本不是单层 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 数组」——F[v]F[v] 始终表示「在当前已处理的物品集合中、费用不超过 v 时的最大价值」。每次 Pack(F, item) 无论 item 哪一类,本质都是把这件物品纳入「已处理集合」。三过程的差异不在 F 的语义,而在「加入」的合法性约束(见「三种背包过程」一节)。

分发正确性 · 共同不变式。任意 Pack 过程处理完第 i 个物品后,F[v]F[v] 都保持同一语义——「在已处理物品 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 按券的类型采用相应件数上界——1/M/V/C{1 / M / \lfloor V/C\rfloor}——给出最优券组合。

观察。同一份 DP 一次完成求解,按券类型分别采用相应件数上界——未引入任何「特殊逻辑」。额度较低时 DP 优先选取性价比 W/C 最高的券(首选 A 店新客 满200-50 性价比 0.25、B 店新客 满300-60 0.20,均 01);这两张在 ¥500 即用完配额,余额让位给多重满减券。额度抬高后多重券逐一达到 Mᵢ(MAXED);额度极宽时余量由平台叠加券(完全,无件数限制)填补——恰对应三类件数上限的本质差异 1/Mi/V/C{1 / M_i / \lfloor V/C\rfloor}

NOTES · 工程实践中的常见误用 · 类型分发错位

反面对照——一类实际项目中容易出现的错误:把「每种限购 Mᵢ 件」的多重物品错误地按「无限供应」(完全物品)处理。这种错误在性能压力下尤其常见——开发者为避开多重背包的二进制拆分实现,可能想「完全背包循环更简单,先用着、容量大概也够」,于是把限量品误当作可无限取。取多重物品 (C,W,M)=(3,5,2)V=18:

  • 正确(分发至 MultiplePack):至多选 2 件,价值 25=10{2\cdot 5=10},剩余容量 12 留给其他物品。
  • 错误(误分发至 CompletePack,忽略 M):视为无上限,18/3=6\lfloor 18/3\rfloor =6 件,价值 65=30{6\cdot 5=30}。这一虚高 200% 的「最优值」对应方案根本不合法——题面只允许 2 件。

更糟的是:错误实现的输出表面上「看起来更优」,且程序不报错——既无运行时异常,也无明显数值溢出,这类 bug 很可能潜伏到生产。启示:物品的「类型」标记是问题语义的一部分,不是可选修饰字段。分发逻辑必须与物品定义严格对应;工程实现中类型字段应在分发入口处显式校验(如 default 分支抛异常),让缺类型 / 错类型立即暴露。

本讲的方法论意义。01、完全、多重背包各自都不难,但把它们组合起来便得到一道看似复杂的题目;只要领会三种基本背包的思想,就能把组合后的题拆分成已解决的子问题。本讲不引入新算法,价值在于揭示一种贯穿系列的方法论:识别正交的子问题、为每个子问题定义清晰统一的接口、组合时仅需调度而无需推导。这一思路将在第 7 讲(依赖背包)的「主件附件视为物品组」中再次出现,并在第 8 讲(泛化物品)中被推到极致——所有背包形式被统一为「对函数 h(v) 的求和」。

NOTES · 关键概念出处 · 延伸阅读 · 通向第 5 讲

关键概念出处。「按类型分发」对应面向对象编程中的多态(polymorphism)与函数式编程中的类型派发(typed dispatch)。其工程学意义最早由 Bertrand MeyerObject-Oriented Software Construction(1988)中以「开闭原则」形式系统阐述:软件实体应对扩展开放、对修改封闭。

延伸阅读。「主循环 + 过程库」模式可推广至更广泛的组合优化问题。例如网络流中「按弧类型选择更新规则」、几何中「按图元类型选择求交算法」,均采用类似结构。核心在于:确认所有子算法在状态空间接口契约上的一致性,使分发逻辑保持简洁。

通向第 5 讲 · 二维费用背包第 5 讲把「每件物品占用一种资源」放宽为「同时占用两种资源」,得到二维费用背包。状态空间由 F[v]F[v] 扩展为 F[v][u]F[v][u],而循环方向、初始化等核心思想不变——本讲的「分发模式」亦可平滑应用于二维情形。