「题目」:有 N 件物品和一只最多承受重量为 W 的背包,第 i 件物品的重量为weight[i],得到的价值是value[i],每件物品只能使用一次,求解将那些物品装进背包后物品价值最大?
I. 二维数组解法
下面提供利用二维数组动态规划求解0-1背包的思路,思路来源于代码随想录对于0-1背包的求解,仅供学习参考。
假设背包的最大承受重量为4,物品价值如下列表格:
重量 | 价值 | |
物品0 | 1 | 15 |
物品1 | 3 | 20 |
物品2 | 4 | 30 |
背包最大能背的价值是多少?
dp数组如图所示:
DP求解某类问题,首先确定动态转移方程中dp数组的含义。在此处我们假设想要放入物品 i ,背包剩余承重 j 时背包里物品的最大价值量为dp[ i ][ j ](此时并不知道有没有放入)。
现在来确定状态转移方程。dp[ i ][ j ]仅有两种情况,即放入了和没有放入。
放入物品 i 需要满足有足够的容量容纳物品 i 的重量,即 j >= weight[ i ]。满足不了这个条件,物品无法被放入,此时最大价值量为 dp[ i-1 ][ j ]。为什么是 i-1呢?此处比较抽象,我仅表达自己的理解,如有不对请指正。我的理解为:这和 i 的定义有关。i 在此处代表的是支持你从 0~i 号物品中任意选取,即每个物品处于可选与可不选的叠加态当中。但是由于在此处已经确定了不选 i 号物品了,那么对于已经确定的物品就会被摘出,剩下 i-1 个自由选择。因此和dp[ i-1 ][ j ]处相同。下文中的 i - 1同理。
当剩余容量能够支持容纳物品 i 的重量时,此时便有了两个选择,放入与不放入。
倘若不放入,则情况与 i-1 时相同,并且剩余容量也不变为 j 则 dp[ i ][ j ] = dp[ i-1 ][ j ]。如果放入物品 i ,那么剩余容量变成了 j-weight[ i ],此时总价值量为 dp[ i-1 ][ j-weight[ i ] ] + value[ i ]。为了使价值量达到最大,则取两者之间较大的数,即 dp[ i ][ j ] = Math.max(dp[ i-1 ][ j ], dp[ i-1 ][ j-weight[ i ] ] + value[ i ])。此时你也许会想,dp[ i-1 ][ j ]代表的是没有放入 i 处物品的价值,而 dp[ i-1 ][ j-weight[ i ] ] + value[ i ]代表的是放入后的价值,放入了怎么会比没有放入小呢?有这个疑惑是因为没有理解 i 代表的含义。试想一下,假设现在剩余重量为 4,判断dp[2][4]的最大值。如果放入物品2,那么得到的最大价值仅有物品4的价值 30。但是如果放的是物品1和3,总价值便是35,显然最大价值是35。这是因为有时放入这个元素会占据太多重量,反而不是最佳搭配,不是最优解。因此要求两者最大值作为dp值。
现在进行数据初始化:
当背包剩余重量为0时,无论选择什么物品都装不下,所以背包重量 0 对应的那一列均为0。
for(int i=0;i<n;i++){ dp[i][0] = 0; }
因为遍历时,本行数据要用到上一行的数据,因此第一行也要初始化。因为第一行仅能选择是否加入物品0,那么此时能加入时最大价值便是value[0],无法加入时价值便是0,不存在搭配问题。
for(int j=weight[0]; j<=w; j++){ dp[0][j] = val[0]; }
初始化结束长这样:
现在对于没有初始化的区域进行遍历操作。
for(int i=1;i<n;i++){ for(int j=1;j<w+1;j++){ if(j<weight[i]) dp[i][j] = dp[i-1][j]; else dp[i][j] = Math.max(dp[i-1][j],dp[i-1][j-weight[i]]+val[i]); } }
最后返回dp[ N-1 ][ W ]即可。
遍历完长这样:
完整代码:
public static int maxBag(int w, int n, int[] val, int[] weight){
int[][] dp = new int[n][w+1]; //w代表能够承担最大重量,n代表有n个物品可选
for(int i=0;i<n;i++){
dp[i][0] = 0;
}
for(int j=weight[0]; j<=w; j++){
dp[0][j] = val[0];
}
for(int i=1;i<n;i++){
for(int j=1;j<w+1;j++){
if(j<weight[i]) dp[i][j] = dp[i-1][j];
else dp[i][j] = Math.max(dp[i-1][j],dp[i-1][j-weight[i]]+val[i]);
}
}
return dp[n-1][w];
}
II. 一维数组解法
一维dp数组求解便是利用滚动数组求解,具体解释如下:
滚动数组是DP中的一种编程思想。简单的理解就是让数组滚动起来,每次都使用固定的几个存储空间,来达到压缩,节省存储空间的作用。起到优化空间,主要应用在递推或动态规划中(如01背包问题)。因为DP题目是一个自底向上的扩展过程,我们常常需要用到的是连续的解,前面的解往往可以舍去。所以用滚动数组优化是很有效的。利用滚动数组的话在N很大的情况下可以达到压缩存储的作用。
当然是用时间去换空间的。
类似于斐波那契数列中用两个数字不断一步步向前交替保存求解,减少了空间复杂度。
因为我们最终需要的是dp[ n-1 ][ w ]所以其中除了最后一行,其他行数最终只是求解路上的垫脚石。我们需要想办法优化代码,减少空间复杂度。只要将数据保存在dp[ j ]里,下一次用的时候直接在dp[ j ]上修改,就可以将数组折叠成一维数组。
现利用一维数组dp,dp[ j ]代表剩余空间为 j 时的最大价值。现在列出状态转移方程。假设物品序号为 i ,则dp[ j ] = Math.max(dp[ j ],dp[ j-weight[ i ] ]+value[ i ])。对比二维数组的状态转移方程我们会发现,二维数组max函数里的dp[ i ][ j-1 ]在一维数组里是dp[ j ],那么这个-1去哪里了? 答案是在此处max函数里的dp[ j ]存储的值是上一个 i 对应的dp[ j ],未完成i++,放在二维数组里就是上一行的dp[ j ],用这种巧妙的次次更新的方式减少不必要空间的开辟是这里最巧妙的一环。同时,为了保持dp[ j-weight[ i ] ]+value[ i ] 当中dp[ j-weight[ i ] ]的结果是“上一行”对应的值,我们在遍历 j 时需要倒序遍历,防止更新值重复利用。遍历到能够容纳“本行”(即此时 i 对应的重量)。整体求解代码如下:
public static int maxBag(int w, int n, int[] val, int[] weight){
int[] dp = new int[w+1];
dp[0] = 0;
for(int i=0;i<n;i++){
for(int j=w;j>=weight[i];j--){
dp[j] = Math.max(dp[j],dp[j-weight[i]]+val[i]);
}
}
return dp[w];
}
图解如下:
III. 完全背包
完全背包和0-1背包的区别在于完全背包当中的物品是可以反复使用,也就是说每一样都有无数个。在代码上区别在于,0-1背包的滚动数组求解中遍历时采用的方式是倒叙遍历,因为需要使用上一轮遍历的数据,正序遍历后面的数据会调用本轮数据。但是在完全背包当中,每一个物品可以取无数个,在对物品 i 遍历时,便可以调取本轮数据,正序遍历。代码如下:
public static int maxBag(int w, int n, int[] val, int[] weight){
int[] dp = new int[w+1];
dp[0] = 0;
for(int i=0;i<n;i++){
for(int j=weight[i];j<=w;j++){
dp[j] = Math.max(dp[j],dp[j-weight[i]]+val[i]);
}
}
return dp[w];
}