欢迎访问我的博客首页。
背包
背包问题不考虑空隙,只要物体体积之和不大于背包容量,就能放进去。AcWing 的前几题都是背包问题。
基本背包问题有《01背包》、《完全背包》、《多重背包》。《01背包》问题中每种物品有 1 个,《完全背包》问题中每种物品有无穷个,《多重背包》问题中每种物品有有限多个。这三类背包问题是《混合背包》问题的特例,都是求背包能装下的最大价值。下面先介绍《混合背包》,再介绍它的三个特例。
1. 混合背包
混合背包中的每个物品可以有 1个、n 个、无穷个,求背包能装下的最大价值。
题目来自 AcWing:有 N 种物品和一个容量是 V 的背包。物品一共有三类:
- 第一类物品只能用1次(01背包);
- 第二类物品可以用无限次(完全背包);
- 第三类物品最多只能用 si 次(多重背包);
每种体积是 vi,价值是 wi。求解将哪些物品装入背包,可使物品体积总和不超过背包容量,且价值总和最大。输出最大价值。
1.1 使用动态规划算法输出最大价值
只输出背包能装下的最大价值,不能输出装哪些物品。
#include<iostream>
#include<vector>
#include<algorithm>
using namespace std;
struct Object {
Object(int _s = 0, int _v = 0, int _w = 0) :s(_s), v(_v), w(_w) {
if (s == -1)
s = 1;
else if (s == 0)
s = INT_MAX;
}
int s, v, w;
};
int package(vector<Object>& objects, int V) {
if (objects.size() == 0 || V <= 0)
return 0;
int N = objects.size();
vector<vector<int>> dp(V + 1, vector<int>(N));
// 1.给定最小问题的解dp[:][0]。
for (int i = 0; i <= V; i++)
dp[i][0] = min(objects[0].s, i / objects[0].v) * objects[0].w;
// 2.用最小问题的解推导更大问题的解dp[:][1:]。
for (int i = 0; i <= V; i++) {
for (int j = 1; j < N; j++) {
int k_max = min(objects[j].s, i / objects[j].v);
if (k_max == 0) {
dp[i][j] = dp[i][j - 1];
continue;
}
for (int k = 1; k <= k_max; k++) {
dp[i][j] =
max(dp[i - (k - 1) * objects[j].v][j - 1],
dp[i - k * objects[j].v][j - 1] + k * objects[j].w);
}
}
}
return dp[V][N - 1];
}
// dp[i][j]=x:把objects[0:j]这j+1个物体放入容积为i的容器中,得到的最大价值是x。
int main() {
int N, V;
cin >> N >> V;
int v, w, s;
vector<Object> objects;
while (N--) {
cin >> v >> w >> s;
objects.push_back(Object(s, v, w));
}
cout << package(objects, V) << endl;
system("pause");
}
1.2 使用分治算法输出最大价值
因为动态规划算法都可以转换成分治算法,所以下面使用分治算法输出背包能装下的最大价值。
int package(vector<Object>& objects, int V, int N) {
if (objects.size() == 0 || V <= 0)
return 0;
if (N == 0)
return min(objects[0].s, V / objects[0].v) * objects[0].w;
int k_max = min(objects[N].s, V / objects[N].v);
if (k_max == 0)
return package(objects, V, N - 1);
int res;
for (int k = 1; k <= k_max; k++) {
res =
max(package(objects, V - (k - 1) * objects[N].v, N - 1),
package(objects, V - k * objects[N].v, N - 1) + k * objects[N].w);
}
return res;
}
1.3 使用分治算法输出最大价值和装包方法
利用分治算法可以知道达到最大价值需要哪些物品及其数量。i_sol 用于记录装哪些物品,其键代表物品在 objects 中的序号,值代表使背包装得最大价值时该类物品需要装多少个。因为 i_sol 的元素在入栈过程中添加,所以最好使用形参回传结果而不是返回值回传结果。
void package(
vector<Object>& objects, int V, int N,
vector<map<int, int>>& res_sol, int& res_max,
map<int, int> i_sol = map<int, int>{}, int i_max = 0) {
// 1.背包没有空间。
if (V <= 0) {
if (i_max > res_max) {
res_max = i_max;
res_sol.clear();
res_sol.push_back(i_sol);
}
else if (i_max == res_max)
res_sol.push_back(i_sol);
return;
}
// 2.背包有空间,但只有一种物品可以装。
if (N == 0) {
int n = min(objects[0].s, V / objects[0].v);
if (n == 0)
return;
i_max += n * objects[0].w;
if (i_max > res_max) {
res_max = i_max;
res_sol.clear();
i_sol[0] += n;
res_sol.push_back(i_sol);
return;
}
else if (i_max == res_max) {
i_sol[0] += n;
res_sol.push_back(i_sol);
return;
}
return;
}
// 3.背包有空间且可选物品不少于2种。
int k_max = min(objects[N].s, V / objects[N].v);
// 3.1不放objects[N]。
package(objects, V, N - 1, res_sol, res_max, i_sol, i_max);
// 3.2放k个objects[N]。
for (int k = 1; k <= k_max; k++) {
i_sol[N] += k;
i_max += k * objects[N].w;
package(objects, V - k * objects[N].v, N - 1, res_sol, res_max, i_sol, i_max);
// 回溯。
i_sol[N] -= k;
i_max -= k * objects[N].w;
}
}
1.4 使用引用类型的参数节约内存和拷贝时间
使用引用类型的 res_sol 可以减少内存占用且避免拷贝。
void package(
vector<Object>& objects, int V, int N,
vector<map<int, int>>& res_sol, int& res_max,
map<int, int>& i_sol = map<int, int>{}, int i_max = 0) {
// 1.背包没有空间。
if (V <= 0) {
if (i_max > res_max) {
res_max = i_max;
res_sol.clear();
res_sol.push_back(i_sol);
}
else if (i_max == res_max)
res_sol.push_back(i_sol);
return;
}
// 2.背包有空间,但只有一种物品可以装。
if (N == 0) {
int n = min(objects[0].s, V / objects[0].v);
if (n == 0)
return;
i_max += n * objects[0].w;
if (i_max > res_max) {
res_max = i_max;
res_sol.clear();
i_sol[0] += n;
res_sol.push_back(i_sol);
// 回溯。
i_sol[0] -= n;
if (i_sol[0] == 0)
i_sol.erase(0);
return;
}
else if (i_max == res_max) {
i_sol[0] += n;
res_sol.push_back(i_sol);
// 回溯。
i_sol[0] -= n;
if (i_sol[0] == 0)
i_sol.erase(0);
return;
}
return;
}
// 3.背包有空间且可选物品不少于2种。
int k_max = min(objects[N].s, V / objects[N].v);
// 3.1不放objects[N]。
package(objects, V, N - 1, res_sol, res_max, i_sol, i_max);
// 3.2放k个objects[N]。
for (int k = 1; k <= k_max; k++) {
i_sol[N] += k;
i_max += k * objects[N].w;
package(objects, V - k * objects[N].v, N - 1, res_sol, res_max, i_sol, i_max);
// 回溯。
i_sol[N] -= k;
if (i_sol[N] == 0)
i_sol.erase(N);
i_max -= k * objects[N].w;
}
}
2. 01背包
《01背包》问题中的每个物体最多能装 1 个。下面我们先解决一个《01背包》问题的例子,再分析。
2.1 问题
题目来自 AcWing:有 N 件物品和一个容量是 V 的背包。每件物品只能使用一次。第 i 件物品的体积是 vi,价值是 wi。求解将哪些物品装入背包,可使这些物品的总体积不超过背包容量,且总价值最大。输出最大价值。
物品的件数 N 和背包容量 V 都是 “大问题”,我们要划分这两项。这意味着我们的算法需要两层循环,且需要一个二维的 dp 数组。我们先这样实现,然后再看能不能优化。
#include<iostream>
#include<vector>
#include<algorithm>
using namespace std;
struct Object {
Object(int _v = 0, int _w = 0) :v(_v), w(_w) {}
int v, w;
};
int zeroone_package1(vector<Object>& objects, int V) {
if (objects.size() == 0 || V<= 0)
return 0;
int N = objects.size();
vector<vector<int>> dp(V + 1, vector<int>(N, 99));
// 1.最小问题的解dp[:][0]。
for (int i = 0; i <= V; i++)
dp[i][0] = i < objects[0].v ? 0 : objects[0].w;
// 2.递推dp[:][1:]。
for (int i = 0; i <= V; i++) {
for (int j = 1; j < N; j++) {
if (i >= objects[j].v)
dp[i][j] = max(dp[i][j - 1], dp[i - objects[j].v][j - 1] + objects[j].w);
else
dp[i][j] = dp[i][j - 1];
}
}
return dp[V][N - 1];
}
int main() {
int N, V;
cin >> N >> V;
int v, w;
vector<Object> objects;
while (N--) {
cin >> v >> w;
objects.push_back(Object(v, w));
}
cout << zeroone_package1(objects, V) << endl;
}
上面的代码中,第一层循环划分背包容量 V,第二层循环划分物品的件数 N。这两层循环没有先后顺序,可以交换位置:
int zeroone_package2(vector<Object>& objects, int V) {
if (objects.size() == 0 || V<= 0)
return 0;
int N = objects.size();
vector<vector<int>> dp(N, vector<int>(V + 1, 99));
// 1.最小问题的解dp[0][:]。
for (int j = 0; j <= V; j++)
dp[0][j] = j < objects[0].v ? 0 : objects[0].w;
// 2.递推dp[1:][:]。
for (int i = 1; i < N; i++) {
for (int j = 0; j <= V; j++) {
if (j >= objects[i].v)
dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - objects[i].v] + objects[i].w);
else
dp[i][j] = dp[i - 1][j];
}
}
return dp[N - 1][V];
}
顺序容器 vector 的初始化有下面两种方法。上面的代码中初始化为 99 只是为了证明我们给定的最小问题的解是正确的。
vector<int> v1(10); // 创建长度为10的vector,每个元素初始化为0。
vector<int> v2(10, 1); // 创建长度为10的vector,每个元素初始化为1。
2.2 空间优化
观察 zeroone_package2 函数第 14 行的递推公式会发现,dp[i][:] 仅与 dp[i-1][:] 有关,与 dp[0:i-2][:] 都无关。这就说明我们可以使用一维的 dp 数组保存 dp[i-1] 的数据。现在我们来讨论两个问题:
问题1:能不能像函数 zeroone_package1 那样,第一层循环划分背包容量 V,第二层循环划分物品的件数 N?答案是不能。因为假如这样的话, dp[j]=x:就代表尝试了前 j+1 件物品得到的最大价值是 x。这样的 dp 不好递推,而且 dp 的数据与我们所求无关。而像函数 zeroone_package2 那样,第一层循环划分物品的件数 N,第二层循环划分背包容量 V,dp[j]=x:就代表容积为 j 的背包能装下的最大价值。这样的 dp 任意递推,而且 dp[V] 既是我们所求。
问题2:在动态规划算法中,总是用小问题的解求大问题的解,即 dp[j] = max(dp[j], dp[j - objects[i].v] + objects[i].w)。i=0 时我们得到了全零的 dp[0:V],i = 1 时我们要更新 dp。如果我们还是像函数 zeroone_package1 和函数 zeroone_package2 那样,j 从小向大更新,那么 i=0 时的数据会被马上覆盖掉。比如 dp[1]=dp[1] + dp[0],等号后面的数据 dp[1] 和 dp[0] 是 i=0 时计算出来的,等号前面的数据 dp[1] 被更新成 i=1 时的数据。接下来更新 dp[2] 或 更大的 j 时,就无法用到 i=1 时的 dp[1] 了。
综上所述,使用一维的 dp 数组时,第一层循环划分物品的件数 N,第二层循环划分背包容量 V
int zeroone_package(vector<Object>& objects, int V) {
if (objects.size() == 0 || V<= 0)
return 0;
int N = objects.size();
vector<int> dp(V + 1, 99);
// 1.为求最大值做准备dp([0])[:]=0。
for (int i = 0; i <= V; i++)
dp[i] = 0;
// 2.递推dp([1:])[:]。
for (int i = 0; i < N; i++) {
for (int j = V; j >= 0; j--) {
if (j >= objects[i].v)
dp[j] = max(dp[j], dp[j - objects[i].v] + objects[i].w);
}
}
return dp[V];
}
2.2 递推公式
背包问题通常用动态规划算法解决。动态规划算法首先处理小问题,本题中,容量为 V 的背包是个需要划分的大问题,它的最小问题是容积为 0 的背包;N 件物品也是个大问题,它的最小问题是第 1 件物品。所以我们需要两层循环,用最小问题 “容积为 0 的背包和第 1 件物品” 的解来推导出最大问题 “容量为 V 的背包和 N 件物品” 的解。于是需要两层循环。
for (int i = 0; i <= V; i++) {
for (int j = 0; j < N; j++) {
if (objects[j].vol <= i)
dp[i][j] = max(dp[i - objects[j].vol][j - 1] + objects[j].val, dp[i][j - 1]);
else
dp[i][j] = dp[i][j - 1];
}
}
2.3 边界值的初始化
所谓的边界值,就是最小问题的解。因为动态规划算法是用最小问题的解推导出最大问题的解,所以我们需要告诉动态规划算法最小问题的解是什么。
动态规划算法用数组 dp 存放问题的解,初始化 dp 就是告诉动态规划算法最小问题的解。如果最小问题的解是 0,我们可以直接全 0 初始化 dp,但更多时候最小问题的解并不是 0,所以怎么初始化 dp 是个值得注意的问题。
虽然 dp 的初始化要在递推公式的实现之前,但怎么初始化边界值却要在递推公式实现之后才容易确定。以上面的代码为例,第一部分初始化边界值 dp[:][0],第二部分实现递推公式。通常我们无法知道哪些边界值需要初始化,所以实际编码时我们先写第二部分,而且是从最小问题开始。因为最小问题是 “容积为 0 的背包和第 1 件物品”,所以代表容积的 i 从 0 取值,代表物品的下标 j 也从 0 开始。实现递推公式后,我们会发现,递推公式中 j - 1 会作为下标,所以 j 不能等于 0。于是我们就知道递推公式中的 j 要从 1 开始,而 dp[:][0] 需要事先初始化。
3. 完全背包
与 01 背包相比,完全背包的每一种物体数量无限。
题目来自 AcWing:有 N 种物品和一个容量是 V 的背包,每种物品都有无限件可用。第 i 种物品的体积是 vi,价值是 wi。求解将哪些物品装入背包,可使这些物品的总体积不超过背包容量,且总价值最大。输出最大价值。
#include<iostream>
#include<vector>
#include<algorithm>
using namespace std;
struct Object {
Object(int _vol = 0, int _val = 0) :vol(_vol), val(_val) {}
int vol, val;
};
int complete_package(vector<Object>& objects, int capacity) {
if (objects.size() == 0 || capacity <= 0)
return 0;
vector<int> dp(capacity + 1);
for (int i = 0; i <= capacity; i++)
dp[i] = 0;
for (int i = 0; i <= capacity; i++)
for (int j = 0; j < objects.size(); j++) {
if (objects[j].vol <= i)
dp[i] = max(dp[i - objects[j].vol] + objects[j].val, dp[i]);
}
return dp[capacity];
}
// dp[i]=x:把objects[0:i]这i+1个物体放入容积为i的容器中,得到的最大价值是x。
int main() {
int examples, capacity;
vector<Object> objects;
cin >> examples >> capacity;
int voli, vali;
while (examples--) {
cin >> voli >> vali;
objects.push_back(Object(voli, vali));
}
cout << complete_package(objects, capacity) << endl;
}