背包问题
总任务:求解将哪些物品装入背包,可使这些物品的总体积不超过背包容量,且总价值最大。
动态规划问题一般没有算法模板。
0-1背包问题
条件:有 件物品和一个容量是 的背包。每件物品最多只能使用一次 。第 件物品的体积是 ,价值是 。
例题:01背包问题的朴素解法
最大价值是物品数量 和背包容量 的函数,设函数 表示前 件物品放入容量为 的背包的最大价值,最终的最大价值就是物品数量 从 增长到 ,背包容量 从 增长到 时的 值。
动态规划 常用如下模型进行分析:
状态表示 : 是前 个物品,背包容量 下的最优解(最大价值):
集合 :是所有选法的集合,需要满足如下两个条件:
属性 :是一个数值,一般代表所有选法总价值的最大值 ,最小值 ,或元素数量等(本题为最大值)。
状态计算 :最终答案是 ,一般对应集合的划分 。本问题可将集合划分为从物品 到 中选择的总体积不超过 的不含 的集合( )以及物品含 的集合( ),共两个子集;集合划分需满足如下原则:
注意以上“含 的集合”指的是从物品 到 中选择的总体积不超过 的选法的集合,也就是 。
可以得到:
进一步写出完整的递推式:
其中 ,且 ,可见 应该全 初始化,可以声明为全局变量。
模板:
for ( int i = 1 ; i <= n ; i ++ ) { // 物品i
for ( int j = 1 ; j <= m ; j ++ ) { // 容量j
if ( j < v [ i ]) f [ i ][ j ] = f [ i - 1 ][ j ];
else
f [ i ][ j ] = max ( f [ i - 1 ][ j ], f [ i - 1 ][ j - v [ i ]] + w [ i ]);
}
}
cout << f [ n ][ m ] << endl ;
时间和空间复杂度均为 ,时间复杂度不能降低了,但空间复杂度还可以降为一维。是否可以用一维数组f[j]
只记录一行数据,让 值顺序循环,顺序更新f[j]
呢?答案是不行,因为如果 是顺序循环,f[j - v[i]]
会先于f[j]
更新,即用新值f[j - v[i]]
去更新f[j]
会出错。
优化 指的是对已有 的代码、方程进行优化。
例题:01背包问题的一维优化
将状态f[i][j]
优化到一维f[j]
,实际上只需要做一个等价变形。为什么可以这样变形呢?我们定义的状态f[i][j]
可以求得任意合法的 与 最优解,但题目只需要求得最终状态f[n][m]
,因此我们只需要一维的空间来更新状态。
状态f[j]$
定义: 件物品,背包容量 下的最优解。注意枚举背包容量 必须从 开始。用一维数组f[j]
只记录一行数据,让 值逆序循环,逆序更新f[j]
的值。f[j]
称为滚动数组 。因为 是逆序循环,f[j]
会先于f[j-w[i]]
更新,即用旧值f[j-w[i]]
去更新f[j]
,才可以得到正确的结果。
模板:
for ( int i = 1 ; i <= n ; i ++ ) {
for ( int j = m ; j >= 1 ; j -- ) { // 这里是逆序循环
if ( j < v [ i ]) f [ j ] = f [ j ];
else f [ j ] = max ( f [ j ], f [ j - v [ i ]] + w [ i ]);
}
}
这个视频 讲解较好。
完全背包问题
条件:有 个物品和一个容量是 的背包。每个物品都有无限件可用 。第 个物品的体积是 ,价值是 。
动态规划问题:
状态表示 :
集合 : 表示考虑前 个物品且总体积不大于 的所有选法;
属性 :所有选法的最大价值。
状态计算 :集合的划分 ,按照第 个物品选了多少个可以分为若干组,设第 个物品选了 件,可按如下步骤进行计算:
去掉 件物品 ;
求最大值: ;
再加回来 件物品 。
状态转移方程为:
更完整地,设第 种物品最多能选 个,可知 ,其中 为当前容量,则可得完整递推式:
例题:完全背包问题的朴素解法
朴素解法时间复杂度为 。
为了优化朴素解法,可以观察原递推方程:
简化得:
这种递推优化的解法就不用再枚举 重循环了,时间复杂度为 。
例题:完全背包问题的递推优化
另一种视角 :
当前背包容量为 ,要考虑第 件物品能否放入,是否放入。
当前背包容量 ,不能放入,则 ;
当前背包容量 ,能放入,但要比较代价:
若第 件物品不放入背包,则 ;
若第 件物品放入背包,则 。
解释:对于前 件物品,背包容量为 时可能已经放入了第 件物品,容量为 时还可以再放入第 件物品,所以用 更新 (注意这里与0-1背包的不同)。
综上:
模板:
for ( int i = 1 ; i <= n ; i ++ ) { // 物品i
for ( int j = 1 ; j <= m ; j ++ ) { // 容量j
if ( j < v [ i ]) f [ i ][ j ] = f [ i - 1 ][ j ];
else
f [ i ][ j ] = max ( f [ i - 1 ][ j ], f [ i ][ j - v [ i ]] + w [ i ]);
}
}
cout << f [ n ][ m ] << endl ;
类似地,完全背包问题也可以优化到一维空间复杂度。上式对应的是 从小到大遍历,于是我们只需把0-1背包问题的代码中的 改为从小到大遍历即可。不用由大到小遍历,因为要的就是覆盖的效果!
例题:完全背包问题的一维优化
一维优化解法的时间复杂度不变,空间复杂度降为 。
注意这里完全背包的j
的循环顺序和0-1背包不同。因为j
是顺序循环,f[j-v[i]]
会先于f[j]
更新,也就是说用新值f[j-v[i]]
去更新f[j]
,相当于用第i
行的f[j-v[i]]
值更新f[j]
,所以可以顺序循环。
模板:
for ( int i = 1 ; i <= n ; i ++ ) { // 物品i
for ( int j = 1 ; j <= m ; j ++ ) { // 容量j (这里是顺序循环)
if ( j < v [ i ]) f [ j ] = f [ j ];
else
f [ j ] = max ( f [ j ], f [ j - v [ i ]] + w [ i ]);
}
}
cout << f [ m ] << endl ;
进一步优化(j
从v[i]
开始循环):
for ( int i = 1 ; i <= n ; i ++ ) { // 物品i
for ( int j = v [ i ]; j <= m ; j ++ ) { // 容量j
f [ j ] = max ( f [ j ], f [ j - v [ i ]] + w [ i ]);
}
}
多重背包问题
条件:有 件物品和一个容量是 的背包。第 种物品最多有 件 。第 件物品的体积是 ,价值是 。
动态规划问题:
状态表示 :
集合 :所有只从前 个物品中选择,并且总体积不超过 的选法;
属性 :集合中每个选法对应的总价值的最大值。
状态计算 :根据第 个物品选了多少个来划分集合(可选 个, 个,...,直到 s[i]
个)。
状态转移方程:
例题:多重背包问题的朴素解法
模板:
for ( int i = 1 ; i <= n ; i ++ ) {
for ( int j = 0 ; j <= m ; j ++ ) {
for ( int k = 0 ; k <= s [ i ] && k * v [ i ] <= j ; k ++ ) {
f [ i ][ j ] = max ( f [ i ][ j ], f [ i - 1 ][ j - v [ i ] * k ] + w [ i ] * k );
}
}
}
cout << f [ n ][ m ] << endl ;
朴素解法一维空间优化的模板:
for ( int i = 1 ; i <= n ; i ++ ) {
for ( int j = m ; j >= v [ i ]; j -- ) {
for ( int k = 0 ; k <= s [ i ] && k * v [ i ] <= j ; k ++ )
f [ j ] = max ( f [ j ], f [ j - k * v [ i ]] + k * w [ i ]);
}
}
cout << f [ m ] << endl ;
朴素解法时间复杂度为 。
我们不能直接用完全背包问题的优化方法来优化多重背包问题,因为max
操作不具有可减性。可以采用二进制优化 。
引论:如何将正整数 用类似二进制的方式表示?选择 这些数字进行相加(每个数字最多选一次),其中 ,且 ;它们(除了 外)可以凑出 之间的数,加上 后可以凑出 之间的数,要求区间 中间不能有缝隙,事实上因为 显然成立。故从 到 的所有数可以拼接出来。
举例:水果店里有 个苹果,小明计划购买 个苹果,如何让小明尽可能快速地完成购买?一个显而易见的暴力做法是,让小明一个个拿(单位是个),但效率过于低下。事实上,店员可事先准备好 个箱子,每个箱子中的苹果数量分别为 ,再让小明按箱子拿(单位是箱子),无论小明计划购买多少个,他最多只需要拿 次,而在暴力做法中,小明最多需要拿 次。
用数学表达式,可以断定对任意的正整数 ,都可以找到 个正整数 ,使得 ,都有:
其中:
且 的每个分量要么是 要么是 。
现在对于每种物品 ,可以将这 个物品分散至 个箱子中,将多重背包便化成0-1背包,多重背包问题中的一个箱子相当于0-1背包问题中的一件物品。
二进制拆分思想 :把第 种物品拆分成若干件物品,每件物品的体积和价值乘以一个拆分系数 ,就可以转化成0-1背包问题求解。例如:若 ,拆分系数为 ,转化成 件0-1背包的物品: 。
这样可以把时间复杂度从 变为 。
例题:多重背包问题的二进制优化
模板程序:
#include <iostream>
#include <algorithm>
using namespace std ;
const int N = 25000 ;
const int M = 2010 ;
int n , m ;
int v [ N ], w [ N ], f [ N ];
int main () {
cin >> n >> m ;
int num = 1 ;
for ( int i = 1 ; i <= n ; i ++ ) {
// v 体积 w 价值 s 数量
int vi , wi , s ;
cin >> vi >> wi >> s ;
for ( int k = 1 ; k <= s ; k <<= 1 ) { // k <<= 1 相当于 k *= 2
v [ num ] = k * vi ;
w [ num ++ ] = k * wi ;
s -= k ;
}
if ( s > 0 ) {
v [ num ] = s * vi ;
w [ num ++ ] = s * wi ;
}
}
for ( int i = 1 ; i <= num - 1 ; i ++ ) {
for ( int j = m ; j >= v [ i ]; j -- ) {
f [ j ] = max ( f [ j ], f [ j - v [ i ]] + w [ i ]);
}
}
cout << f [ m ] << endl ;
return 0 ;
}
分组背包问题
条件:有 组物品和一个容量是 的背包。每组物品有若干个,同一组内的物品最多只能选一个 。每件物品的体积是 ,价值是 ,其中 是组号, 是组内编号。 表示第 个物品组的物品数量。
动态规划问题:
状态表示 :
集合 :只从前 组物品中选,且总体积不大于 的所有选法;
属性 :最大值。
状态计算 (集合划分):
枚举第 组物品选 哪个 ;
如果不选第 组第 个物品: ;
如果选第 组第 个物品: 。
递推式:
例题:分组背包问题 ;分组背包问题一维优化
分组背包问题难以使用二进制或单调队列进行优化,但可以对空间进行一定的优化。
模板程序:
#include <iostream>
#include <algorithm>
using namespace std ;
const int N = 110 ;
int n , m , s ;
int v [ N ], w [ N ];
int f [ N ];
int main () {
cin >> n >> m ;
for ( int i = 1 ; i <= n ; i ++ ) { // 物品
cin >> s ;
for ( int j = 1 ; j <= s ; j ++ ) cin >> v [ j ] >> w [ j ];
// 不能交换体积和决策的内外循环顺序!
for ( int j = m ; j >= 0 ; j -- ) { // 体积
for ( int k = 0 ; k <= s ; k ++ ) { // 决策
if ( j >= v [ k ])
f [ j ] = max ( f [ j ], f [ j - v [ k ]] + w [ k ]);
}
}
}
cout << f [ m ] << endl ;
return 0 ;
}