摘要
背包问题是一类经典的动态规划问题,它非常灵活,需要仔细琢磨体会,本文先对背包问题的几种常见类型作一个总结,然后再看看LeetCode上几个相关题目。
一、01背包问题(小偷问题)
你只有一个容量有限的背包,总容量为c,有n个可待选择的物品,每个物品只有一件,它们都有各自的重量和价值,你需要从中选择合适的组合来使得你背包中的物品总价值最大。
1.1 问题分析
假设有4个物品,它们的价值(v)和重量(w)如下图:
背包总容量为10,现在要从中选择物品装入背包中,要求物品的重量不能超过背包的容量,并且最后放在背包中物品的总价值最大。、
这里每个物品只有一个,对于每个物品而言,只有两种选择,盘它或者不盘,盘它记为1,不盘记为0,我们不能将物品进行分割,比如只拿半个是不允许的。这就是这个问题被称为0/1背包
问题的原因。所以究竟选还是不选,这是个问题。让我们先来体验一下将珠宝装入背包的感觉,为了方便起见,用xi
代表第i个珠宝的选择(xi = 1
代表选择该珠宝,0则代表不选),vi
代表第i个珠宝的价值,wi
代表第i个珠宝的重量。于是我们就有了这样的限制条件:
我们的初始状态是背包容量为10,背包内物品总价值为0,接下来,我们就要开始做选择了。对于1号珠宝,当前容量为10,容纳它的重量2绰绰有余,因此有两种选择,选它或者不选。我们选择一个珠宝的时候,背包的容量会减少,但是里面的物品总价值会增加。就像下面这样:
这样就分出了两种情况,我们继续进行选择,如果我们选择了珠宝1,那么对于珠宝2,当前剩余容量为8,大于珠宝2的容量3,因此也有两种选择,选或者不选。现在,我们得到了四个可能结果,我们每做出一个选择,就会将上面的每一种可能分裂成两种可能,后续的选择也是如此,最终,我们会得到如下的一张决策图:
这里被涂上色的方框代表我们的最终待选结果,本来应该有16个待选结果,但有三个结果由于容量不足以容纳下最后一个珠宝,所以就没有继续进行裂变。
然后,我们从这些结果中,找出价值最大的那个,也就是13
,这就是我们的最优选择,根据这个选择,依次找到它的所有路径,便可以知道该选哪几个珠宝,最终结果是:珠宝4,珠宝2,珠宝1。
然后,我们从这些结果中,找出价值最大的那个,也就是13
,这就是我们的最优选择,根据这个选择,依次找到它的所有路径,便可以知道该选哪几个珠宝,最终结果是:珠宝4,珠宝2,珠宝1。
1.2 分而治之
接下来,我们就来分析一下,如何将它扩展到一般情况。为了实现这个目的,我们需要将问题进行抽象并建模,然后将其划分为更小的子问题,找出递推关系式,这是分治思想中很重要的一步。
- 抽象问题,背包问题抽象为寻找组合(x1,x2,x3...xn,其中xi取0或1,表示第i个物品取或者不取),vi代表第i个物品的价值,wi代表第i个物品的重量,总物品数为n,背包容量为c。
- 建模,问题即求max(x1v1 + x2v2 + x3v3 + ... + xnvn)。
- 约束条件,x1w1 + x2w2 + x3w3 + ... + xnwn < c。
- 定义函数KS(i,j):代表当前背包剩余容量为j时,前i个物品最佳组合所对应的价值;
那这里的递推关系式是怎样的呢?对于第i个物品,有两种可能:
- 背包剩余容量不足以容纳该物品,此时背包的价值与前i-1个物品的价值是一样的,KS(i,j) = KS(i-1,j)
- 背包剩余容量可以装下该商品,此时需要进行判断,因为装了该商品不一定能使最终组合达到最大价值,如果不装该商品,则价值为:KS(i-1,j),如果装了该商品,则价值为KS(i-1,j-wi) + vi,从两者中选择较大的那个,所以就得出了递推关系式:
对于这个问题的子问题,这里有必要详细说明一下。原问题是,将n件物品放入容量为c的背包,子问题则是,将前i件物品放入容量为j的背包,所得到的最优价值为KS(i,j),如果只考虑第i件物品放还是不放,那么就可以转化为一个只涉及到前i-1个物品的问题。如果不放第i个物品,那么问题就转化为“前i-1件物品放入容量为j的背包中的最优价值组合”,对应的值为KS(i-1,j)。如果放第i个物品,那么问题就转化成了“前i-1件物品放入容量为j-wi的背包中的最优价值组合”,此时对应的值为KS(i-1,j-wi)+vi。
public class Solution{
// 表示物品的价值
int[] vs = {0,2,4,3,7};
// 表示物品的容量
int[] ws = {0,2,3,5,5};
@Test
public void testKnapsack1() {
int result = ks(4,10);
System.out.println(result);
}
private int ks(int i, int c){
int result = 0;
if (i == 0 || c == 0){
// 初始条件
result = 0;
} else if(ws[i] > c){
// 装不下该珠宝
result = ks(i-1, c);
} else {
// 可以装下
int tmp1 = ks(i-1, c);
int tmp2 = ks(i-1, c-ws[i]) + vs[i];
result = Math.max(tmp1, tmp2);
}
return result;
}
}
1.3 动态
假设(x1,x2,…,xn)是01背包问题的最优解,则有(x2,x3,…,xn)是其子问题的最优解,假设(y2,y3,…,yn)是上述问题的子问题最优解,则有(v2y2+v3y3+…+vnyn)+v1x1 > (v2x2+v3x3+…+vnxn)+v1x1。说明(X1,Y2,Y3,…,Yn)才是该01背包问题的最优解,这与最开始的假设(X1,X2,…,Xn)是01背包问题的最优解相矛盾,故01背包问题满足最优性原理
。
就像上一篇里的解法一样,自上而下的解法与分治法的区别就是增加了一个数组用来存储计算的中间结果来减少重复计算。这里,我们只需要多定义一个二维数组。
表格中,每一个格子都代表着一个子问题,我们最终的问题是求最右下角的格子的值,也就是i=4,j=10
时的值。这里,我们的初始条件便是i=0或者j=0时对应的ks值为0,这很好理解,如果可选物品为0,或者剩余容量为0,那么最大价值自然也是0。代码如下:
package 动态规划算法;
public class testKnapsack2 {
int[] vs = {0, 2, 4, 3, 7};
int[] ws = {0, 2, 3, 5, 5};
Integer[][] results = new Integer[5][11];
private int ks2(int i, int c) {
int result = 0;
// 如果该结果已经被计算,那么直接返回
if (results[i][c] != null) {
return results[i][c];
}
if (i == 0 || c == 0) {
// 初始条件
result = 0;
} else if (ws[i] > c) {
// 装不下该珠宝
result = ks2(i - 1, c);
} else {
// 可以装下
int tmp1 = ks2(i - 1, c);
int tmp2 = ks2(i - 1, c - ws[i]) + vs[i];
result = Math.max(tmp1, tmp2);
}
results[i][c] = result;
return result;
}
public void testKnapsack2() {
int result = ks2(4, 10);
System.out.println(result);
}
}
接下来,我们用自下而上的方法来解一下这道题,思路很简单,就是不断的填表,回想一下上一篇中的斐波拉契数列的自下而上解法,这里将使用同样的方式来解决。还是使用上面的表格,我们开始一行行填表。
当i=1时,即只有珠宝1可供选择,那么如果容量足够的话,最大价值自然就是珠宝1的价值了。
当i=2时,有两个物品可供选择,此时应用上面的递推关系式进行判断即可。这里以i=2,j=3为例进行分析:
剩下的格子使用相同的方法进行填充即可:
这样,我们就得到了最后的结果:13。根据结果,我们可以反向找出各个物品的选择,寻找的方法很简单,就是从i=4,j=10
开始寻找,如果ks(i-1,j)=ks(i,j)
,说明第i个物品没有被选中,从ks(i-1,j)
继续寻找。否则,表示第i个物品已被选中,则从ks(i-1,j-wi)
开始寻找。
package 动态规划算法;
public class testKnapsack3 {
int[] vs = {0,2,4,3,7};
int[] ws = {0,2,3,5,5};
Integer[][] results = new Integer[5][11];
public void testKnapsack3() {
int result = ks3(4,10);
System.out.println(result);
}
private int ks3(int i, int j){
// 初始化
for (int m = 0; m <= i; m++){
results[m][0] = 0;
}
for (int m = 0; m <= j; m++){
results[0][m] = 0;
}
// 开始填表
for (int m = 1; m <= i; m++){
for (int n = 1; n <= j; n++){
if (n < ws[m]){
// 装不进去
results[m][n] = results[m-1][n];
} else {
// 容量足够
if (results[m-1][n] > results[m-1][n-ws[m]] + vs[m]){
// 不装该珠宝,最优价值更大
results[m][n] = results[m-1][n];
} else {
results[m][n] = results[m-1][n-ws[m]] + vs[m];
}
}
}
}
return results[i][j];
}
private int ks3V2(int i, int j){
// 开始填表
for (int m = 0; m <= i; m++){
for (int n = 0; n <= j; n++){
if(m==0||n==0){
results[m][n]=0;
}
if (n < ws[m]){
// 装不进去
results[m][n] = results[m-1][n];
} else {
// 容量足够
if (results[m-1][n] > results[m-1][n-ws[m]] + vs[m]){
// 不装该珠宝,最优价值更大
results[m][n] = results[m-1][n];
} else {
results[m][n] = results[m-1][n-ws[m]] + vs[m];
}
}
}
}
return results[i][j];
}
}
二、完全背包问题
三、多重背包问题
四、leetcode的算法问题
剑指 Offer II 101. 分割等和子集
剑指 Offer II 102. 加减的目标值
剑指 Offer II 103. 最少的硬币数目(与本题是同一道题目)
剑指 Offer II 104. 排列的数目
博文参考
【动态规划】一次搞定三种背包问题 - 弗兰克的猫 - 博客园
https://www.youtube.com/watch?v=j4eab7N3Hfs