来源:《算法笔记》
01背包问题是一个多阶段动态规划问题。所谓多阶段动态规划问题,是指它可以描述成若干个有序的阶段,且每个阶段的状态只和上一个阶段的状态有关。
问题描述
有n件物品,每件物品的重量为w[i],价值为c[i]。现有一个容量为V的背包,问如何选取物品放入背包,使得背包内物品的总价值最大。其中每种物品都只有1件。
样例:
5 8 //n==5, V==8
3 5 1 2 2 //w[i]
4 5 2 1 3 //c[i]
如果暴力枚举,复杂度为O(2n);儿动态规划可以将复杂度将为O(nV)。
如何解决
令dp[i][v]表示前i件物品(1≤i≤n, 0≤v≤V),恰好装入容量为v的背包中所能获得的最大价值。怎么求解dp[i][v]?
- 不放第i件物品,那么问题转化为前i-1件物品恰好装入容量为v的背包中所能获得的最大价值,即 d p [ i − 1 ] [ v ] dp[i-1][v] dp[i−1][v]。
- 放第i件物品,那么问题转化为前i-1件物品恰好装入容量为v-w[i]的背包中所能获得的最大价值,即 d p [ i − 1 ] [ v − w [ i ] ] + c [ i ] dp[i-1][v-w[i]] + c[i] dp[i−1][v−w[i]]+c[i]。
由于只有这两种策略,且要求获得最大价值,因此
d
p
[
i
]
[
v
]
=
m
a
x
{
d
p
[
i
−
1
]
[
v
]
,
d
p
[
i
−
1
]
[
v
−
w
[
i
]
]
+
c
[
i
]
}
(
1
≤
i
≤
n
,
w
[
i
]
≤
v
≤
V
)
dp[i][v] = max\{dp[i-1][v], dp[i-1][v-w[i]] + c[i]\}\\ (1≤i≤n, w[i]≤v≤V)
dp[i][v]=max{dp[i−1][v],dp[i−1][v−w[i]]+c[i]}(1≤i≤n,w[i]≤v≤V)
上面这个是状态转移方程。注意到
d
p
[
i
]
[
v
]
dp[i][v]
dp[i][v]只与之前的状态
d
p
[
i
−
1
]
[
]
dp[i-1][]
dp[i−1][]有关,所以可以枚举i从1到n,v从0到V,通过边界
d
p
[
0
]
[
v
]
=
0
(
0
≤
v
≤
V
)
dp[0][v] = 0(0≤v≤V)
dp[0][v]=0(0≤v≤V)(即前0件物品(表示没有物品)放入任何容量v的背包中都只能获得价值0)就可以把整个dp的数组递推出来。而由于
d
p
[
i
]
[
v
]
dp[i][v]
dp[i][v]表示的是恰好为v的情况,所以需要枚举
d
p
[
n
]
[
v
]
(
0
≤
v
≤
V
)
dp[n][v](0≤v≤V)
dp[n][v](0≤v≤V),取其最大值才是最后的结果。
for(int i=1; i<=n; i++){
for(int v=w[i]; v<= V; v++){
dp[i][v] = max(dp[i-1][v], dp[i-1][v-w[i]]+c[i]);
}
}
可以知道,时间复杂度和空间复杂度都是O(nV),接下来还可以优化空间复杂度。(要注意这个演化的过程)
如图所示,状态转移方程中计算
d
p
[
i
]
[
v
]
dp[i][v]
dp[i][v]总是只需要
d
p
[
i
−
1
]
[
v
]
dp[i-1][v]
dp[i−1][v]左侧部分的数据(正上方或左上方),且当计算
d
p
[
i
+
1
]
[
]
dp[i+1][]
dp[i+1][]的部分时,
d
p
[
i
−
1
]
dp[i-1]
dp[i−1]的数据又完全用不到了(只需要
d
p
[
i
]
[
]
dp[i][]
dp[i][]),因此不妨直接开一维数组dp[v](即省去第一维),但是需要枚举方向改变为i从1到n,v从V到0(逆序),状态转移方程改为
d
p
[
v
]
=
m
a
x
{
d
p
[
v
]
,
d
p
[
v
−
w
[
i
]
]
+
c
[
i
]
}
(
1
≤
i
≤
n
,
w
[
i
]
≤
v
≤
V
)
dp[v] = max\{dp[v], dp[v-w[i]] + c[i]\}\\ (1≤i≤n, w[i]≤v≤V)
dp[v]=max{dp[v],dp[v−w[i]]+c[i]}(1≤i≤n,w[i]≤v≤V)
这样修改对应到图中可以这样理解:v的枚举顺序变为从右往左, d p [ i ] [ v ] dp[i][v] dp[i][v]右边的部分为刚计算过的需要保存给下一行使用的数据,而 d p [ i ] [ v ] dp[i][v] dp[i][v]左上角的阴影部分为当前需要使用的部分。将这两者结合一下,即把 d p [ i ] [ v ] dp[i][v] dp[i][v]左上角和右边的部分放在一个数组里,每计算出一个 d p [ i ] [ v ] dp[i][v] dp[i][v],就相当于把 d p [ i − 1 ] [ v ] dp[i-1][v] dp[i−1][v]抹消,因为在后面的运算中 d p [ i − 1 ] [ v ] dp[i-1][v] dp[i−1][v]再也用不到了。这种技巧称为滚动数组。
代码如下:
for(int i=0; i<=n; i++){
for(int v=V; v>=w[i]; v--){
dp[v] = max(dp[v], dp[v-w[i]]+c[i]);
}
}
这样01背包问题就可以用一维数组表示来解决了,空间复杂度为O(V)。
完整代码
#include <cstdio>
#include <algorithm>
using namespace std;
const int maxn = 100; //物品最大件数
const int maxv = 1000; //V的上限
int w[maxn], c[maxn], dp[maxv];
int main(){
int n, V;
scanf("%d%d",&n, &V);
for(int i=0;i<n;i++){
scanf("%d",&w[i]);
}
for(int i=0;i<n;i++){
scanf("%d",&c[i]);
}
//边界
for(int v=0; v<=V; v++){
dp[v] = 0;
}
for(int i=1; i<=n; i++){
for(int v=V; v>=w[i]; v--){
//状态转移方程
dp[v] = max(dp[v], dp[v-w[i]] + c[i]);
}
}
//寻找dp[0...V]中最大的即为答案
int max = 0;
for(int v=0; v<=V; v++){
if(dp[v] > max){
max = dp[v];
}
}
printf("%d\n",max);
return 0;
}
/*
输入:
5 8
3 5 1 2 2
4 5 2 1 3
输出:
10
*/
动态规划是如何避免重复计算的问题在01背包问题中非常明显。在一开始暴力枚举每件物品放或者不放入背包是,其实忽略了一个特写:第i件物品放或者不放而产生的最大值是完全可以由前面i-1件物品的最大值来决定的,而暴力做法无视了这一点。
另外,01背包中的每个物品都可以看作一个阶段,这个阶段中的状态有 d p [ i ] [ 0 ] dp[i][0] dp[i][0]~ d p [ i ] [ V ] dp[i][V] dp[i][V],它们均由上一个阶段的状态得到。事实上,能够划分阶段的问题来说,都可以尝试把阶段作为状态的一维,这可以使我们更方便地得到满足无后效性的状态。从而也可以得到这么一个技巧,如果当前设计的状态不满足无后效性,那么不妨把状态进行升维,即增加意味或者若干维来表示相应的信息,这样可能就能满足无后效性了。
一个例题
这里再给出一个例题,PAT1068,实际上我是从这道题出发,才开始学习了前面《算法笔记》书上01背包问题的基础内容,再参考了https://blog.csdn.net/a617976080/article/details/99694845这篇博客,里面的c[i][v]的使用精髓是书上没有的,可以参考学习。动态规划的题目很难,需要多学习多训练才能慢慢领悟。这里我附上带注释的代码,供大家参考。
#include <cstdio>
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
const int MAX_N = 10010;
const int MAX_M = 110;
//将其看做价值和质量等同的0-1背包问题,将硬币从大到小排序后,依次考虑是否放入第i个硬币
//w[i]表示金币面值(同时是重量w[i]和价值c[i]),dp[v]表示空间为v时最大的价值
int w[MAX_N],dp[MAX_M];
//c[i][v]表示达到价值v时是否放入第i个硬币
int c[MAX_N][MAX_M];
//N为硬币个数,M为应付金额
int N, M;
bool cmp1(int a, int b){
return a>b;
}
int main(){
cin>>N>>M;
//下标从1开始
for(int i=1; i<=N; i++) cin>>w[i];
//从大到小排序w[1]~w[N]。为了后面逆向输出(从小到的大的)路径
sort(w+1, w+N+1, cmp1);
//边界
for(int v=0; v<=M; v++){
dp[v] = 0;
}
for(int i=1; i<=N; i++){
for(int v=M; v>=w[i]; v--){
//状态转移方程
//等价于dp[v] = max(dp[v], dp[v-w[i]]+c[i]); 在这道题中w[i]=c[i]
//一定得是>=,这表示如果有机会选后面的(小的)(也就是当前的i),一定选后面
if(dp[v-w[i]] + w[i] >= dp[v]){
dp[v] = dp[v-w[i]] + w[i];
//表示在当前的i物品下的当前v下这个物品被选中了
c[i][v] = true;
}else{
c[i][v] = false;
}
}
}
vector<int> res;
//遍历完成后如果dp[M]!=M即没有一个序列能达到M,否则逆向查找出路径(题目要求输出字典序小的)
if(dp[M] != M){
printf("No Solution");
}else{
//res.push( c[k从N->1][v -= w[k]] )
int i = N, v = M;
while(i >= 1){
if(c[i][v] == true){
res.push_back(w[i]);
v-= w[i];
}
i--;
}
for(int i=0; i<res.size(); i++){
printf("%d",res[i]);
if(i != res.size()-1) printf(" ");
}
}
return 0;
}
/*
8 9
5 9 8 7 2 3 4 1
1 3 5
4 8
7 2 4 3
No Solution
*/