首先背包问题有多种如图:
01背包问题
有N件物品和⼀个最多能背重量为W 的背包。第i件物品的重量是weight[i],得到的价值是value[i] 。每
件物品只能⽤⼀次,求解将哪些物品装⼊背包⾥物品价值总和最⼤。
这是标准的背包问题,以⾄于很多朋友看了这个⾃然就会想到背包,甚⾄都不知道暴⼒的解法应该怎么解了。这样其实是没有从底向上去思考,⽽是习惯性想到了背包,那么暴⼒的解法应该是怎么样的呢?看过我动态规划第一篇文章的朋友因该都知道肯定是递归加回溯。每⼀件物品其实只有两个状态,取或者不取,所以可以使⽤回溯法搜索出所有的情况,那么时间复杂度就是O(2^n),这⾥的n表示物品数量。所以暴⼒的解法是指数级别的时间复杂度。进⽽才需要动态规划的解法来进⾏优化!
动规五部曲:
1. 确定dp数组以及下标的含义
对于背包问题的写法, 是使⽤⼆维数组,即dp[i][j] 表示从下标为[0-i]的物品⾥任意取,放进容量为j的背包,价值总和最⼤是多少。只看这个⼆维数组的定义,⼤家⼀定会有点懵,看下⾯这个图:
把dp数组拆开看dp[i]代表在i件物品那一行可以取0~i的物品,dp[j]表示背包已经用了j容量,dp[i][j]表示表示从下标为[0-i]的物品⾥任意取,放进容量为j的背包,价值总和最⼤。要时刻记着这个dp数组的含义,下⾯的⼀些步骤都围绕这dp数组的含义进⾏的,如果哪⾥看不懂了,就来回顾⼀下i代表什么,j⼜代表什么。
2. 确定递推公式
再回顾⼀下dp[i][j]的含义:从下标为[0-i]的物品⾥任意取,放进容量为j的背包,价值总和最⼤是多少。, 我们假设现在要求dp[1][4]的最大价值,那么可以有两个⽅向推出来dp[1][4]:
1.我们先不放物品1进入容量为4的背包,那么dp[1][4]的最大价值即为dp[1 - 1][4] = dp[0][4],注意do[0][4]已经是计算过了的最大价值,而且不难知道只有物品0背包容量为4的时候dp[0][4] = 15即不放入物品1时背包容量4的最大价值。那么不难得出背包容量为j,⾥⾯不放物品i的最⼤价值,此时就是dp[i - 1][j]
2.将物品1放入容量为4的背包,因为物品1的重量为3价值为20,那么dp[1][4]的最大价值为:dp[1 - 1][4 - 3] + 20 = dp[0][1] + 20。dp[0][1]放入物品1后背包剩余容量的最大价值而且不难看出为10,20就是物品1的价值,所以dp[1][4] = 35。那么dp[i - 1][j - weight[i]] + value[i] (物品i的价值),就是背包放物品i得到的最⼤价值。
总结dp[1][4] = max(dp[1 - 1][4], dp[i -1][4 - 3] + 20)
所以递归公式: dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);
3. dp数组如何初始化
⾸先从dp[i][j]的定义出发,如果背包容量j为0的话,即dp[i][0],⽆论是选取哪些物品,背包价值总和⼀定为0。如图:
在看其他情况。状态转移⽅程 dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]); 可以看出i 是由 i-1 推导出来,那么i为0的时候就⼀定要初始化。dp[0][j],即:i为0,存放编号0的物品的时候,各个容量的背包所能存放的最⼤价值。那么很明显当 j < weight[0]的时候,dp[0][j] 应该是 0,因为背包容量⽐编号0的物品重量还⼩。当j >= weight[0]是,dp[0][j] 应该是value[0],因为背包容量放⾜够放编号0物品。
代码初始化如下:
for (int j = weight[0]; j <= bagWeight; j++) {
dp[0][j] = value[0];
}
此时dp数组初始化情况如图所示:
费了这么⼤的功夫,才把如何初始化讲清楚,相信不少朋友平时初始化dp数组是凭感觉来的,但有时候
感觉是不靠谱的。
4. 确定遍历顺序
在如下图中,可以看出,有两个遍历的维度:物品与背包重量
那么问题来了,先遍历物品还是先遍历背包重量呢?
其实都可以!! 但是先遍历物品更好理解。Java代码如下:
for(int i = 1; i < dp.length; i++) { // 遍历物品
for(int j = 0; j <= dp[0].length; j++) { // 遍历背包容量
if (j < weight[i])
dp[i][j] = dp[i - 1][j]; // 当背包容量小于物品大小时无法放入物品。
else
dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);
}
}
先遍历背包,再遍历物品,也是可以的!(注意我这⾥使⽤的⼆维dp数组) 例如这样:
for(int i = 1; i < dp[0].length; i++) { // 遍历物品
for(int j = 0; j <= dp.length; j++) { // 遍历背包容量
if (j < weight[i])
dp[i][j] = dp[i - 1][j]; // 当背包容量小于物品大小时无法放入物品。
else
dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);
}
}
为什么也是可以的呢?
要理解递归的本质和递推的⽅向。
dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]); 递归公式中可以看出dp[i][j]是靠dp[i-1][j]和dp[i - 1][j - weight[i]]推导出来的。dp[i-1][j]和dp[i - 1][j - weight[i]] 都在dp[i][j]的左上⻆⽅向(包括正左和正上两个⽅向),那么先遍历物品,再遍历背包的过程如图所示:
再来看看先遍历背包,再遍历物品呢,如图:
⼤家可以看出,虽然两个for循环遍历的次序不同,但是dp[i][j]所需要的数据就是左上⻆,根本不影响dp[i][j]公式的推导!但先遍历物品再遍历背包这个顺序更好理解。其实背包问题⾥,两个for循环的先后循序是⾮常有讲究的,理解遍历顺序其实⽐理解推导公式难多了。
5. 举例推导dp数组
来看⼀下对应的dp数组的数值,如图:
动规五部分分析完毕,对应Java代码如下:
public int dp(){
int[] value = {15, 20, 30};
int[] weight = {1, 3, 4};
int bagWeight = 4;
int[][] dp = new int[weight.length][bagWeight + 1];
for (int j = weight[0]; j <= bagWeight; j++) {
dp[0][j] = value[0];
}
for (int i = 1; i < dp.length; i++) {
for (int j = 1; j < dp[0].length; 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]] + value[i]);
}
}
return dp[weight.length - 1][bagWeight];
}