0. 说明
首先,还请不要被滚动数组这种词给吓到,它的本质其实很简单,在之前的经历中你很可能已经接触过,例如比较经典的斐波那契数列。
其次,读者可能更想知道0-1背包压缩为什么要倒序遍历,请跳转到第三部分0-1背包问题分析,我一定努力将该问题表达清楚。
1. 思想介绍
在动态规划运行过程中,申请到的数组空间可以进行压缩。根据dp方程判断可以抛弃哪些数据,从而只用更少的空间来存在动态规划过程中产生的数据,并不断用新数据覆盖旧数据,减少空间的使用。
2. 一维数组空间的压缩
leetcode链接:509. 斐波那契数
70. 爬楼梯
以斐波那契数列为例:
class Solution {
public int fib(int n) {
if (n <= 1) return n;
int[] dp = new int[n + 1];
// 初始化
dp[0] = 0;
dp[1] = 1;
for (int i = 2; i <= n; i++) {
dp[i] = dp[i - 1] + dp[i - 2];
}
return dp[n];
}
}
上述求解方式,申请了n个空间,并通过 dp[i]
来表示第 i
个数的斐波那契数值。但是,通过分析状态转移方程 dp[i] = dp[i - 1] + dp[i - 2]
,可以发现,其实在推导过程中只需要维护两个数值就够了,并不需要记录整个数列过程中的值。
即我们的目的是为了得到
dp[n]
,那么只需要维护在循环过程中的最新的两个数值,保证他们不被覆盖,最终能够得到正确的数值即可。而并不需要保留每一个i的数值dp[i]
。
压缩空间后的代码如下所示:
class Solution {
public int fib(int n) {
if (n < 2) return n;
int a = 0, b = 1, c = 0;
for (int i = 1; i < n; i++) {
c = a + b;
a = b;
b = c;
}
return c;
}
3. 二维数组空间的压缩(0-1背包问题)
leetcode链接:416. 分割等和子集
下面来分析0-1背包问题的空间压缩。
3.1 理论基础
首先来理一下0-1背包的理论基础,先搞清楚如何用二维数组表示该问题。
假设有n个物体,标号分别为[1,2,… ,n],每个物体都有各自的重量用 weight
数组来表示,以及对应的价值用 value
数组表示。题目的需求是在有限背包容量的情况下,尽可能装更多价值的物品。
用 dp[i][j]
来表示下标在 1~i
范围内的物品里任意取,放入容量为 j
的背包里,价值总和最大为多少
推导 dp[i][j]
过程如下:对于物品 i ,有两种选择方式,不放入背包或放入背包,dp[i][j]
要取这两种选择方式下对应的最大价值。分别对其进行分析。
-
i 不放入背包:可以表示为,标号为 [1, 2, …, i - 1] 的物品里任意取(因为 i 已经确定不放入背包了),放入容量为
j
的背包里,那么其价值总和最大为dp[i - 1][j]
。即这种情况下可以表示为dp[i][j] = dp[i - 1][j]
(但是不一定,因为这种情况下不一定取到最大值,只是为了方便理解) -
i 放入背包:
dp[i - 1][j - weight[i]]
表示背包容量为j - weight[i]
的时候不放物品 i 的最大价值,那么dp[i - 1][j - weight[i]] + value[i]
,就是背包放物品i得到的最大价值
所以求出了二维数组表示的递推公式:dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]
对dp
进行初始化后,就可以通过两层循环求出需要的结果,如下:
// weight数组的大小 就是物品个数
for(int j = 0; j <= bagweight; j++) { // 遍历背包容量
for(int i = 1; i < weight.size(); i++) { // 遍历物品
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]);
}
}
3.2 用一维dp数组表示背包问题
重头戏来了!!!
参考斐波那契数列压缩时的方式:在循环过程中只需要维护两个变量的值就足够了。
带着这个思路来观察上一小节得到的递推公式,dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]
,可以发现在递推的过程中,事实上也只是用到了两行的数据,即在求第 i 行时,并没有用到 i - 1 行之前的结果,所以就考虑只维持一个一维数组,在循环中不断进行数据覆盖与更新,来得到需要的结果。
推导dp[j]过程同上:
- i 不放入背包:可以表示为,标号为[1, 2, …, i - 1]的物品里任意取,放入容量为
j
的背包里,那么其价值总和最大为dp[j]
。 - i 放入背包:
dp[j - weight[i]]
表示背包容量为j - weight[i]
的时候不放物品i的最大价值,那么dp[j - weight[i]] + value[i]
,就是背包放物品 i 得到的最大价值
一维数组表示出的递推公式为:dp[j] = max(dp[j], dp[j - weight[i] + value[i])
具体实现代码如下:
for(int i = 0; i < weight.size(); i++) { // 遍历物品
for(int j = bagWeight; j >= weight[i]; j--) { // 遍历背包容量
dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
}
}
为什么第二层循环要倒序呢?
- 首先要想明白:用一行的空间来表示的时候,如何能够用原来上一行的数据表示出本行的数据呢?
事实上,未进行第 i 行遍历(外层循环)时,dp数组中存放的数据就为第 i - 1 行对应的值。能理解这一点非常重要。例如,在进行这段循环的代码之前(也就是循环i = 0 之前),dp的值就为刚刚初始化的值;而当 i = 2 时的循环开始前,其实 dp 中存放的值是第一行的数据值,也即dp[1][j]
;以此类推,当i
的循环开始之前,dp中存放的值是第i - 1
行的数据。 - 继续分析,根据二维的递推式,
dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]
,每一个第 i 层的数据都是由第i - 1
层得来,且这个过程中,用到的两个中间dp的列坐标j 和 j - weight[i]
,都小于等于所求的dp的列坐标j
(即j >= [j, j - weight[i]]
)。 - 得出上面两点后,再去看一维递推实现的代码过程:即在遍历过程中所需要求解的
dp[j]
是属第i
行的值,而赋值等式的右边用到的两个数据dp[j], dp[j - weight[i]] + value[i]
是第i - 1
行的数据,所以如果正序遍历的话就会导致下标小的dp先进行赋值(此时下标小的dp已经变为了第i
行数据),而下标大的dp再进行赋值时,需要下标小的第i - 1
行的数据,但此时该数据已经被第i
行给覆盖了,故下标大的dp赋值出现错误!!所以,用倒序遍历的话,就可以先更新大下标的dp值,而不会影响后续更新的过程。
至此分析完毕,希望读者能有所收获,感谢!
参考链接:
代码随想录_0-1背包理论基础
滚动数组(简单说明)