目录
问题描述
有 N 件物品和一个容量是 V 的背包。每件物品只能使用一次。
第 i 件物品的体积是 vi,价值是 wi。
求解将哪些物品装入背包,可使这些物品的总体积不超过背包容量,且总价值最大。
输出最大价值。输入格式
第一行两个整数,N,V,用空格隔开,分别表示物品数量和背包容积。
接下来有 N 行,每行两个整数 vi,wi,用空格隔开,分别表示第 i 件物品的体积和价值。
输出格式
输出一个整数,表示最大价值。
数据范围
0<N,V≤1000
0<vi,wi≤1000输入样例
4 5 1 2 2 4 3 4 4 5
输出样例:
8
问题分析
01背包是一种最基本的背包问题,特点是背包容量有限,每个物品有且仅有一个。对于每个物品,都有放与不放两种选择,因此也叫01背包问题。
朴素想法
最朴素的想法是依次枚举每件物品选与不选的情况,时间复杂度为显然是低效而不可取的。但这也给了我们拆分重叠子问题的思路:从1到n依次考虑物品的状态,通过构造最优子结构来求得最后的解。
怎么表示每件物品的状态呢?
动态规划——二维数组
我们最先想到的是用一个二维数组表示状态,其中表示前i个物品放进容量为的背包能够获得的最大价值。
依次考虑物品状态,怎么列出状态转移方程求解呢?
考虑对第i件物品的选择策略,有以下两种:
- 不放第i件物品,那么显然有;
- 放第i件物品,问题转化为
从而得到状态转移方程:
进而可以写出以下代码:
#include <iostream>
#include <algorithm>
using namespace std;
const int N = 1010;
int n, m;
int v[N], w[N];
int dp[N][N];
int main(){
ios::sync_with_stdio(false); //关闭流同步,提高cin速度
cin >> n >> m;
for(int i = 1; i <= n; i ++)
cin >> v[i] >> w[i];
for(int i = 1; i <= n; i ++)
for(int j = 0; j <= m; j ++){
dp[i][j] = dp[i - 1][j];
if(j >= v[i]) //判断能不能装下
dp[i][j] = max(dp[i][j], dp[i - 1][j - v[i]] + w[i]);
}
cout << dp[n][m] << '\n';
return 0;
}
重要优化——一维滚动数组
可以知道,时间复杂度和空间复杂度都是,其中时间复杂度无法优化,空间复杂度却可以进一步优化。
经过思考可以发现,原先方法计算的时候总是要用到这个维度中,在左侧的数组元素,而当计算这一维度的时候,只需要用到这一维度,这一维度则完全用不到了。因此,不妨直接开一个一维数组(即省去第一维),代表状态为:
N件物品,背包容量为j下的最优解。
这样,状态转移方程改变为:
注意点:在枚举j的时候,需要逆序从右往左枚举。这是01背包最难以理解的地方。
在这里需要引入滚动数组的概念:
- 在求解斐波那契数组的时候,我们通常觉得要开一个大数组(比如说),表示的就是第i个元素的值;
- 但是当我们仅仅需要第k个元素的值的时候,事实上我们不关心其他元素的值是多少;
- 那么,来来回回我们真正需要存储的值只有三个:当前元素的值,上一个元素的值,上上一个元素的值。有了这三个,我们就可以循序渐进,求出后面元素的值,当求出下一个元素的值之后,上上一个元素其实已经对我毫无用处了,不如舍弃它,拿它的下一个元素覆盖它;
- 此时,“当前元素”变成了“上一个元素”,“上一个元素”变成了“上上一个元素”,可以进一步求值。我们向前推进了问题,数组却还是原来的数组;
- 那么,我就可以开一个大小为3的数组(或者直接定义三个变量,本质上是一样的),不断地用后面的运算结果覆盖前面的运算结果,最后求得答案。
-
def fibonacci(n): a = 1 b = 1 ans = 0 if n == 1 or n == 2: return a else: while n >= 3: ans = a + b a = b b = ans n -= 1 return ans n = int(input()) print(fibonacci(n)) //懒得写C++了,附上翻出的python代码
这就是滚动数组,不断地用后面的值覆盖前面的值,车轮滚滚向前,重复利用着每一寸轮胎。
这正是动态规划中常用的技巧。再来看原来的问题:为什么需要从后往前枚举?
我们对比一下两个状态转移方程:
当前的问题是:背包容量为j时选N件物品的最优解
- 在二维解法中,我们当前这一维度的状态是由这一维度得来的;
- 在一维解法中,的状态是由自身与左边的元素得来的;
- 二维状态与一维状态的状态转移方程,本质是一样的,这就是说对于一维状态下左边的元素,它们在二维解法中实际的第一维度是;
- 如果我们按正序遍历数组,在处理当前问题的时候,我们用到的来自左边的元素实际的第一维度是,因为已经被更新过了;
- 这就体现了逆序遍历的合理性:从右向左遍历,使得右侧被更新过的、需要留给下一个维度的数据不会再被使用;而左侧需要使用的数据都还没被更新,依然来自上一个维度。
一切如上。接着来看代码:
#include <iostream>
#include <algorithm>
using namespace std;
const int N = 1010;
int dp[N], v[N], w[N];
int main(){
ios::sync_with_stdio(false); //关闭流同步,提高cin速度
int n, m;
cin >> n >> m;
for(int i = 1; i <= n; i ++){
cin >> v[i] >> w[i];
}
for(int i = 1; i <= n; i ++)
for(int j = m; j >= v[i]; j --){
dp[j] = max(dp[j], dp[j-v[i]] + w[i]);
}
cout << dp[m] << '\n';
return 0;
}
这里有最终优化版本
#include <iostream>
#include <algorithm>
using namespace std;
const int N = 1010;
int dp[N], v, w;
int main(){
ios::sync_with_stdio(false);
int n, m;
cin >> n >> m;
for(int i = 1; i <= n; i ++){
cin >> v >> w;
for(int j = m; j >= v; j --)
dp[j] = max(dp[j], dp[j-v] + w);
}
cout << dp[m] << '\n';
return 0;
}
写在最后
动态规划如何避免重复计算d的问题在01背包问题中非常明显。暴力枚举方法忽略了这一点:在考虑第件物品选与不选而产生的最大值的时候,它其实是由前面件物品的最大值决定的。
因此,01背包的每件物品其实都可以看做一个阶段,这阶段里的状态有,它们均由上一个阶段得到。对于能够划分阶段的问题来说,可以尝试把阶段作为状态的一维,这样能使我们更加方便地得到满足无后效性的状态。如果当前设计的状态不能满足无后效性,可以尝试升维操作。