背包问题 (Knapsack)
背包问题包括:
- 01 背包
- 完全背包
- 多重背包
- 分组背包
- 二维背包
众所周知,背包问题是一个 NPC 问题,目前没有多项式时间解法,但可以通过伪多项式时间的「动态规划」解决。
01 背包
问题描述
有 \(N\) 件物品和容量为 \(V\) 的背包,放入第 \(i\) 个物品的耗费是 \(C_i\) ,获得价值是 \(W_i\) ,每个物品仅能放进一次。问:
- 能获得的最大价值。
- 装入哪些物品能获得最大价值。
转移方程
每个物品只有选择或者不选择 2 种情况。
令 \(dp[i, j]\) 表示在前 \(i\) 物品中,背包容量为 \(j\) 的条件下,能获得的最大价值。
当 \(i = 0\) 或者 \(j = 0\) 时,\(dp[i, j] = 0\) 。
时间和空间复杂度为 \(O(NV)\) ,空间复杂度可以优化为 \(O(V)\) .
代码
/**
* @items - The volume of given items.
* @vals - The value of given items, len(vals) == len(items).
* @cap - The capacity of knapsack.
* Return the max-value we can get.
*/
int knapsack(vector<int> &items, vector<int> &vals, int cap)
{
int n = items.size();
vector<vector<int>> dp(n + 1, vector<int>(cap + 1, 0));
for (int i = 1; i <= n; ++i)
{
int item = items[i - 1], val = vals[i - 1];
for (int j = 1; j <= cap; ++j)
{
if (j >= item)
dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - item] + val);
else
dp[i][j] = dp[i - 1][j];
}
}
return dp[n][cap];
}
空间优化
int knapsack(vector<int> &items, vector<int> &vals, int cap)
{
int n = items.size();
vector<int> dp(cap + 1, 0);
for (int i = 0; i < n; ++i)
{
int item = items[i], val = vals[i];
for (int j = cap; j >= item; --j)
dp[j] = max(dp[j], val + dp[j - item]);
}
return dp[cap];
}
完全背包
完全背包,也叫 "Completed Knapsack" 或者 "Unbounded Knapsack" .
问题描述
与 01 背包条件相同,但物品可以无限次使用。
转移方程
\(dp[i, j]\) 表示在前 \(i\) 物品中,背包容量为 \(j\) 的条件下,能获得的最大价值。
代码
评测:https://www.luogu.com.cn/problem/T164644
int knapsack(vector<int> &items, vector<int> &vals, int cap)
{
int n = items.size();
vector<int> dp(cap + 1, 0);
for (int i = 0; i < n; ++i)
{
int item = items[i], val = vals[i];
for (int j = item; j <= cap; ++j)
dp[j] = max(dp[j], val + dp[j - item]);
}
return dp[cap];
}
注意,此处与 01 背包的区别是:内层循环是 item -> cap
,而 01 背包是 cap -> item
,原因是:01 背包的 dp[j]
依赖的是上一行的 dp[j]
,而完全背包依赖的是当前行的 dp[j]
。
多重背包
问题描述
有 \(N\) 种物品和一个容量为 \(V\) 的背包。第 \(i\) 种物品最多有 \(M_i\) 件可用,每件耗费的空间是 \(C_i\),价值是 \(W_i\) 。求解:将哪些物品装入背包可使这些物品的耗费的空间总和不超过背包容量,且价值总和最大。
一个直观的方法是:把表示物品的数组
items
展开,把items[i]
重复 \(M_i\) 次,然后套用 01 背包的算法。但这种方法的空间复杂度受到 \(\sum{M_i}\) 的限制。
转移方程
对于第 \(i\) 物品,有 \(M_i + 1\) 种处理策略:取 0 次,取 1 次,...,取 \(M_i\) 次。
基于 01 背包,令 \(dp[i, j]\) 表示在前 \(i\) 个物品中,背包容量为 \(j\) 的条件下,能获得的最大价值。
时间复杂度是 \(O(N \cdot \sum{M_i})\) .
代码
/**
* @nums - nums[i] denote the number of item[i].
* len(items) == len(vals) == len(nums)
*/
int knapsack(vector<int> &items, vector<int> &vals, vector<int> &nums, int cap)
{
int n = items.size();
vector<vector<int>> dp(n + 1, vector<int>(cap + 1, 0));
for (int i = 1; i <= n; ++i)
{
int item = items[i - 1], m = nums[i - 1], val = vals[i - 1];
for (int j = 1; j <= cap; ++j)
{
dp[i][j] = dp[i - 1][j];
for (int k = 0; k <= m && j >= k * item; ++k)
dp[i][j] = max(dp[i][j], dp[i - 1][j - k * item] + k * val);
}
}
return dp[n][cap];
}
二进制优化
评测:https://www.luogu.com.cn/problem/P1776
考虑到放进背包中的每个物品的数量 \(N_i\) 都可以写成 2 的幂次之和。
将第 \(i\) 种物品分成若干件 01 背包中的物品,其中每件物品有一个系数,拆分后物品的费用和价值均是原来的费用和价值乘以这个系数。令这些系数分别为 \(1,2,2^2, ..., 2^{k−1},M_i − 2^{k}+1\),且 \(k\) 是满足 \(M_i − 2^{k} + 1 > 0\) 的最大整数。
可以证明,任意的 \(N_i \in [1, M_i]\) 均可以通过这些系数相加得到。
- 假设物品
item
的数量是 13 ,其价值为val
。 - 可以把
item
拆分为{j * item, j * val}
,其中j = 1,2,4,6
. - 考虑到每个 \(M_i\) 总是为一个 32 位或者 64 位的整数,因此每个物品的拆分所需要的空间复杂度是常数级的,最终的空间复杂度为 \(O(N)\) .
算法正确性的证明可以分 \(0, ..., 2^{k−1}\) 和 \(2^k, ..., M_i\) 两段来分别讨论得出。
int ZeroOneKnapsack(vector<int> &items, vector<int> &vals, int cap)
{
int n = items.size();
vector<int> dp(cap + 1, 0);
for (int i = 0; i < n; ++i)
{
int item = items[i], val = vals[i];
for (int j = cap; j >= item; --j)
dp[j] = max(dp[j], val + dp[j - item]);
}
return dp[cap];
}
/**
* @nums - nums[i] denote the number of item[i].
* len(items) == len(vals) == len(nums)
*/
int MultipleKnapsack(vector<int> &items, vector<int> &vals, vector<int> &nums, int cap)
{
vector<int> newItems, newVals;
int n = items.size();
for (int i = 0; i < n; ++i)
{
int num = nums[i], item = items[i], val = vals[i];
for (int k = 1; k <= num; k <<= 1)
{
newItems.push_back(k * item);
newVals.push_back(k * val);
num -= k;
}
if (num > 0)
{
newItems.push_back(num * item);
newVals.push_back(num * val);
}
}
return ZeroOneKnapsack(newItems, newVals, cap);
}
分组背包
问题描述
有 \(N\) 件物品和一个容量为 \(V\) 的背包。第 \(i\) 件物品的费用是 \(C_i\),价值是 \(Wi\)。这些物品被划分为 \(K\) 组,每组中的物品互相冲突,最多选一件。求解将哪些物品装入背包,可使这些物品的费用总和不超过背包容量,且价值总和最大。
转移方程
对于每组物品,面临 2 种选择:取其中一件物品,或者一件都不取。
令 \(dp[k, j]\) 表示前 \(k\) 组物品中,背包容量为 \(j\) 的条件下,能获得的最大价值。
代码
/**
* @items - item[k][i] = {cost, val}, denote the cost and value of i-th item in k-th group
* @cap - the capacity of knapsack
*/
struct Item { int cost = 0, val = 0; };
int GroupKnapsack(vector<vector<Item>> &items, int cap)
{
int groups = items.size();
vector<vector<int>> dp(groups + 1, vector<int>(cap + 1, 0));
for (int k = 1; k <= groups; ++k)
{
for (int j = 0; j <= cap; ++j)
{
dp[k][j] = dp[k - 1][j];
for (auto [cost, val] : items[k - 1])
dp[k][j] = max(dp[k][j], j >= cost ? (dp[k - 1][j - cost] + val) : 0);
}
}
return dp[groups][cap];
}
- 评测:https://www.luogu.com.cn/problem/P1757 , 注意:本题输入的分组编号
c
不一定是连续的,且c
可能很大导致访存错误,可通过map[c] = idx++
映射到[0, n)
的范围上。 - 空间复杂度为 \(O(KV)\),存在 MLE 的风险。
空间优化
/**
* @items - item[k][i] = {cost, val}, denote the cost and value of i-th item in k-th group
* @cap - the capacity of knapsack
*/
struct Item { int cost = 0, val = 0; };
int GroupKnapsack(vector<vector<Item>> &items, int cap)
{
int groups = items.size();
vector<int> dp(cap + 1, 0);
for (int k = 0; k < groups; ++k)
for (int j = cap; j >= 0; --j)
for (auto [cost, val] : items[k])
dp[j] = max(dp[j], j >= cost ? (dp[j - cost] + val) : 0);
return dp[cap];
}
Leetcode: 5269. Maximum Value of K Coins From Piles
二维背包
问题描述
二维费用的背包问题是指:对于每件物品,具有两种不同的费用,选择这件物品必须同时付出这两种费用。对于每种费用都有一个可付出的最大值(背包容量)。问怎样选择物品可以得到最大的价值。
设第 i
个物品所需的 2 种费用为 C[i], D[i]
,价值为 W[i]
,两种费用的付出上限为 V, U
(即背包的容量)。
转移方程
令 \(dp[i, u, v]\) 表示在 items[0, ..., i]
中,背包容量限制为 (u, v)
的条件下,能获得的最大价值。
代码
using vec = vector<int>;
using vec2 = vector<vec>;
using vec3 = vector<vec2>;
struct Item { int cost1, cost2, value; };
int Knapsack2D(vector<Item> &items, int cap1, int cap2)
{
int n = items.size();
vec3 dp(n + 1, vec2(cap1 + 1, vec(cap2 + 1, 0)));
for (int i = 1; i <= n; ++i)
{
auto [cost1, cost2, val] = items[i - 1];
for (int u = 0; u <= cap1; ++u)
{
for (int v = 0; v <= cap2; ++v)
{
dp[i][u][v] = max(
dp[i - 1][u][v],
(u >= cost1 && v >= cost2) ? (dp[i - 1][u - cost1][v - cost2] + val) : 0
);
}
}
}
return dp[n][cap1][cap2];
}
变种问题
输出可行解
一般而言,背包问题是要求一个最优值,如果要求输出这个最优值的一个可行解(最优值可能存在多个可行解),可以参照一般动态规划问题输出方案的方法:记录状态转移的路径。
以 01 背包为例,状态方程为:
令 path[i, j] = 0
,如果 dp[i, j]
取的是 \(\max\) 函数的前一项,令 path[i, j] = 1
如果取的是后一项。
int ZeroOneKnapsack(vector<int> &items, vector<int> &vals, int cap)
{
int n = items.size();
vector<vector<int>> dp(n + 1, vector<int>(cap + 1, 0));
vector<vector<bool>> path(n + 1, vector<bool>(cap + 1, 0));
for (int i = 1; i <= n; ++i)
{
int item = items[i - 1], val = vals[i - 1];
for (int j = 1; j <= cap; ++j)
{
if (j >= item)
{
dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - item] + val);
path[i][j] = (dp[i][j] == dp[i - 1][j]) ? 0 : 1;
}
else
dp[i][j] = dp[i - 1][j];
}
}
int j = cap;
vector<bool> selected(n, 0);
for (int i = n; i > 0; --i)
{
int item = items[i - 1];
if (path[i][j])
{
selected[i - 1] = 1;
j -= item;
}
}
return dp[n][cap];
}
求可行解的数目
上面提到,最优值的可行解可能不止一个,如果我们想要求解可行解的总数,应该怎么做呢?
一般只需将状态转移方程中的 \(\max\) 改成 \(\text{sum}\) 即可。例如完全背包问题中,转移方程为:
如果想要求解,背包装满 cap
容量的可行解的数目,那么:
初始条件为 \(dp[0, 0] = 1\) .
空间优化版本的代码为:
for (int item : items)
for (int j = item; j <= cap; ++j)
dp[j] = dp[j] + dp[j - item];
return dp[cap];