从斐波那契数列说起
斐波那契数列(Fibonacci sequence),对于n(n>=0),n=0时,f(0)=0; n=1时,f(1)=1;n=2时,f(2)=f(0)+f(1);n=3时,f(2)=f(1)+f(2);以此类推...求解f(n)。 最早 ,我们可能在课本上学过,这是个经典的递归问题,于是,能很很快写出递归的代码:
public int fib(int N) {
if (N == 0) {
return 0;
}
if (N == 1) {
return 1;
}
return fib(N - 1) + fib(N - 2);
}
然而,我们观察,在递归中,例如计算f(5),需要先计算f(4),f(3),f(2);计算f(6),又要去计算f(5),f(4),f(3),f(2)...其中有些计算过的值,会在递归中被反复计算。这个方法的时间复杂度为O(2^n)。显然这并不是个快速的算法。
经过观察,我们发现:f(n)=f(n-1)+f(n-2);
我们可以使用一个数组,来记录一些被计算过的值。
public int fib(int n) {
if (n == 0) {
return 0;
}
if (n == 1) {
return 1;
}
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];
}
上述代码,下一个的值,依赖于前面两个的值,经过初始状态对0和1 的初始化之后,后面的值都会根据前面的依次被生成。这段代码时间复杂度为O(n)。
从这个简单的代码来看,我们先对一些前面的值进行的初始化操作,然后,使用了一个中间数组来存储前面计算的一些值,在计算后续值时,根据状态转移方程,来直接生成后续值。这里先体会下使用动态规划的基本思路。
一般对于dp问题,我们使用几维数组,可以简单看问题中的转态改变量,例如,在下面的01背包问题中,我们在把物品放入背包时候,改变的有物品的状态,背包的容量;所以我们使用一个二维的数组即可。这个小诀窍是我再lc上刷路径问题的时候发现的,根据DFS方法进行转换出来的,有兴趣的可以去lc上看一下那个路径问题的学习。
0-1背包问题解法
有C个物品,一个容量为V的背包,物品的价值数组为v,物品的重量数组为w,求解该背包可放置物品的最大价值。
首先来看物品的状态,一个物品有两种状态:
- 背包容量足够,该物品能被放入背包;则该物品可以选择放入背包也可以选择不被放入背包;
- 背包容量不足以放入该物品;
我们定义dp数组:dp[物品][重量].
对于动态规划的问题,除了要确定状态的转移,状态转移方程,对于数组的初始化和结束的处理,也是相当重要。
对于01背包的问题,在初始化时候,我们要考虑物品仅有第一个的情况,和背包可容纳为0的情况。
- 先来看简单的背包为0的情况,在这种情况下,任何物品都放不进去,所以最大价值为0.
- 然后是只有一个物品往包里放的情况,对于从[0,V]的容量的背包,当第一个物品的重量小于等于背包容量时候,才能被放进去,这时候,能取到第一个物品的价值。但是当第一个物品放不进去背包时候,能取到的最大价值还是0.
之后,我们确定状态转移方程,对于dp[物品][重量], 状态转移方程 : dp[i][fv] = max( dp[i-1][fv] , dp[i-1][fv-w[i]]+ v[i] )。
- dp[i-1][fv] :物品不能放入背包,只能继承之前的最大值。
- dp[i-1][fv-w[i]]+ v[i] : 物品能放入背包,能放入的前提条件是
fv - w[i] >= 0
如果还不太理解,可以自己根据这个二维数组画个表格看下。 如果还不能理解dp问题的状态,状态在转移,建议先去lc上把路径问题和动态规划的easy题目刷完,先找找感觉。
/**
* @param V 最大容量
* @param C 物品个数
* @param v 价值数组
* @param w 重量数组
* @return
*/
public int getMaxValue(int V, int C, int[] v, int[] w) {
int[][] dp = new int[C][V + 1]; //[物品][重量]
//初始化起始数据
for (int fv = 0; fv <= V; fv++) {
//初始化只有一件物品时候,如果能放入背包,则能取到此物品价值
if (w[0] <= fv) {
dp[0][fv] = v[0];
}
}
for (int i = 1; i < C; i++) {
for (int fv = 0; fv <= V; fv++) {
//状态转移方程 : dp[i][fv] = max( dp[i-1][fv] , dp[i-1][fv-w[i]]+ v[i] )
if (fv - w[i] >= 0) {
//该物品大于剩余容量,可以放入背包,此时比较放入该物品与不放该物品的值
dp[i][fv] = Math.max(dp[i - 1][fv], dp[i - 1][fv - w[i]] + v[i]);
} else {
//改物品不能放入背包,直接取不放该物品的值
dp[i][fv] = dp[i - 1][fv];
}
}
}
return dp[C - 1][V];
}
@Test
public void test11() {
int V = 17;
int C = 5;
int[] v = {4, 5, 10, 11, 13};
int[] w = {3, 4, 7, 8, 9};
System.out.println(getMaxValue(V, C, v, w)); //24
}
使用滚动数组进行空间优化
上面我们解dp问题的时候,用了个二维数组,空间复杂度为O(n2)。下面我们来使用滚动数组,来对空间复杂度进行优化。
下面还是先用斐波那契的dp解法做demo.在斐波那契数列中,当前值等于前一个的值加上前前那个的值,我们之前在dp解法中,使用了长度为n+1的数组来保存之前的结果,但是我们发现,其实我们字实际运算中,只需要保存前两个值即可,我们下面对之前的解法进行下空间优化。
public int fib(int n) {
if (n == 0) {
return 0;
}
if (n == 1) {
return 1;
}
int[] dp = new int[2];
dp[1] = 1;
for (int i = 2; i <= n; i++) {
int current = dp[0] + dp[1];
dp[0] = dp[1];
dp[1] = current;
}
return dp[1];
}
优化之后,斐波那契数列的空间复杂度由O(N)变为O(1)了。
- 其实上面在优化的时候,我们就已经使用到了滚动数组。下面来看下滚动数组的作用: 滚动数组是一种能够在动态规划中降低空间复杂度的方法,有时某些二维dp方程可以直接降阶到一维,在某些题目中甚至可以降低时间复杂度,是一种极为巧妙的思想。简要来说就是通过观察dp方程来判断需要使用哪些数据,可以抛弃哪些数据,一旦找到关系,就可以用新的数据不断覆盖旧的数据量来减少空间的使用。
观察背包问题的状态转移方程:dp[i][fv] = max( dp[i-1][fv] , dp[i-1][fv-w[i]]+ v[i] ) ,dp[i][fv]的值,只跟上一行中的上一个值dp[i-1][fv] 和dp[i-1][fv] 左边的一个值dp[i-1][fv-w[i]]+ v[i] )有关系。其他值我们可以像优化斐波那契数列那样,把用不到的空间优化掉。
粗略一看,其实我们在代码过程中,使用到的只有当前行和上一行 其他行我们可以不要,值使用两行保存运算值。
public int getMaxValueUsingArray(int V, int C, int[] v, int[] w) {
int[][] dp = new int[2][V + 1]; //[物品][重量]
//初始化起始数据
for (int fv = 0; fv <= V; fv++) {
//初始化只有一件物品时候,如果能放入背包,则能取到此物品价值
if (w[0] <= fv) {
dp[0][fv] = v[0];
}
}
for (int i = 1; i < C; i++) {
for (int fv = 0; fv <= V; fv++) {
//状态转移方程 : dp[i][fv] = max( dp[i-1][fv] , dp[i-1][fv-w[i]]+ v[i] )
if (fv - w[i] >= 0) {
//该物品大于剩余容量,可以放入背包,此时比较放入该物品与不放该物品的值
dp[i & 1][fv] = Math.max(dp[(i - 1) & 1][fv], dp[(i - 1) & 1][fv - w[i]] + v[i]);
} else {
//改物品不能放入背包,直接取不放该物品的值
dp[i & 1][fv] = dp[(i - 1) & 1][fv];
}
}
}
return dp[(C - 1) & 1][V];
}
经过这次优化,我们把空间复杂度从O(n2)降低到了O(n).
目前我们只需使用两行长度为V + 1的数组,就能保存我们的中间运算结果,并能进行后面的计算。下面我们继续来对空间进行优化,使用一行V + 1的数组,来保存运算结果。思路是:我们需要
dp[i-1][fv] 左边的一个值dp[i-1][fv-w[i]]+ v[i] ),对于dp[i-1][fv] 我们可以直接保存在dp[fv]中,对于dp[i-1][fv-w[i]]+ v[i] ),我们使用dp[fv-w[i]]+ v[i] )。
public int getMaxValueUsingArray2(int V, int C, int[] v, int[] w) {
int[] dp = new int[V + 1]; //[重量],表示此重量时候能获得的最大价值
//初始化起始数据
Arrays.fill(dp, 0);
for (int i = 0; i < C; i++) { //物品
for (int fv = V; fv >= w[i]; fv--) { //重量
//从后往前遍历,物品不会被重复放置
dp[fv] = Math.max(dp[fv], dp[fv - w[i]] + v[i]);
}
}
return dp[V];
}
首先先来对照代码看示例:
// 价值 v : 1 3 4
// 重量 w : 15 20 30
// 背包容量:4
// i : 0
// fv : 4 3 2 1 0
// dp: 15 15 15 15 0
// i : 1
// fv : 4 3 2 1 0
// dp: 35 20 15 15 0
// i : 2
// fv : 4 3 2 1 0
// dp: 35 20 15 15 0
遍历过程还是外层是物品,内层是重量。只不过这次重量是从大到小遍历的,这样做是为了保证遍历过程中,物品不会被重复放置。
下面来看为什么会重复:
// i : 0
// fv : 0 1 2 3 4
// dp: 0 15 *
如上 * 号位置,如果正序遍历,计算到 * 号位置时候,dp[2]=max(dp[1], dp[2-1] + v[0]),其中dp[1]=15,背包里面此时是第一个物品的价值; dp[2-1] + v[0] = dp[1]+v[0],这时候,你会发现,物品1被加了两次,这是不正确的。 对比使用两行数组时候,计算行的值,依赖于另一行中左侧的值,即小背包的最大价值。如果背包容量从小开始到大的话,物品被放入之后,在中间计算过程,还有可能有足够容量放置该物品,则它可能被再次放入。而背包重量从大到小去遍历,则没有这个问题,能保证物品只会被放入一次。
01背包小结
- 注意dp数组的初始化值,初始化是dp问题开始的起点。另外,同样也要注意,结束时候,该如何取dp数组的值。
- 找对状态转移方程。这是dp问题的关键,在一开始时候,可能我们有点儿感觉,这是个dp问题,但是不知道如何dp,这时候要多想想,回顾下你做过的dp问题。
- dp问题的解数组,是根据dp问题的变化量来的,但是可以使用滚动数组优化空间使用。优化的前提是,你非常了解dp过程中,你使用到了哪些空间,哪些空间又是你使用不到的。