01背包问题
题目:有n件物品需要放入一个容量为v的背包,第i件物品的体积为vi,他的价值为wi,求解将哪些物品装入背包可以使得总价值最大。
题目特点:每种物品只有一件,可以选择或者不放。
用子问题定义状态:即用dp[ i ][ j ] 表示前i件物品恰放入一个容量为 j 的背包可以获得的最大价值。则其状态转移方程为:dp[ i ][ j ] = max(dp[i - 1][ j ], dp[i - 1][j - vi] + w[ i ]);
下面将这个方程解释一下,将前i个物品放入容量为 j 的背包中这个子问题,若只考虑第 i 件物品的策略(放或不放),那么就可以转化为“前i - 1件物品放入容量为 j 的背包中,此时产生的
价值为dp[ i - 1] [ j ],如果放入第 i 件物品,那么问题就转化为前 i - 1 件物品放入剩下的容量为j - ci 的背包中,此时能获得的最大价值为dp[ i - 1][ j - c[i] ]再加上通过第 i 件物品获得的价值w[ i ]。
滚动数组优化:我们在计算都是利用dp[ i - 1 ][ 0 ..... v] 计算出了dp[ i ][ 0 .... v] ,由上式可以看出dp[ i ]只与dp[ i - 1]有关,那么我们可不可以省略这一组呢?答案是可以的,如果我们省略第一
维,那么我们就可以得出另一个状态转移方程dp[ j ] = max(dp[ j ], dp[ j - v[ i ] ] + w[ i ] )。在这里我们的状态转移方程就相当于dp[ i ][ j ] = max(dp[i - 1][ j ], dp[i - 1][j - vi] + w[ i ]);那么在二维状态下
我们利用dp[ i - 1] [ j - v[ i ] ] + w[ i ] 推出了dp [ i ][ j ],这里第二维是从小到大推的,所以倒序和顺序不影响结果,但是如果我们将第一维度去掉这样还可以么?我们可以考虑,如果 j 依然是从v [ i ] - > V还可行么,
答案是不可行,因为如果还是顺序访问的话,我们只是用dp[ i ][j - v[ i ] ] + w[ i ] 推出了dp [ i ][ j ],这与原题不符合。
初始化:01背包问题一般分为两种,第一种为恰好装满背包,第二种为给定背包求解能带走的最大值而不必恰好装满。在求解恰好装满的背包中我们需要将dp[ 0 ] 初始化为0,其余的都初始化为 -INF,
为啥要这样呢,由于需要得到将背包完全装满的状态,所以我们将象征着背包装满的dp[ 0 ]初始化为0,其余的皆为-INF,这样的话只有达到刚好装满状态的装载方法才会依次滚动下来,未装满的情况并不会被保留。
参考代码:
1 void Zero_One_Pick() {//n为物品个数,V为背包容量,v[i]为每件物品所占得体积,w[i]为他所带来的价值 2 for(int i = 1; i <= n; i ++) 3 for(int j = V; j >= v[i]; j --) 4 dp[j] = max(dp[j], dp[j - v[i]] + w[i]); 5 }
完全背包
题目:有n件物品需要放入一个容量为v的背包,第i件物品的体积为vi, 他的价值为wi,每件物品都有无限多个,求解将哪些物品放入背包可以使得背包中的总价值最大。
题目特点:每种物品有无穷件。
和01背包不一样的是所有物品都有不止一件,所以我们很容易可以得出状态转移方程:dp[ i ][ j ] = max(dp[i - 1][j - k * vi] + k * w[ i ])(0 <= k * c[ i ] << v);参照01背包的方程这个式子也很容易得出。
转换为01背包问题:我们可以知道对于每一种物品,我们把他拆分成1 ~ V / c[ i ]件同样的物品,然后对于所有物品我们进行与01背包相同的计算即可得出正确答案。但是这样做并不会给问题带来时间复杂度上的
优化,那么我们可以考虑另一种拆分方式,可以将第 i 件物品拆分为体积为v[ i ] * 2 ^ k,所带来的价值为w[ i ] * 2^k的多个物品,这里v[ i ] * 2 ^ k <= V;我们可以知道,对于物品 i 组成的每一种拆分情况我们都可以利用
对应的二进制得到,所以显而易见我们算法得正确性。
我们可以给出一维数组滚动情况下该思路的伪代码:
F[0..V ] ←0
for i ← 1 to N
for v ← Ci to V
F[v] ← max(F[v], F[v − Ci ] + Wi)
你会发现,这个伪代码与01背包问题的伪代码只有v的循环次序不同而已。 为什么这个算法就可行呢?首先想想为什么01背包中要按照v递减的次序来 循环。让v递减是为了保证第i次循环中的状态F[i, v]是由状态
F[i − 1, v − Ci ]递 推而来。换句话说,这正是为了保证每件物品只选一次,保证在考虑“选入 第i件物品”这件策略时,依据的是一个绝无已经选入第i件物品的子结果F[i − 1, v − Ci ]。而现在完全背包的特点恰是每种物
品可选无限件,所以在考虑“加 选一件第i种物品”这种策略时,却正需要一个可能已选入第i种物品的子结果F[i, v − Ci ],所以就可以并且必须采用v递增的顺序循环。这就是这个简单的 程序为何成立的道理。 值得一
提的是,上面的伪代码中两层for循环的次序可以颠倒。这个结论有 可能会带来算法时间常数上的优化。 这个算法也可以由另外的思路得出。例如,将基本思路中求解F[i, v−Ci ]的 状态转移方程显式地写出来,代入
原方程中,会发现该方程可以等价地变形成 这种形式: F[i, v] = max(F[i − 1, v], F[i, v − Ci ] + Wi)。
下面给出该算法的参考代码:
1 void Complete_Pick() { 2 for(int i = 0; i <= n; i ++) { 3 for(int j = v[i]; j <= V; j ++) { 4 dp[j] = max(dp[j], dp[j - v[i]] + w[i]); 5 } 6 } 7 }
多重背包
题目:有n件物品需要放入一个容量为v的背包,第i件物品的体积为vi, 他的价值为wi,每件物品都有m[ i ] 个,求解将哪些物品放入背包可以使得背包中的总价值最大。
题目特点:每种物品有有限多件。
这个问题和完全背包很相似,我们只需要将完全背包的代码稍作改动即可得出dp[ i ][ j ] = max(dp[i - 1][j - k * vi] + k * w[ i ])(0 <= k <= m [ i ]);
转化为01背包问题:我们依然可以像完全背包一样将所有物品拆分为1~m[ i ]件物品,这样做的效率等同于上面的方法,所以我们依旧考虑二进制拆分。
将第 i 种物品我们仍然进行拆分,拆分之后的的物品 i 的价值为k * w[ i ],体积为k * v[ i ],k取1, 2, 4, ....2^(k - 1), m[ i ] - 2 ^k + 1 > 0。即 k 是满足m [ i ] - 2^k + 1 > 0的最大整数。这样我们就将原本的m[ i ] 件
物品拆分为log m件物品,我们很容易可以知道我们新切分的这些物品可以组成物品 i 的状态数的所有状态,即0......m[ i ]。这样使得算法的复杂度得到了较大的提升。
下面我们给出该算法的参考代码:
1 #include <cstdio> 2 #include <algorithm> 3 using namespace std; 4 5 const int maxn = 1000 + 5, maxe = 1e4 + 5; 6 int n, V, v[maxn], w[maxn], dp[maxe], c[maxn]; 7 8 void Mutiple_Pack() { 9 for(int i = 0; i < n; i ++) { 10 if(c[i] * v[i] >= V) {//如果第i件物品的总质量大于等于V则视这种物品为无限提供 11 for(int j = v[i]; j <= V; j ++) 12 dp[j] = max(dp[j], dp[j - v[i]] + w[i]); 13 continue; 14 } 15 int k = 1, num = c[i]; 16 while(k <= num) { 17 for(int j = V; j >= k * v[i]; j --) 18 dp[j] = max(dp[j], dp[j - k * v[i]] + k * w[i]); 19 num -= k; 20 k *= 2; 21 } 22 for(int j = V; j >= num * v[i]; j --) 23 dp[j] = max(dp[j], dp[j - num * v[i]] + num * w[i]); 24 } 25 } 26 27 int main () { 28 scanf("%d %d", &n, &V); 29 for(int i = 0; i < n; i ++) 30 scanf("%d %d", &v[i], &w[i]); 31 Mutiple_Pack(); 32 printf("%d ", dp[V]); 33 return 0; 34 }
针对填满多重背包的单调队列优化的O(VN)的优化算法:
单调队列已经在之前的一篇博客中讲到,这里不再赘述,这是之前博客的链接https://www.cnblogs.com/bianjunting/p/10566469.html。