动态规划(二维数组)
大致描述就是有 N 个物品,每件物品有各自的重量 W 及价值 V,有一个最大容量为 C 的背包,求在不超过这个背包的容量下能装入物品的最大价值。(N,W,V,C都为整数)
假如对这些物品从1~i 进行编号,并用两个数组V、W分别保存物品的价值和重量,相应的 V[ i ] 和 W[ i ] 表示的意义是第 i 件物品的价值和重量。
首先定义F[ i ][ j ] = 在背包容量为 j 的情况下从前 i 件物品中能选取到的最大价值。这个定义的理解非常重要,它表示的是不同限制情况下的最优解。注意此处的 j 并不是题中描述的最大容量C,而是可以取从0~C的所有整数。
那么根据这个定义可以得到状态转移方程:F[ i ] [ j ] = max( F[ i-1 ] [ j ],F[ i-1 ] [ j-W[ i ] ] + V[ i ])
这个方程表示的意义是,在背包容量 j 的情况下对于物品 i :
- 如果W[ i ]>j,物品重量超出背包容量肯定不能选,那么从前 i 个物品中装入等价于从前 i-1 个物品中装入,
对应的状态转移方程 F[ i ] [ j ] = F[ i-1 ] [ j ] 。 - 如果W[ i ]≤j,假设选入,注意这里是假设,那么有 F[ i ] [ j ] = F[ i-1 ] [ j-W[ i ] ] + V[ i ] ,也就是在背包总容量减去物品 i 重量后的基础上,前 i-1 件物品中的最优解+物品 i 的价值。
注意上面的 j 均表示的是背包总容量,而不是减去某物品重量后剩余的。
现在回到大方程 F[ i ] [ j ] = max( F[ i-1 ] [ j ],F[ i-1 ] [ j-W[ i ] ] + V[ i ]),它是在上面两种情况下取最大值,这也是为什么上面强调假设的原因,它表明的意义是即使物品 i 重量不大于背包总容量,也并不一定是非选它不可, 具体还要看能不能从前 i-1 件中选出更大的价值。
从大方程可以看出,背包容量 j 下前 i 件物品中的最优解,只和背包容量 j 下前 i-1 件物品中的最优解和背包容量减去物品 i 重量下前 i-1 件物品中的最优解有关。因此,可以通过循环迭代将不同情况限制下的最优解填写成一张表,类似下图,并可以根据这张表直接得到不同限制情况下的最优解。
物品编号和背包容量都含0是为了编程方便,并根据表示的实际意义,物品编号为0或背包容量为0时的最大价值应该设为0。
同样下面代码中W,V数组传入时也要将第一位先设为0。比如上图对应的W数组:{0,7,5,4,8,12},V数组:{0,13,8,20,11,6},但这里设0只是起到了占位的作用,也可以设为其它数字。
public class Knapsack_01 {
public static int knapsack(int[] W, int[] V, int C) {
// 创建二维数组用来保存不同限制条件下的最优解
// 注意dp[0][],dp[][0]虽然没有显式初始化0但由于是int数组,默认都为0
int[][] dp = new int[W.length][C + 1];
for (int i = 1; i < W.length; i++) {
for (int j = 1; j < C + 1; j++) {
if (W[i] > j) {
dp[i][j] = dp[i - 1][j];
} else {
dp[i][j] = Math.max(dp[i - 1][j], dp[i - 1][j - W[i]] + V[i]);
}
}
}
// 数组最右下角就是最大价值
return dp[W.length - 1][C];
}
public static void main(String[] args) {
int[] W = { 0, 7, 5, 4, 8, 12 };
int[] V = { 0, 13, 8, 20, 11, 6 };
System.out.println(knapsack(W, V, 20));
}
}
设物品个数 n,背包最大容量 m,因双层循环所以上面代码的时间复杂度 O ( n ∗ m ) Ο(n*m) O(n∗m),空间复杂度即二维数组的空间也是 O ( n ∗ m ) Ο(n*m) O(n∗m)。
dp优化(一维数组)
优化主要是对二维数组的优化,从上面的状态转移方程可以看出,下一行的数据只与上一行的数据有关,更确切的说, 任意 F[ i ] [ j ] 的结果只与它上一行左边的 F[ i-1 ] [ 0 ] ~ F[ i-1 ] [ j ] 这一部分区间数据相关。利用这一特点可以将二维数组优化为一维数组。
优化过程网上看了很多都没有讲的太详细,理解了好久~
如果上面的dp数组是保留2行的话,相信很多人都知道怎么优化,可以通过滚动数组来解决。其实2行1行道理都差不多,上面说了,对于任意 F[ i ] [ j ] 的结果只与它上一行左边的这一部分区间数据相关,还是这张图
假设以物品序号和背包容量为坐标描述的话,(1,20)位置的值13只与(0,0)~(0,20)处的数据有关,对于第一行的数据填写时,从右往左填写,而得到的值并不是直接写在(1,20)的位置上,而是写在上一行相同的位置(0,20),为什么可以这么写?因为下次确定(1,19)处的值的时候已经用不上这个位置的数据了。
因此,虽然只有一行数据,但却包括了逻辑上的两行,每次所填写的位置左边的数据都属于上一行的数据,填写位置及右边的数据都属于下一行,理解了这,就能明白为什么不能从左往右填写:因为第二行新的数据会覆盖第一行可能后面还要参与计算的数据。
这样从右往左填写一遍便可以得到第一行的所有数据,类似的得到第二行,第3行…,直到最后一行,而事实上需要的也仅是最后一行的最后一个位置上的数字。
优化后新的状态转移方程:F[ j ] = max( F[ j ] ,F[ j-W[ i ] ] + V[ i ] ),
方程中括号外面的 F[ j ] 相当于原方程中的 F[ i ] [ j ],
括号里面的 F[ j ] 相当于原方程中的 F[ i-1 ] [ j ],
括号里面的 F[ j-W[ i ] ] + V[ i ] 相当于原方程中的 F[ i-1 ] [ j-W[ i ] ] + V[ i ]。
对比原新方程可以发现,区别就是少了表示行数的 i 。
优化后的代码
public class Knapsack_01 {
public static int knapsack(int[] W, int[] V, int C) {
int[] dp = new int[C + 1];
for (int i = 1; i < W.length; i++) {
for (int j = C; j >= 1; j--) {
if (W[i] < j)
dp[j] = Math.max(dp[j], dp[j - W[i]] + V[i]);
}
}
return dp[C];
}
public static void main(String[] args) {
int[] W = { 0, 7, 5, 4, 8, 12 };
int[] V = { 0, 13, 8, 20, 11, 6 };
System.out.println(knapsack(W, V, 20));
}
}
优化后的时间复杂度不变还是 O ( n ∗ m ) Ο(n*m) O(n∗m),而空间复杂度减小到了 O ( m ) Ο(m) O(m)。
根据dp数组获取选择的物品编号
将没有优化过的状态转移方程反向运用
int j = C;
for (int i = W.length - 1; i >= 1; i--) {
if (dp[i][j] == dp[i - 1][j - W[i]] + V[i]) {
System.out.print("物品" + i + " ");
j = j - W[i];
}
}