动态规划之背包九讲之一 — 01背包

在讲解之前我们先来看一道题目。如下:
一个小偷有一个最大容纳M千克的背包,现在商店里有N件物品,每件物品的的重量分别是w1,w2,…wn。每件物品的价值为v1,v2,…,vn。求小偷能偷走的最大价值。(其中M<=200,n<=30)
第一行输入M,n。第二行到第n+1行,每行输入一个重量和一个价值,代表第i件物品的信息。

这道题就是典型的背包问题,分析可知在不把背包装爆的情况下,选择适当的物品,使总价值达到最大。
由于这一题目属于动态规划问题,而动态规划有四大步骤,分别是:①确定状态 ②转移方程 ③初始条件与临界情况 ④计算顺序 那么我们现在开始第一步,确定状态。顾名思义,确定状态就是说要确定我们的表达式代表的含义,我们首先开辟一个二维数组dp[ i ][ j ]代表在前i件物品,当前背包容量为j时,所能获得的最大价值
第二步也是最重要的 转移方程,通俗点说就是递推关系式。对于每一件物品来说,只有两种情况,第一种情况是当前的背包容量大于这一件物品的重量,也就是可以装得下当前这件物品。第二种情况是当前的背包容量小于这一件物品的重量,也就是装不下。那么对于第二种情况,我们别无选择。俗话说理想很丰满,现实很骨感,纵使你想称霸武林,可惜奈何实力不够呀~~。那么对于第一种情况呢,在你有足够的空间时,主动权便在我们手里,我们可以决定拿还是不拿。如果不拿,那么结果等同于拿不下的情况。如果拿,就要在背包里去掉当前这件物品的重量,也就是说用减掉重量之后的的背包所对应的容量的最大价值加上当前这件物品的价值。(注意:一定要时刻记住,dp[ i ][ j ]代表什么,不要而不要太注重它的变量字母 i,j 代表什么。)
所以,根据分析总结出递推关系式如下:
dp[ i ][ j ] = max(dp[ i ][ j ] ,dp[ i - 1][ j - w[ i ] ] + v[ i ])
第三步呢?初始条件,什么是初始条件?通俗讲就是第一个数,对于本题来说就是当i = 0时,dp[ i ][ j ] = 0,当j = 0时,dp[ i ][ j ] = 0。 对应的现实意义:当第0件物品也就是没有物品,价值当然是0。当背包容量是0,当然装不下任何物品,价值仍然是0。
OK,在完成了最艰难的一步之后,我们开始给出代码:
初级版本: 时间复杂度: O(n2)

#include<iostream>
#include<algorithm>
using namespace std;
int dp[40][210], w[40], v[40];
int main()
{
	int n, M;
	cin >> M >> n;
	for (int i = 1; i <= n; i++)
	{
		cin >> w[i] >> v[i];
	}
	for (int i = 1; i <= n; i++)
	{
		for (int j = 1; j <= M; j++)
		{
			if (j < w[i])
				dp[i][j] = dp[i - 1][j];
			else
				dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - w[i]] + v[i]);
		}
	}
	cout << dp[n][M];
	return 0;
}

附:前面已经思考的初始条件问题,所以对应于代码中就是初始化为0,从i=1开始遍历循环。

相信大家已经看到了前面的四个字 初级版本 ,没错,既然有初级版本,那么就一定会有高级版本。下面进入该问题的优化解法。

在前面的分析我们能够知道,当你面对第i件物品时,无论你做什么决策,都与上一件物品有联系,而与其他的物品没有联系,这就是该问题的 后无效性原则 。 对应到代码里也能得到验证。

			if (j < w[i])
				dp[i][j] = dp[i - 1][j];
			else
				dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - w[i]] + v[i]);

我们假设输入的数据如下 :
10 4
2 1
3 3
4 5
7 9

事实上我们可以在每一次外层循环时打印一下这个二维数组的值。我已经打印好了,如下。

行:第几个物品 . 列:背包容量
在这里插入图片描述
根据后无效性原则,并且我们最终需要的结果是最后一次求出的结果,所以前面求出的我们可以舍弃。我们可以把二维数组压缩成一位数组,根本原理是滚动数组就是说在你求第i+1行的某一个数据时,不需要把得到的数据结果写第i+1行,只需要在原来的第i行上替换掉旧数据即可。如此循环,就可以达到每一次外层循环都更新了一遍数据条的效果(非常重要的思想)
那么我们有了这个想法就赶紧去做一下升级吧,得到了如下代码:

#include<iostream>
#include<algorithm>
using namespace std;
int dp[210], w[40], v[40];
int main()
{
	int n, M;
	cin >> M >> n;
	for (int i = 1; i <= n; i++)
	{
		cin >> w[i] >> v[i];
	}
	for (int i = 1; i <= n; i++)
	{
		for (int j = w[i]; j <= M; j++)
		{
			dp[j] = max(dp[j], dp[j - w[i]] + v[i]);
		}
	}
	cout << dp[M];
	return 0;
}

然后我们就去OJ平台再次提交,这时发现了问题。结果不对!!这是为什么呢?这里是大部分人最难以理解的地方,本想着从二维数组直接迁移过来,但事实上却出现了错误。下面我们在进行深入分析。观察图表:
(蓝色箭头&橙色箭头为一组,绿色箭头&黑色箭头为一组)
在这里插入图片描述
对于黄色区域的结果8,根据转移方程知,是通过橙色箭头的起点4蓝色箭头的起点3加上对应的物品的价值5等于总价值8 比较得出来的,8>4 ,所以把8天道黄色区域。 对于蓝色区域也一样。再来看转移方程 dp[ i ][ j ] = max(dp[ i ][ j ] ,dp[ i - 1][ j - w[ i ] ] + v[ i ]) 会发现dp[ i - 1][ j - w[ i ] ]的第二位下标永远小于dp[ i ][ j ] 的第二位下标(因为是减法)。对应到表格里来说就是每一次比较数据都是以后面数字的为基点,和它前面的某一个数比较,再把比较的结果更新到基点里。这时就会出现一个问题。是从后向前的方向比较,还是从前到后的方向比较?(注意,比较的方向是大方向,对于比较的两个数来说,后面的数永远都是基点,千万不要混淆这个方向)
重点:
由于是在一个一维数组里不断地刷新数据,而且我们在比较的时候还需要用到旧数据(在二维数组里就是第i-1行的数据),那么如果从前到后的方向比较,就会把作为基点的数据给更新,暂时把这个基点叫做A点,但是此时基点相对于另一个比较的数字来说是在后面的。当你再比较下几轮的时候,当A点作为被比较的数字来说(在新一轮比较里,它不是基点,而是作为被基点比较的数字),实际需要的是旧数据的A点的值,但是在你前面的某一轮比较里,已经把A点的旧数据给更新了,现在A点存放的是新数据。这样就会造成数据覆盖,从此开始后面的数据都是错误的。所以这个方向不可行。
那就只剩另外一种比较方向了,从后向前。对于这个方向来说,由于基点相对于另一个比较的数字来说是在后面,所以每次更新基点的数据之后,这个基点将不会在被后面的比较中用到,那么整个一轮的比较得到的数据就不会发生覆盖。
正确高级版本代码如下

#include<iostream>
#include<algorithm>
using namespace std;
int dp[210], w[40], v[40];
int main()
{
	int n, M;
	cin >> M >> n;
	for (int i = 1; i <= n; i++)
	{
		cin >> w[i] >> v[i];
	}
	for (int i = 1; i <= n; i++)
	{
		for (int j = M; j >= w[i]; j--)
		{
			dp[j] = max(dp[j], dp[j - w[i]] + v[i]);
		}
	}
	cout << dp[M];
	return 0;
}

它与上面的错误版本唯一的区别就是内层循环的方向改变。

到此为止,01背包问题的所有解法,由浅入深都讲解完毕。后续还会更新背包问题的其他分支,如果喜欢就点个赞呗。谢谢大家。

ps:博主能力有限,如果读者发现什么问题,欢迎私信或评论指出不足。欢迎读者询问问题,乐意尽我所能解答读者的问题。欢迎评论,欢迎交流。谢谢大家!

发布了46 篇原创文章 · 获赞 10 · 访问量 977

猜你喜欢

转载自blog.csdn.net/lyqptp233/article/details/104892176