文章目录
一、前言
二、0/1 背包问题
- 以上就是 0/1 背包问题的完整描述,之所以叫 0/1 背包,是因为每种物品只有一个,可以选择放入背包或者不放,而 0 代表不放,1 代表放。
1、状态设计
- 第一步:设计状态;
- 状态 ( i , j ) (i, j) (i,j) 表示前 i i i 个物品恰好放入容量为 j j j 的背包 ( i ∈ [ 0 , n ] , j ∈ [ 0 , m ] ) (i \in [0, n], j \in [0, m]) (i∈[0,n],j∈[0,m]);
- 令 d p [ i ] [ j ] dp[i][j] dp[i][j] 表示状态 ( i , j ) (i, j) (i,j) 下该背包得到的最大价值,即前 i i i 个物品恰好放入容量为 j j j 的背包所得到的最大总价值;
2、状态转移方程
- 第二步:列出状态转移方程; d p [ i ] [ j ] = m a x ( d p [ i − 1 ] [ j ] , d p [ i − 1 ] [ j − c [ i ] ] + w [ i ] ) dp[i][j] = max(dp[i-1][j], dp[i-1][j - c[i]] + w[i]) dp[i][j]=max(dp[i−1][j],dp[i−1][j−c[i]]+w[i])
- 因为每个物品要么放,要么不放,所以只需要考虑第 i i i 个物品 放 或 不放 的情况:
- 1)不放:如果 “第 i i i 个物品不放入容量为 j j j 的背包”,那么问题转化成求 “前 i − 1 i-1 i−1 个物品放入容量为 j j j 的背包” 的问题;由于不放,所以最大价值就等于 “前 i − 1 i-1 i−1 个物品放入容量为 j j j 的背包” 的最大价值,即 d p [ i − 1 ] [ j ] dp[i-1][j] dp[i−1][j];
- 2)放:如果 “第 i i i 个物品放入容量为 j j j 的背包”,那么问题转化成求 “前 i − 1 i-1 i−1 个物品放入容量为 j − c [ i ] j-c[i] j−c[i] 的背包” 的问题;那么此时最大价值就等于 “前 i − 1 i-1 i−1 个物品放入容量为 j − c [ i ] j-c[i] j−c[i] 的背包” 的最大价值 加上放入第 i i i 个物品的价值,即 d p [ i − 1 ] [ j − c [ i ] ] + w [ i ] dp[i-1][j - c[i]] + w[i] dp[i−1][j−c[i]]+w[i];
- 将以上两种情况取大者,就是我们所求的 “前 i i i 个物品恰好放入容量为 j j j 的背包” 的最大价值了。
3、初始状态
- 我们发现,当状态在进行转移的时候, ( i , j ) (i, j) (i,j) 不是来自 ( i − 1 , j ) (i-1, j) (i−1,j),就是来自 ( i − 1 , j − c [ i ] ) (i-1, j - c[i]) (i−1,j−c[i]),所以必然有一个初始状态,而这个初始状态就是 ( 0 , 0 ) (0, 0) (0,0),含义是 “前 0 个物品放入一个背包容量为 0 的背包”,这个状态下的最大价值为 0,即 d p [ 0 ] [ 0 ] = 0 dp[0][0] = 0 dp[0][0]=0;
4、非法状态
- 那么我们再来考虑, ( 0 , 3 ) (0, 3) (0,3) 是什么意思呢?它代表的是 “前 0 个物品恰好放入一个背包容量为 3 的背包”,明显这种情况是不存在的,因为 0 个物品的价值肯定是 0。所以这种状态被称为非法状态,非法状态是无法进行状态转移的,于是我们可以通过初始状态和非法状态进所有状态进行初始化。
5、状态初始化
- d p [ 0 ] [ i ] = { 0 i = 0 i n f i > 0 dp[0][i] = \begin{cases} 0 & i = 0\\ inf & i > 0\end{cases} dp[0][i]={0infi=0i>0
- 其中 i n f inf inf 在程序实现时,我们可以设定一个非常小的数,比如 − 1000000000 -1000000000 −1000000000,只要保证无论如何状态转移它都不能成为最优解的候选状态。
- 为了加深状态转移的概念,来看图二-5-1 的一个例子,每个格子代表一个状态, ( 0 , 0 ) (0,0) (0,0) 代表初始状态,蓝色的格子代表已经求得的状态,灰色的格子代表非法状态,红色的格子代表当前正在进行转移的状态,图中的第 i i i 行代表了前 i i i 个物品对应容量的最优值,第 4 个物品的容量为 2,价值为 8,则有状态转移如下:
-
d
p
[
4
]
[
4
]
=
m
a
x
(
d
p
[
4
−
1
]
[
4
]
,
d
p
[
4
−
1
]
[
4
−
2
]
+
8
)
=
m
a
x
(
d
p
[
3
]
[
4
]
,
d
p
[
3
]
[
2
]
+
8
)
\begin{aligned} dp[4][4] &= max( dp[4-1][4], dp[4-1][4 - 2] + 8) \\ &= max( dp[3][4], dp[3][2] + 8) \end{aligned}
dp[4][4]=max(dp[4−1][4],dp[4−1][4−2]+8)=max(dp[3][4],dp[3][2]+8)
图二-5-1
三、0/1 背包问题的实现
1、背包物品结构设计
- 用
Knapsack
来代表背包物品的数据结构,每个背包物品只需要两个值,一个值是容量,因为需要映射到下标进行状态转移,所以 99% 的情况都是整数(剩下 1% 留给读者 YY);另一个值是价值(或者说权值),价值可以是布尔类型、整型 或者 浮点数,所以我们用ValueType
来代替,不同情况进行不用的定义,代码如下:
const int MAXN = 1010;
typedef int ValueType;
struct Knapsack {
int capacity;
ValueType weight;
public:
Knapsack(){}
Knapsack(int c, ValueType w) : capacity(c), weight(w) {}
}Knap[MAXN];
2、状态数组
- 状态数组存储的是价值的最优值(对于【例题1】中的问题,存储的是最大值,但是也有求最小值的问题,所以为了问题覆盖得更加全面,这里把它称为最优值);
- 令背包物品为 n n n 个,背包最大容量为 m m m,那么总的状态数为 O ( n m ) O(nm) O(nm);
- 则定义如下:
const int MAXN = 101; // n
const int MAXC = 10001; // m
typedef int ValueType;
ValueType dp[MAXN][MAXC];
MAXN
代表物品的个数上限 n n n,MAXC
代表容量上限 m m m;
3、状态转移
- 状态转移采用的是两层循环,状态数为
O
(
n
m
)
O(nm)
O(nm),每次状态转移的消耗为
O
(
1
)
O(1)
O(1),所以整个状态转移的过程时间复杂度是
O
(
n
m
)
O(nm)
O(nm)。用
opt(x,y)
来求 x x x 和 y y y 之间的最优值,以适应不同的问题。
ValueType opt(ValueType x, ValueType y) {
return x > y ? x : y;
}
void zeroOneKnapsack(int knapsackSize, Knapsack *knap, int maxCapacity) {
zeroOneKnapsackInit(maxCapacity);
for(int i = 1; i <= knapsackSize; ++i) {
for(int j = 0; j <= maxCapacity; ++j) {
if( j >= knap[i].capacity )
dp[i][j] = opt(dp[i-1][j], dp[i-1][j - knap[i].capacity] + knap[i].weight);
else
dp[i][j] = dp[i-1][j];
}
}
}
- 从代码中可以看出,状态转移的过程就是不断利用前一行的数据,填充 n × m n \times m n×m 这个二维数组的过程。
4、状态初始化
const ValueType inf = -100000000;
const ValueType init = 0;
void zeroOneKnapsackInit(int maxCapacity) {
for(int i = 0; i <= maxCapacity; ++i) {
dp[0][i] = (i==0) ? init : inf;
}
}
四、0/1 背包问题的扩展思考
1、最大值问题
- 当所求问题的最优值是最大值的时候,就是上文提到的问题,非法状态
inf
需要取一个极小值,opt
状态转移函数就是max(x,y)
函数的功能;
typedef int ValueType;
const ValueType inf = -100000000;
const ValueType init = 0;
ValueType opt(ValueType x, ValueType y) {
return x > y ? x : y;
}
2、最小值问题
- 当所求问题的最优值是最小值的时候,定义非法状态
inf
的时候应该定义成极大值,并且状态转移函数opt
应该是取两者中的小者,来看个例子;
- 所谓正难则反,至少1个的概率问题我们可以转换成 1 减去 1个都不的概率,那么第 i i i 所学校无法获取 offer 的概率为 1 − p [ i ] 1 - p[i] 1−p[i],问题转变成这样:
- 每个学校选或不选,选定的学校的费用加起来不能超过 m m m,且满足选定的学校收不到 offer 的乘积最小,我们可以把概率取对数,这样就把乘法转换成加法了。利用的是:
- l o g 2 ( a b ) = l o g 2 ( a ) + l o g 2 ( b ) log_{2}(ab) = log_{2}(a) + log_{2}(b) log2(ab)=log2(a)+log2(b)
- 这样就转化成了一个价值是浮点数的,求解最小值的 0/1 背包问题了。
opt
状态转移函数就是min(x,y)
函数的功能,修改部分代码即可:
typedef double ValueType;
const ValueType inf = 100000000;
const ValueType init = 0;
ValueType opt(ValueType x, ValueType y) {
return x < y ? x : y;
}
3、存在性问题
- 存在性问题就是指:给定一些背包物品,问是否能够组合出恰好等于给定容量的值,同样通过一个例题来理解:
- 因为每件设备只有两种选择,可以认为 选 和 不选 两种方案,所有选的设备的权值和为 X, 那么不选设备的权值和就应该等于 总权值和减去 X。需要求的就是 | 总权值和 - 2X | 尽量小。
- 令 d p [ i ] [ j ] dp[i][j] dp[i][j] 表示前 i i i 件设备的选择情况中,权值和为 j j j 的方案是否存在,则有状态转移方程:
- d p [ i ] [ j ] = ( d p [ i − 1 ] [ j ] o r d p [ i − 1 ] [ j − v [ i ] ] ) dp[i][j] = (dp[i-1][j] \ or \ dp[i-1][j - v[i] ]) dp[i][j]=(dp[i−1][j] or dp[i−1][j−v[i]])
- 我们把这个问题中的权值理解成物品的容量,那么它就是一个物品价值为 0 的 0/1 背包问题。
- 修改部分代码即可:
typedef bool ValueType;
const ValueType inf = false;
const ValueType init = true;
ValueType opt(ValueType x, ValueType y) {
return x || y;
}
4、方案数问题
- 方案数问题就是指:给定一些背包物品,问组合出恰好等于给定容量值的方案数,同样通过一个例题来理解:
- 因为砝码可以放入左边也可以放入右边,或者选择两边都不放,所以有三种情况,无法对应到 0/1 背包,所以这种情况我们需要升维,用一个三维的状态来表示: d p [ i ] [ j ] [ k ] dp[i][j][k] dp[i][j][k] 表示 “前 i i i 个砝码放入左右两边,并且左边的值为 j j j,右边的值为 k k k” 的方案数。
- 那么可以列出状态转移方程: d p [ i ] [ j ] [ k ] = d p [ i − 1 ] [ j ] [ k ] + d p [ i − 1 ] [ j − v [ i ] ] [ k ] + d p [ i − 1 ] [ j ] [ k − v [ i ] ] dp[i][j][k] = dp[i-1][j][k] + dp[i-1][j - v[i]][k] + dp[i-1][j][k-v[i]] dp[i][j][k]=dp[i−1][j][k]+dp[i−1][j−v[i]][k]+dp[i−1][j][k−v[i]]
- d p [ i − 1 ] [ j ] [ k ] dp[i-1][j][k] dp[i−1][j][k] 代表第 i i i 个砝码不放置的方案;
- d p [ i − 1 ] [ j − v [ i ] ] [ k ] dp[i-1][j - v[i]][k] dp[i−1][j−v[i]][k] 代表第 i i i 个砝码放入左边天平的方案数;
- d p [ i − 1 ] [ j ] [ k − v [ i ] ] dp[i-1][j][k-v[i]] dp[i−1][j][k−v[i]] 代表第 i i i 个砝码放入右边天平的方案数;
- 初始状态 d p [ 0 ] [ 0 ] [ 0 ] = 1 dp[0][0][0] = 1 dp[0][0][0]=1;
- 假设天平左边有砝码个数为 a a a,右边砝码个数为 b b b,那么对于所有的 d p [ n ] [ j ] [ k ] dp[n][j][k] dp[n][j][k],累加所有满足: a + j = = b + k a + j == b + k a+j==b+k 的情况就是所求方案数了。
5、有顺序关联的问题
- 假定有两个物品 a ( p a , q a ) a(p_a, q_a) a(pa,qa) 和 b ( p b , q b ) b(p_b,q_b) b(pb,qb) 都想购买,那么有两种情况:
- 1)先购买 a a a,再购买 b b b,则至少需要 p a + q b p_a + q_b pa+qb 元;
- 2)先购买 b b b,再购买 a a a,则至少需要 p b + q a p_b + q_a pb+qa 元;
- 我们希望购买相同的物品的花费尽量少,因为 a a a 和 b b b 是无差别的,所以可以令 p a + q b < p b + q a p_a + q_b < p_b + q_a pa+qb<pb+qa 时,先购买 a a a,再购买 b b b,调整式子后得到: q a − p a > q b − p b q_a - p_a > q_b - p_b qa−pa>qb−pb
- 从而得到当 p p p 和 q q q 差值越大时的物品先购买;
- 但是,实际对物品进行排序的时候,是按照 q [ i ] − p [ i ] q[i] - p[i] q[i]−p[i] 从小到大进行排序,因为根据动态规划的求解顺序,最后放入背包的其实是最先购买的;
6、容量为负数的问题
- 所谓负容量,就是背包的容量可以为负数,这样就无法进行下标映射了,我们可以采用加上一个偏移量的方法将容量转换成正数。
- 购买物品问题 和 背包放置问题 正好是逆过程 ( 购买时的价格类比背包容量,它是越来越少的,而背包容量是越来越多的);
- 所以这个问题中,令 d p [ i ] [ j ] dp[i][j] dp[i][j] 表示 "购买前 i i i 个物品后余额为 j j j" 的方案是否存在,初始状态为 d p [ 0 ] [ m ] = 1 dp[0][m] = 1 dp[0][m]=1,那么有状态转移方程: d p [ i ] [ j ] = d p [ i − 1 ] [ j ] o r d p [ i − 1 ] [ j + p [ i ] ] dp[i][j] = dp[i-1][j] \ or \ dp[i-1][j + p[i]] dp[i][j]=dp[i−1][j] or dp[i−1][j+p[i]]
- 这个问题中, d p [ i ] [ 4 ] dp[i][4] dp[i][4] 已经是终止状态,不能再进行状态转移了;当然, d p [ i ] [ 5 ] dp[i][5] dp[i][5] 是最小的非终止状态,每个菜的价格最大值为 50,所以最小的终止状态为 d p [ i ] [ − 45 ] dp[i][-45] dp[i][−45]。由于出现负数,无法映射到下标,所以这里需要加上一个偏移量 45。
- 状态转移的过程中需要保证 j + p [ i ] ≤ 5 j + p[i] \le 5 j+p[i]≤5;
- 因为价格有限制,所以需要依照 【例题6】 的方式对价格进行排序,剩下就是普通的 0/1 背包问题了。
7、容量很大的问题
- 这类问题由于背包容量很大,但是物品个数很少,所以并不是动态规划问题,需要采用 深度优先搜索 来求解;
- 考虑每个物品选或者不选,最坏情况是 n = k = 40 n=k=40 n=k=40 的情况,为 2 40 2^{40} 240;然而实际情况下如果剪枝控制的好,状态会远远少于这个数;
- 首先可以将所有物品按照价值递减排序,然后记录后缀和,进行深搜枚举所有情况,记录最优解 MAX,加入四种剪枝:
- 1)可行性剪枝:如果枚举过程中当前组合出的价值 v a l u e > m value > m value>m,则返回;
- 2)最优解剪枝:如果 v a l u e = = m value == m value==m,则找到全局最优解,终止搜索过程;
- 3)最优性剪枝 1: v a l u e value value 加上剩余能够选取的几个最大价值的物品的总价值 ≤ \le ≤ MAX,则返回 ;
- 4)最优性剪枝 2: v a l u e value value 加上剩余能够选取的几个最大价值的物品的总价值 ≤ m \le m ≤m,更新 MAX, 返回;
五、0/1 背包问题的空间优化
- 了解了几个典型的背包问题之后,我们发现背包问题的状态数是 O ( n m ) O(nm) O(nm) 的,状态转移已经是 O ( 1 ) O(1) O(1) 无法再优化,所以整个 0/1 背包 问题的求解过程的时间复杂度就是 O ( n m ) O(nm) O(nm) 的,但是空间复杂度还是可以优化的。
1、滚动数组
- 对于 0/1 背包问题,考虑到当前行的状态值只依赖于上一行状态的两个值,和上上一行的状态无关,也就是说上上一行的状态对于计算当前行的状态完全没用,所以再计算完上一行以后,上上一行的数据就可以清理掉了。
图五-1-1 - 我们可以用
dp[2][MAXC]
代替原先的dp[MAXN]MAXC]
,这样一来,空间复杂度不会随着物品个数增多而变大,而dp[0][MAXC]
代表了偶数行的状态,dp[1][MAXC]
则代表了奇数行的状态; - 代码实现如下:
ValueType dp[2][MAXC];
void zeroOneKnapsackRollClear(int index, int maxCapacity) {
for(int i = 0; i <= maxCapacity; ++i) {
dp[index][i] = inf;
}
}
int zeroOneKnapsack(int knapsackSize, Knapsack *knap) {
zeroOneKnapsackInit();
int maxCapacity = 0;
int pre = 0;
for(int i = 0; i < knapsackSize; ++i) {
maxCapacity += knap[i].capacity;
zeroOneKnapsackRollClear(pre^1, maxCapacity);
for(int j = 0; j <= maxCapacity; ++j) {
if( j >= knap[i].capacity )
dp[pre^1][j] = opt(dp[pre][j], dp[pre][j - knap[i].capacity] + knap[i].weight);
else
dp[pre^1][j] = dp[pre][j];
}
pre = (pre^1);
}
return pre;
}
- 实现原理就是通过
pre
行的状态推算pre^1
行的状态,然后令pre = pre^1;
实现状态的滚动; - 滚动数组解决了空间复杂度的问题,是一种全新的思维方式,当然利用这种方法编码较复杂一些,而且比较容易写错,如果不是很理解也没有关系,接下来将介绍降维的思想,无论是编码复杂度上还是理解上都会比滚动数组更加优秀!
2、降维思想
- 上文提到了一个观点:当前行的状态值只依赖于上一行状态的两个值,和上上一行的状态无关;
- 我们再来观察下状态转移方程:
- d p [ i ] [ j ] = m a x ( d p [ i − 1 ] [ j ] , d p [ i − 1 ] [ j − c [ i ] ] + w [ i ] ) dp[i][j] = max(dp[i-1][j], dp[i-1][j - c[i]] + w[i]) dp[i][j]=max(dp[i−1][j],dp[i−1][j−c[i]]+w[i])
- 那么对上文的观点再进行一个补充:对于第二维状态,状态转移的过程一定是从值小的到值大的方向进行转移;
- 大胆假设将第一维去掉,再来看状态转移方程是否能够满足我们的需要:
- d p [ j ] = m a x ( d p [ j ] , d p [ j − c [ i ] ] + w [ i ] ) dp[j] = max(dp[j], dp[j - c[i]] + w[i]) dp[j]=max(dp[j],dp[j−c[i]]+w[i])
- 这种情况下,如果还是按照原有代码进行状态转移,会发现一个问题:每个物品会被用多次。举个例子:容量为4的背包,放入容量为2,价值为 8 的物品,那么 d p [ 4 ] dp[4] dp[4] 的状态一定来自 d p [ 2 ] dp[2] dp[2],而 d p [ 2 ] dp[2] dp[2] 的状态来自 d p [ 0 ] dp[0] dp[0],这时候我们发现这个容量为 2 的物品被用了两次: d p [ 4 ] = d p [ 2 ] + 8 = ( d p [ 0 ] + 8 ) + 8 dp[4] = dp[2] + 8 = (dp[0] + 8) +8 dp[4]=dp[2]+8=(dp[0]+8)+8
- 事实上,我们只需要将状态转移的过程从大到小逆序求解即可,原因就是在求解 d p [ j ] dp[j] dp[j] 时,如果逆序求解,由于 j − c [ i ] j - c[i] j−c[i] 比 j j j 小,所以 d p [ j − c [ i ] ] dp[j - c[i]] dp[j−c[i]] 相当于还是上一行的状态,等价于降维之前的 d p [ i − 1 ] [ j − c [ i ] ] dp[i-1][j-c[i]] dp[i−1][j−c[i]];而反观顺序求解, d p [ j − c [ i ] ] dp[j - c[i]] dp[j−c[i]] 状态计算会在 d p [ i ] dp[i] dp[i] 之前,则变成了当前行的状态,和 0/1 背包状态转移不符。
- 最后给出降维后,状态转移部分的代码实现:
void zeroOneKnapsack(int knapsackSize, Knapsack *knap, int maxCapacity) {
zeroOneKnapsackInit(maxCapacity);
for(int i = 0; i < knapsackSize; ++i) {
for(int j = maxCapacity; j >= knap[i].capacity; --j) {
dp[j] = opt(dp[j], dp[j - knap[i].capacity] + knap[i].weight);
}
}
}
- 关于 0/1 背包 的内容到这里就结束了。
- 如果还有不懂的问题,可以 想方设法 找到作者的微信进行在线咨询。
- 本文所有示例代码均可在以下 github 上找到:github.com/WhereIsHeroFrom/Code_Templates
六、0/1 背包问题相关题集整理
题目链接 | 难度 | 解析 |
---|---|---|
HDU 2602 Bone Collector | ★☆☆☆☆ | 【例题1】最大值问题 |
HDU 1203 I NEED A OFFER! | ★☆☆☆☆ | 【例题2】最小值问题 |
HDU 1171 Big Event in HDU | ★☆☆☆☆ | 【例题3】存在性问题 |
洛谷 P2925 Hay For Sale S | ★☆☆☆☆ | 存在性问题 |
PKU 3624 Charm Bracelet | ★☆☆☆☆ | 最大值问题 |
HDU 1864 最大报销额 | ★☆☆☆☆ | 存在性问题 |
PKU 1948 Triangular Pastures | ★☆☆☆☆ | 二维 0/1 背包 的 存在性问题 |
HDU 2126 Buy the souvenirs | ★☆☆☆☆ | 最大值问题 + 方案数 |
HDU 2955 Robberies | ★☆☆☆☆ | 最大值问题 |
PKU 2184 Cow Exhibition | ★★☆☆☆ | 负容量问题 |
P1507 NASA的食物计划 | ★★☆☆☆ | 二维容量 最大值问题 |
HDU 4502 吉哥系列故事——临时工计划 | ★★☆☆☆ | 区间、非恰好的情况 |
HDU 3448 Bag Problem | ★★☆☆☆ | 【例题7】搜索 + 剪枝 求解 0/1 背包 |
HDU 2660 Accepted Necklace | ★★☆☆☆ | 增加一维状态 |
PKU 1157 LITTLE SHOP OF FLOWERS | ★★☆☆☆ | 最大值问题 |
HDU 3466 Proud Merchants | ★★★☆☆ | 【例题5】有顺序的 0/1 背包 |
HDU 2546 饭卡 | ★★★☆☆ | 【例题6】负容量问题 |
HDU 2639 Bone Collector II | ★★★☆☆ | 增加一维状态 |
HDU 3535 AreYouBusy | ★★★☆☆ | 分组 0/1 背包 |
HDU 6376 度度熊剪纸条 | ★★★★☆ | 0/1 背包 +贪心 |
HDU 5543 Pick The Sticks | ★★★★☆ | 0/1 背包 +贪心 |