Description
Bessie has gone to the mall's jewelry store and spies a charm bracelet. Of course, she'd like to fill it with the best charms possible from the N (1 ≤ N ≤ 3,402) available charms. Each charm i in the supplied list has a weight Wi (1 ≤ Wi ≤ 400), a 'desirability' factor Di (1 ≤ Di ≤ 100), and can be used at most once. Bessie can only support a charm bracelet whose weight is no more than M (1 ≤ M ≤ 12,880).
Given that weight limit as a constraint and a list of the charms with their weights and desirability rating, deduce the maximum possible sum of ratings.
Input
* Line 1: Two space-separated integers: N and M
* Lines 2..N+1: Line i+1 describes charm i with two space-separated integers: Wi and DiOutput
* Line 1: A single integer that is the greatest sum of charm desirabilities that can be achieved given the weight constraints
Sample Input
4 6 1 4 2 6 3 12 2 7Sample Output
23
思路
最基础的背包题目,特点就是:每种物品只有一件,你可以选择放或者不放。
刚上手的话很容易拿贪心去解这道题,因为贪心和动态规划都有共同的特征,就是问题本身就具有最优子结构,意思是说原问题的最优解是由子问题的最优解演变成的,且子问题的最优解的形成只与某个或某几个子子问题有关,而与另外一个或几个子问题无关。想着是不是可以每次都挑价值最大的物品,如果考虑上容量的话,那就挑单位体积价值最大的物品咯,但是通过下面这个例子你能够很明显地发现这么想是有bug的:
假设有一个容量为 50 的包与三件重量和价值分别是 10&60、20&100、30&120 的物品,计算知道三件物品单位体积价值分别是 6、5、4 ,采用贪心策略的话,会将前两个物品放入包中,但是很明显将后两个物品放入包中才是最优解。
为什么会这样呢?我想问题应该出在背包的容量上,贪心不能保证背包的空间恰好被填满,而剩余的容量使总价值降低了。
那么采用动态规划解题,DP解题首先思考子状态。显然如果枚举计算前 i 个物品的最大金额的话,所做的决策都会有后效性,即当前的决策使得空间变小了,导致后面价值大的物品装不下。后效性是DP问题定义状态时一定要避免的,所以这个子状态OVER,不能用。
我们换个子状态(子问题)思考,原问题里的决策是选 or 不选,共 i 个决策(也称 i 个阶段),那么影响下一步决策的因素是背包容量,因为如果背包容量不够了就只能做出不选的决策。所以用背包容量定义子状态。
枚举背包容量计算最大价值,即 dp[i][j] 表示前 i 件物品放入容量为 j 的背包中可以获得的最大价值,可以得到一个等式,也就是状态转移方程:
dp[i][j] = max (dp[i-1][j], dp[i-1][j-w[i]] + d[i])
//d[i] 表示第 i 件物品的价值,w[i]表示第 i 件商品的重量
有了状态转移方程,我们就可以开始解题啦。
首先想到递归是解决递推式,它是最简单粗暴的办法。但缺点是开大数组,本题会爆内存,我也就不从递归入手了。
那么动手写一个自底向上的方法,二维数组实现:
#include<iostream> #include<algorithm> using namespace std; const int MAX_N = 3402; const int MAX_M = 12880; int d[MAX_N+1] = {0}; int w[MAX_N+1] = {0}; int dp[MAX_N+1][MAX_M+1] = {0}; int main(void) { int N, M; cin >> N >> M; for (int i = 1; i <= N; i++) { cin >> w[i] >> d[i]; } for (int i = 1; i <= N; i++) { //这一维表示物品 for (int j = 0; j <= M; j++) { //这一维表示背包容量 if (w[i] <= j) { dp[i][j] = std::max(dp[i-1][j], dp[i-1][j-w[i]]+d[i]); //前i个物品价值之和,分选i与不选i两种 } else { dp[i][j] = dp[i-1][j]; //物品超重,不选i } } } cout << dp[N][M] << endl; return 0; }
虽然算法的时间复杂度是 O(N·M) ,但是空间复杂度是 O(N·M) ,会爆内存。
如何优化空间复杂度?
分析发现,第 i 行的数据其实是由第 i-1 行数据计算得到的,也就是说第 i 行的数据只需第 i-1 行的数据就可递推得到而无需第 1 到 i-2 行的数据,那么为什么不让只有两行的数组去存储数据呢?
我们利用一个变量 c 去实现数据的滚动存储。初始化 c = 0,每次循环前,让 c = 1- c ,实现数组行下标在 0,1 之间循环变化。这种微妙的存储优化方式叫做滚动数组,优化后,空间复杂度为 O(2·M)
#include<iostream> #include<algorithm> using namespace std; const int MAX_N = 3402; const int MAX_M = 12880; int d[MAX_N+1] = {0}; int w[MAX_N+1] = {0}; int dp[2][MAX_M+1] = {0}; int main(void) { int N, M; cin >> N >> M; for (int i = 1; i <= N; i++) { cin >> w[i] >> d[i]; } //二维滚动数组实现DP int c = 0; for (int i = 1; i <= N; i++) { //这一维表示物品 c = 1-c; for (int j = 0; j <= M; j++) { //这一维表示背包容量 if (w[i] <= j) { dp[c][j] = std::max(dp[1-c][j], dp[1-c][j-w[i]]+d[i]); } else { dp[c][j] = dp[1-c][j]; //物品超重,不选i } } } if (N%2 == 0) { //物品个数为偶数时最优解位于第一行末尾 cout << dp[0][M] << endl; } else { //物品个数为奇数时最优解位于第二行的末尾 cout << dp[1][M] << endl; } return 0; }
其实还可以把二维的滚动数组降到一维,但是要特别注意滚动的方向。
回顾一下二维数组实现的状态转移方程:
dp[i][j] = max (dp[i-1][j], dp[i-1][j-w[i]] + d[i])
现在用 dp[j] 表示把前 i 件物品放入容量为 j 的背包中得到的价值。它可以表示当前状态 dp[i][j] 。而它是由两个子问题 dp[i-1][j] 、dp[i-1][j - w[i]] 递推而来的。
那么,如何保证推当前状态 dp[i][j] 时(也就是第 i 次循环推 dp[j] 时),能够得到前一状态 dp[i-1][j] 、dp[i-1][j-w[i]] 的值?
关键就是让一维滚动数组逆序滚动更新,也就是内循环逆序。
for i = 1 .. N for j = V .. 0 dp[j] = max (dp[j], dp[j-w[i]] + d[i])
内循环是逆序时,就可以保证 max 中的 dp[j] 、dp[j-w[i]] 是前一状态的!
算法的空间复杂度是 O(M)
#include<iostream> #include<algorithm> using namespace std; const int MAX_N = 3402; const int MAX_M = 12880; int d[MAX_N+1] = {0}; int w[MAX_N+1] = {0}; int dp[MAX_M+1] = {0}; int main(void) { int N, M; cin >> N >> M; for (int i = 1; i <= N; i++) { cin >> w[i] >> d[i]; } for (int i = 1; i <= N; i++) { for (int j = M; j >= 1; j--) { if (w[i] <= j) { dp[j] = std::max(dp[j], dp[j-w[i]] + d[i] ); } else { dp[j] = dp[j]; } } } cout << dp[M] << endl; return 0; }
再优化,减少无用的内循环循环次数,有:
#include<iostream> #include<algorithm> using namespace std; const int MAX_N = 3402; const int MAX_M = 12880; int d[MAX_N+1] = {0}; int w[MAX_N+1] = {0}; int dp[MAX_M+1] = {0}; int main(void) { int N, M; cin >> N >> M; for (int i = 1; i <= N; i++) { cin >> w[i] >> d[i]; } for (int i = 1; i <= N; i++) { for (int j = M; j >= w[i]; j--) { dp[j] = std::max(dp[j], dp[j-w[i]] + d[i] ); } } cout << dp[M] << endl; return 0; }
由于一维数组解 01背包公式以后会常常会被调用,所以这里给出伪代码:
//过程 ZeroOnePack,表示处理一件背包中的物品,两个参数 cost、weight 分别表示这件物品的费用和价值 procedure ZeroOnePack (cost, weight) for j = j to cost dp[j] = max (dp[j], dp[j-cost] + weight) //以后01背包问题的伪代码可以这么写 for i=1..N ZeroOnePack (c[i], w[i])
最后谈谈一维度背包的初始化的问题,直接引用《背包九讲》:
延伸阅读
动态规划的解题步骤: