Bootstrap

混合背包三类问题

欢迎访问我的博客首页


  背包问题不考虑空隙,只要物体体积之和不大于背包容量,就能放进去。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;
}

4. 多重背包


5. 参考


  1. 完全背包和多重背包
  2. 测试用例
  3. 背包问题

悦读

道可道,非常道;名可名,非常名。 无名,天地之始,有名,万物之母。 故常无欲,以观其妙,常有欲,以观其徼。 此两者,同出而异名,同谓之玄,玄之又玄,众妙之门。

;