核心逻辑与数学原理
复杂背包问题(如混合背包、分组背包)的本质是状态空间的降维映射与决策树的剪枝。在考场上面对复杂的限制条件,我们需要通过重新定义“阶段”与“状态内层互斥性”,将非线性的限制强行映射到线性的背包轴上。
1. 分组背包 (Grouped Knapsack) 的互斥逻辑
分组背包规定:物品被划分为若干组,每组内的物品相互互斥(即每组至多只能选一件)。
- 底层冲突:如果按照普通 01 背包的逻辑,外层枚举物品,内层倒序枚举容量,会导致同组内的多件物品被同时选入背包,直接违反“至多选一件”的互斥约束。
- 三维拓扑序重排:为了在空间压缩后依然维持互斥性,必须重排循环拓扑序:先枚举组,再倒序枚举容量,最后枚举组内的具体物品。由于容量循环在物品循环之外,当我们在当前组内尝试不同物品时,它们所引用的前驱状态都是“上一组处理完后的纯净状态”。这就保证了组内的各种选择之间是并列、互斥的关系,不可能发生“用组内物品 $A$ 更新后的状态去支撑组内物品 $B$ 的选择”的情况。
2. 混合背包 (Mixed Knapsack) 的多态融合
混合背包是指有的物品只能取一次(01),有的能取无限次(完全),有的能取有限次(多重)。
- 多态降维映射:无需为每种物品设计独立的 DP 方程。利用第 14.3 节的结论,多重背包可以通过二进制拆分完全转化为 01 背包。因此,整个混合背包在底层被抽象为两种元状态转移:01 转移(倒序循环)与完全转移(正序循环)。在遍历物品阶段,根据物品的类型特征,动态切换内层容量循环的方向即可。
状态设计与算法推导
分组背包的数学推导与一维降维
- 状态设计:设 $f[j]$ 表示当前背包容量恰好或不超过 $j$ 时所能获得的最大价值。
- 转移方程:设第 $k$ 组的物品集合为 $G_k$,组内某个物品的体积为 $w$,价值为 $v$。
$$f[j] = \max \bigg( f[j], \max_{(w, v) \text{ in } G_k} \bigg\{ f[j - w] + v \bigg\} \bigg)$$
- 循环控制(严禁颠倒):
for (int k = 1; k <= T; ++k) { // 1. 先枚举组别 for (int j = V; j >= 0; --j) { // 2. 再倒序枚举容量 for (auto& item : G[k]) { // 3. 最后枚举组内物品 if (j >= item.w) f[j] = max(f[j], f[j - item.w] + item.v); } } }
C++ 标准源码(NOIP风格)
以下源码在一个程序中融合了分组背包与混合背包的考场标准实现,完美适配 Linux g++ -O2 环境。
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
const int MAXV = 1005;
int f[MAXV];
struct Item {
int w, v;
};
// 存储分组背包的数据结构
vector<Item> group[105];
int main() {
ios_base::sync_with_stdio(false);
cin.tie(NULL);
int n, max_v;
if (!(cin >> n >> max_v)) return 0;
// ==================== 典型场景 1: 混合背包处理 ====================
for (int i = 1; i <= n; ++i) {
int w, v, p; // p 表示数量:p=-1 代表01背包,p=0 代表完全背包,p>0 代表多重背包
cin >> w >> v >> p;
if (p == -1) {
// 01背包:直接一维倒序
for (int j = max_v; j >= w; --j) {
f[j] = max(f[j], f[j - w] + v);
}
}
else if (p == 0) {
// 完全背包:直接一维正序
for (int j = w; j <= max_v; ++j) {
f[j] = max(f[j], f[j - w] + v);
}
}
else if (p > 0) {
// 多重背包:考场直接二进制拆分为 01 背包颗粒,原地消化
for (int k = 1; p >= k; k <<= 1) {
int cur_w = k * w;
int cur_v = k * v;
for (int j = max_v; j >= cur_w; --j) {
f[j] = max(f[j], f[j - cur_w] + cur_v);
}
p -= k;
}
if (p > 0) {
int cur_w = p * w;
int cur_v = p * v;
for (int j = max_v; j >= cur_w; --j) {
f[j] = max(f[j], f[j - cur_w] + cur_v);
}
}
}
}
// ==================== 典型场景 2: 分组背包处理 ====================
int m, g_num; // m 件物品,g_num 个组
cin >> m >> g_num;
for (int i = 1; i <= m; ++i) {
int w, v, g_id;
cin >> w >> v >> g_id;
group[g_id].push_back({w, v});
}
for (int k = 1; k <= g_num; ++k) {
// 致命踩坑点:容量循环必须夹在“组循环”与“组内物品循环”中间
for (int j = max_v; j >= 0; --j) {
for (const auto& item : group[k]) {
if (j >= item.w) {
f[j] = max(f[j], f[j - item.w] + item.v);
}
}
}
}
cout << f[max_v] << "\n";
return 0;
}
NOIP 实战避坑指南
1. 分组背包错将物品循环写在容量循环之外
- 现象:将分组背包写成了:先枚举组 $k$,再枚举组内物品,最后倒序枚举容量 $j$。这会导致严重的逻辑崩溃。 因为这样写在底层等价于对组内的每一件物品各自跑了一次独立的 01 背包更新。同一组内的物品 $A$ 更新了 $f[j]$ 后,物品 $B$ 在紧接着的循环里会读取这个已经被更新过的 $f[j]$ 去更新更靠后的状态,导致同一个组内的多件物品被同时选入背包。
- 规避手段:死记分组背包的三层循环嵌套顺序:组 $\to$ 容量(逆序) $\to$ 物品。
2. 混合背包多重背包拆分时未动态同步容量边界
- 现象:在混合背包中处理多重背包的二进制拆分时,部分选手习惯性地将拆分出来的新物品体积
cur_w直接用原物品体积w代替,或者内层倒序循环的下界误写为原物品体积w而非cur_w。这会导致数组下标越界(读取负数内存)或状态漏刷。 - 规避手段:拆分出的新物品是一个体积为 $k \times w$、价值为 $k \times v$ 的全新独立个体,内层 01 背包倒序循环的截止下界必须严格等于这个新个体的总体积
cur_w。
经典 NOIP/洛谷 真题
1. 洛谷 P1757 通天之分组背包
- 题意描述:自适应背包问题。给定背包容量 $m$ 和 $n$ 个物品,每个物品有重量 $w_i$、价值 $v_i$ 以及所属组号 $c_i$。同一组内的物品相互冲突,至多只能选一件。求最大总价值。
- 问题本质与解题思路:标准分组背包模型。
- 核心思路:使用
std::vector数组作为邻接表,以组号 $c_i$ 为索引将物品分类存储。严格按照“组 $\to$ 容量(逆序) $\to$ 组内物品”的三层循环结构进行状态更新。由于数据范围较小($m, n \le 1000$),该算法在时空上拥有极高裕量。
2. 洛谷 P1064 [NOIP2006 提高组] 金明的预算方案
- 题意描述:金明有 $N$ 元钱,想买 $m$ 件物品。物品分为主件与附件,附件必须在购买了对应的主件后才能购买。每个主件至多有 2 个附件。每件物品有价格 $v_i$ 和重要度 $w_i$,要求在总花费不超过 $N$ 的情况下,使得购买物品的价格与重要度乘积之和最大。
- 问题本质与解题思路:带有依赖关系的背包问题,本质上可以通过强制穷举策略降维映射为标准的分组背包。
- 核心思路:由于每个主件至多只有 2 个附件,对于任意一个主件而言,它的购买决策组合是极其有限且互斥的。每个主件及其附件群可以绑定为独立的一个“组”。这个组内至多包含 4 种互斥的决策状态:
- 只买主件;
- 买主件 + 附件 1;
- 买主件 + 附件 2;
- 买主件 + 附件 1 + 附件 2。 将这 4 种组合的总体积与总价值分别计算出来,作为 4 个并列的物品存入该主件对应的组中。接下来直接套用标准分组背包的循环控制结构,即可将复杂的树状依赖限制完美降维,转化为线性空间上的互斥选择。