背包问题

0-1背包

普通做法

每件物品要么拿,要么不拿,只有 1 件,且不许拆分,这个时候,不能用贪心。例如“采药问题”,如果输入数据为:

10 3
60 6
40 5
40 5

从单位时间的采摘价值来看,第一株草药单位时间可采价值为 10,最大,所以用贪心做就会优先选择第一株,但显然是错的,采后两株能得到的总价值为 80,更大。原因即在于这里不允许拆第二株只采 4 分钟得到 32 的价值,要么采 5 分钟得到 40,要么得到 0。

由此可以看出,贪心无法解决“0-1背包”问题,需要使用动态规划思想。我们将草药作为阶段进行划分,用 f[i][j] 表示状态:在前 \(i\) 株草药中选择一些草药,时间限制为 \(j\) 的最大总价值。通过状态转移方程来进行阶段转移:

\[f[i][j] = max(f[i-1][j], f[i-1][j-time[i]] + value[i]); \]

最终要求的结果即为状态 f[N][T] 的值。

由此得到我们的第一个代码:

// f[i][j]:在前 i 株草药中选择一些草药,时间限制为 j 的最大总价值
// f[i][j] = max(f[i-1][j], f[i-1][j-time[i]]+value[i]);
#include <iostream>
#include <algorithm>
using namespace std;
const int C = 107, M = 1007;
int f[C][M];
int main() {
	int T, N;
	scanf("%d%d", &T, &N);
	for (int i = 1, time, value; i <= N; ++i) {
		scanf("%d%d", &time, &value);
		for (int j = 1; j <= T; ++j) {
			f[i][j] = f[i-1][j];
			if (j >= time)
				f[i][j] = max(f[i][j], f[i-1][j-time]+value);
		}
	}
	printf("%d\n", f[N][T]);
}

第一次改进:滚动数组

既然已经写出代码,我们就只看代码进行优化。

由上面的循环可知,f[i][j] 的值只由其上一行产生,与其余行无关。因此,我们在循环过程中,仅保存两行内容即可。

初始,我们令 u = 1,表示原来的第 \(i\) 行,它的上一行就是 u = 0

接着,我们通过 u ^= 1u 取反,变原来的“上一行”为新的“下一行”。

i		i-1		u		u^1
1		0		1		0
2		1		0		1
3		2		1		0
4		3		0		1
...

最终结果即转化为:f[N&1][T]

代码转换为:

// f[u][j] = max(f[u^1][j], f[u^1][j-t[i]]+v[i]);
#include <iostream>
#include <algorithm>
using namespace std;
const int M = 1007;
int f[2][M];
int main() {
	int T, N;
	scanf("%d%d", &T, &N);
	for (int i=1, u=1, t, v; i <= N; ++i, u^=1) {
		scanf("%d%d", &t, &v);
		for (int j = 1; j <= T; ++j) {
			f[u][j] = f[u^1][j];
			if (j >= t)
				f[u][j] = max(f[u][j], f[u^1][j-t]+v);
		}
	}
	printf("%d\n", f[N&1][T]);
}

第二次改进:降维

我们继续观察第一个代码,既然每一行都仅由上一行得出,那么我们能不能只用一行进行更新呢?

这有一个前提:如果新的一行的更新顺序是单调的(从左至右或从右至左,或从大到小或从小到大,总之有序),则可以按序将该行内容逐个更新,最终变为新的一行。

\(j \lt t\) 时,第 \(i\) 行的内容就是第 \(i-1\) 的内容,不需要进行更新。我们直接从 \(t\) 开始更新。从式子 f[i][j] = max(f[i-1][j], f[i-1][j-time]+value); 可以看出,第 \(j\) 列的内容是由上一行的第 \(j\) 列及第 \(j-time\) 列的旧值更新而来,所以在更新第 \(j\) 列以前,第 \(j-time\) 列应该保持原来的值,我们必须从大到小(从右到左)进行更新。

代码如下:

// f[j] = max(f[j], f[j-t[i]]+v[i]);
#include <iostream>
#include <algorithm>
using namespace std;
const int M = 1007;
int f[M];
int main() {
	int T, N;
	scanf("%d%d", &T, &N);
	for (int i=1, t, v; i <= N; ++i) {
		scanf("%d%d", &t, &v);
		for (int j = T; j >= t; --j)
			f[j] = max(f[j], f[j-t]+v);
	}
	printf("%d\n", f[T]);
}

总结一下,滚动数组仅对空间进行了优化,时间上保持不变;而降维同时优化了时间与空间。

完全背包

在背包问题中,当每种物品的数量没有上限时,我们称之为“完全背包”问题。其实不难发现,完全背包的每种物品也是有上限的(\(V/w[i]\)),其中 \(V\) 为背包的总体积,\(w[i]\) 为第 \(i\) 种物品的单件体积。

普通代码

我们直接可以根据"0-1背包"的思路,写出如下状态转移方程:

\[f[i][j] = max(f[i-1][j], f[i-1][j-w[i]]+v[i], f[i-1][j-2*w[i]]+2*v[i],...); \]

不难看出,我们需要多一层循环 \(k\)

\[f[i][j] = max(f[i-1][j-k*w[i]]+k*v[i]); \]

这其实就是一个“多重背包问题”,代码如下:

#include <iostream>
#include <algorithm>
using namespace std;
const int N = 33, M = 207;
int f[N][M];
int main() {
	int m, n;
	scanf("%d%d", &m, &n);
	for (int i=1, w, c; i <= n; ++i) {
		scanf("%d%d", &w, &c);
		for (int j = 1; j <= m; ++j)
			for (int k = 0; k*w <= j; ++k)
				f[i][j] = max(f[i][j], f[i-1][j-k*w]+k*c);
	}
	printf("max=%d\n", f[n][m]);
}

第一次改进:滚动数组

原理同“0-1背包”问题,滚动数组可以降低空间,但不能降低时间。

#include <iostream>
#include <algorithm>
using namespace std;
const int M = 207;
int f[2][M];
int main() {
	int m, n;
	scanf("%d%d", &m, &n);
	for (int i=1, u=1, w, c; i <= n; ++i, u^=1) {
		scanf("%d%d", &w, &c);
		for (int j = 1; j <= m; ++j)
			for (int k = 0; k*w <= j; ++k)
				f[u][j] = max(f[u][j], f[u^1][j-k*w]+k*c);
	}
	printf("max=%d\n", f[n&1][m]);
}

第二次改进:降维

我们仔细观察一下上面的代码,发现 \(k\) 循环其实有很多重复的操作。

\[\begin{aligned} f[i][j] =& max(f[i-1][j], f[i-1][j-w[i]]+v[i], f[i-1][j-2*w[i]]+2*v[i],\\ &\dots ,f[i-1][j-(k-1)*w[i]+(k-1)*v[i], f[i-1][j-k*w[i]]+k*v[i]); \end{aligned} \]

\[\begin{aligned} f[i][j-w[i]] =& max(f[i-1][j-w[i]], f[i-1][j-2*w[i]]+v[i], f[i-1][j-3*w[i]]+2*v[i], \\ &\dots ,f[i-1][j-k*w[i]]+(k-1)*v[i]); \end{aligned} \]

我们将 f[i][j-w[i]]+v[i] 替代 f[i][j]max 中除 f[i-1][j] 以外的其余项,得:

\[f[i][j] = max(f[i-1][j], f[i][j-w[i]]+v[i]); \]

请注意:它和“0-1背包”不一样,max 中出现了 f[i][...],而不是 f[i-1][...]

现在我们考虑开始降维,先考虑单调性,由于第 \(j\) 列是由第 \(j-w[i]\) 列的新值产生的,所以第 \(j\) 列更新之前,必须先更新第 \(j-w[i]\) 列,我们应该从左到右更新。

代码如下:

#include <iostream>
#include <algorithm>
using namespace std;
const int M = 207;
int f[M];
int main() {
	int m, n;
	scanf("%d%d", &m, &n);
	for (int I = 1, w, c; i <= n; ++i) {
		scanf("%d%d", &w, &c);
		for (int j = w; j <= m; ++j)
			f[j] = max(f[j], f[j-w] + c);
	}
	printf("max=%d\n", f[m]);
}

多重背包

我们还有一种常见的思路——将所有的背包全部转为“0-1背包”或“多重背包”完成。其中,多重背包比较好做。

#include <iostream>
#include <algorithm>
using namespace std;
const int N = 13, M = 207;
int f[N][M];
int main() {
	int V, n;
	scanf("%d%d", &V, &n);
	for (int i = 1, w, v, x; i <= n; ++i) {
		scanf("%d%d%d", &w, &v, &x);
		for (int j = 1; j <= V; ++j)
			for (int k = 0; k*w<=j && k<=x; ++k)
				f[i][j] = max(f[i][j], f[i-1][j-k*w]+k*v);
	}
	printf("%d\n", f[n][V]);
}

对于混合背包问题,我们可以将其全部转为多重背包问题。

#include <iostream>
#include <algorithm>
using namespace std;
const int N = 37, M = 207;
int f[N][M];
int main() {
	int V, n;
	scanf("%d%d", &V, &n);
	for (int i = 1, w, v, x; i <= n; ++i) {
		scanf("%d%d%d", &w, &v, &x);
		if (!x) x = V / w;
		for (int j = 1; j <= V; ++j)
			for (int k = 0; k*w<=j && k<=x; ++k)
				f[i][j] = max(f[i][j], f[i-1][j-k*w]+k*v);
	}
	printf("%d\n", f[n][V]);
}

更高阶的玩法

我们发现多重背包,无非就是对取值数量进行枚举的“0-1背包”,所以,如果我能将多重背包迅速地拆成尽量少的“0-1背包”,就可以进一步提高多重背包(完全背包也是)的效率。

最快的拆法当然就是用倍增(类二进制枚举),例如当物品数量为 13 时,我们除从 1 到 13 进行枚举外,还可以将其拆为 \(1, 2, 4, 6\) 共 4 种“0-1背包”,并使用“0-1背包”进行运算。

例如上题混合背包问题,使用二进制拆分枚举,得到如下代码:

#include <iostream>
const int N = 37, M = 207;
int f[M];
int main() {
	int v, n;
	scanf("%d%d", &v, &n);
	for (int i = 1, W, C, P; i <= n; ++i) {
		scanf("%d%d%d", &W, &C, &P);
		if (P == 0) P = v / W;
		for (int j = 1; P; j <<= 1) {
			if (P >= j) P -= j;
			else j = P, P = 0;
			for (int k = v; k >= j*W; --k)
				f[k] = std::max(f[k], f[k-j*W] + j*C);
		}
	}
	printf("%d", f[v]);
}

此种方法通常运行速度较快,大家可以在逃亡的准备做一下对比练习。

请注意:上面两段代码,对重量循环与对个数的循环位置不同,如果在个数循环里嵌套重量循环,则不同的个数会看成不同的物品,就有可能同时选择多个不同的个数,也就是说,有可能先选了 2 个又选了 3 个,共选了 5 个;如果在重量循环里嵌套个数循环,则不同个数的物品每次只能选择一样,即要么选 1 个,要么选 2 个,……,总之不能既选 1 个又再选 2 个

队列优化

硬币购物
直接做会导致 MLE 和 TLE,可以使用 滚动数组+队列优化 的方式完成。

容斥原理(计数类背包问题优化)

通常专门应用于计数类背包问题。还是硬币购物问题,可以先将最大量的情况预处理,再通过容斥将不合法的情况去除。

反方向的背包类型

潜水员

这道题的状态应该不难写出,由于有两个不同类型的容量,显然状态应该是二维,\(f[i][j]\),关键是它的描述是什么?

回想以下普通的背包问题,降维以后的 \(f[i]\) 表示在容量 \(i\) 的限制所能达到的最大价值。

而这道题,需要至少满足氧气和氮气的需求时的最小重量,此时的限制不是在某一个范围内,而是至少在某个值以上

因此,\(f[i][j]\) 表示至少满足 \(i\) 升氧气 \(j\) 升氮气的条件下(越多越好),能实现的最小重量。

这会影响我们更新的方式。之前当某一物品的重量大于给定限制时,我们是无法放进背包的;现在,当某一个气罐给出的容量大于我们给定的值时,我们是吸纳的(越多越好),需要用它的重量来更新。如果一个气罐能提供的气体超过我们的最小需求,且重量更小,那我们当然欢迎。

#include <iostream>
#include <cstring>
using namespace std;
int f[30][87];
int main() { 
	int n, V1, V2, v1, v2, m; 
	scanf("%d%d%d", &V1, &V2, &n);
	memset(f, 0x3F, sizeof f);
	f[0][0] = 0;
	for (int i = 1; i <= n; ++i) { 
		scanf("%d%d%d", &v1, &v2, &m);
		for (int j = V1; j >= 0; --j) 
			for (int k = V2; k >= 0; --k) 
				f[j][k] = min(f[j][k], f[max(0,j-v1)][max(0,k-v2)] + m);
	}
	printf("%d", f[V1][V2]);
	return 0;
}
posted @ 2024-08-26 08:26  飞花阁  阅读(83)  评论(0)    收藏  举报
//雪花飘落效果