定义
动态规划中一个经典的问题就是0-1背包问题,0-1的意思也就是说对于每个元素/选项,只有两个选择,要么选择,要么不选择。动态规划的分析过程为:确定推导公式/状态转移方程,子元素结构/独立子结构,确定边界,保存中间数据/备忘录。动态规划过程的核心要素就是确定推导公式,类似于初中数学上经常遇到的这样一个场景:
已知:
a(n)=a(n-1)+a(n-2);
a(0)=a(1)=1;
求解a(10)的值,过程就是:
a(10)=a(9)+a(8);
a(9)=a(8)+a(7);
......
a(2)=a(1)+a(0);
这样的计算过程大家都很熟悉,其中:a(n)=a(n-1)+a(n-2)就是所谓的状态转移方程,a(9),a(8)就是独立的子结构,a(0),a(1)就是边界,上面的计算过程常用的有两种方式:
(1)由下向上
计算a(2),根据(a(2),a(1))计算a(3)...,直到得出a(10),也就是常用的迭代方式。
while i<=10:
a(i)=a(i-1)+a(i-2)
i++
(2)由上向下
根据a(9),a(8)的值计算a(10),根据a(8),a(7)的值计算a(9)...,直到a(1),a(0),这就是所谓的递归。
f(x):
if(x==0||x==1) {
return 1
}else{
return f(x-1)+f(x-2)
}
迭代过程没什么好说的,观察递归过程可以发现:
a(10)
=a(9)+a(8)
=( a(8)+a(7) )+a(8)
=( a(7)+a(6) )+( a(6)+a(5) )+( a(7)+a(6) )
即递归的展开过程存在许多重复使用的元素,例如这里的a(7)、a(6),如果继续展开会重复使用更多的a(3)、a(2),所以如果递归的计算过程如:
f(x):
if(x==0||x==1) {
return 1
}else{
return f(x-1)+f(x-2)
}
则存在很多重复的计算,因为每一个f函数都会递归计算到:x==1||x==0,然后再返回数据,这里可以使用数组来保存中间结果:
f(x):
if(x==0||x==1) {
return 1
}else if(arr[x]!=0){
return arr[x]
}else{
arr[x]=f(x-1)+f(x-2)
return arr[x]
}
避免重复的计算过程
示例
存在容量为W的背包,n个价值为v[i](0=<i<n),占据空间为w[i](0=<i<n)的物品,每个物品取一个,问在背包容量W的条件下,最多能够装载的最大物品价值。
分析:
对于n个物品中的每一个,仅存在两种情况,即被放入背包或者不放入背包,此处将该n个物品看做数组,物品在数组中的顺序并无影响,因为每个物品的考虑情况都是完全一样的,以f(i,w)表示在w容量下,i个物品时能够获得的最大价值,则对于第i件物品,放入背包或不放入背包对最大价值的影响为:
f(i,w)=max(s1,s2);
s1=f(i-1,w-w[i])+v[i];
s2=f(i-1,w);
s1表示w容量下,将第i件物品放入背包,最大价值即为f(i-1,w-w[i])+v[i],其中v[i]表示第i件物品的价值,f(i-1,w-w[i])表示w容量下,除去第i件之外,在容量为w-w[i],物品数为(i-1)个的情况下的最大价值;
s2表示w容量下,不将第i件物品放入背包,容量为w,物品数为(i-1)个的情况下的最大价值
两者相比较即可获得f(i,w)的最大值,f(i,w)=max(s1,s2)即为状态转移方程,对应的边界即为只有1个物品时的最大价值
参考代码
算法1(严重不推荐):
public static int method1(int n,int W){//n表示物品个数,W表示空间大小
if(n==0){//当只有1个物品时,只需判断该物品能否放下即可
if(W>=w[0]){//能够放入背包
return v[0];
}else{//不能放入
return 0;
}
}else{//当多于1个物品时,按照状态转移方程计算
if(W>=w[n]){//第i个物品可以被放入背包时,比较放入和不放入哪个价值更大
return Math.max(method1(n-1,W),method1(n-1,W-w[n])+v[n]);
}else{//第i个物品不能被放入背包,则直接等于不能放入的价值
return method1(n-1,W);
}
}
}
private static int LEN=50;
private static long[] fib=new long[LEN+1];
public static long fibo2(int n){//使用fib数组保存中间结果
if(n==1||n==2){
fib[1]=1;
fib[2]=1;
return 1;
}else{
if(fib[n]==0){
fib[n]=fibo2(n-1)+fibo2(n-2);
}
return fib[n];
}
}
public static long fibo1(int n){//不保存中间结果
if(n==1||n==2){
return 1;
}else{
return fibo1(n-1)+fibo1(n-2);
}
}
fibo1和fibo2方法都是使用递归调用获得结果,但是两者的性能差异会随着数据的增大变得异常明显,所以虽然fibo2需要额外的数据空间来保存临时结果,但是这种消耗是必需的,否则计算过程的时间将会超出忍耐。
算法2:
public static void method2(){
for(int i=0;i<n;i++){//n表示物品个数
for(int j=0;j<=W;j++){//W表示容量大小
if(i==0){//只有一个物品时
if(j>=w[i]){
s[i][j]=v[i];
}else{
s[i][j]=0;
}
}else{//多于一个物品时
if(j>=w[i]){//第i个物品可以被放入背包时,比较放入和不放入哪个价值更大
s[i][j]=Math.max(s[i-1][j],s[i-1][j-w[i]]+v[i]);
}else{//第i个物品不能被放入背包,则直接等于不能放入的价值
s[i][j]=s[i-1][j];
}
}
}
}
}
方法2使用二维数组来存储临时数据,以增加空间消耗的方式来节省计算性能,观察上述计算过程,可以发现上述使用二维数组来保存计算结果的过程中,是按照由上向下,由左向右的方式进行计算的,这样的计算过程结合转移方程(一切核心都在转移方程中)可以发现某种规律,而这种规律的作用下,使用二维数组就显得没有必要,所以这里进一步优化空间使用,使用一维数组来保存计算结果,节省空间消耗。
算法3:
if(j>=w[i]){//第i个物品可以被放入背包时,比较放入和不放入哪个价值更大
s[i][j]=Math.max(s[i-1][j],s[i-1][j-w[i]]+v[i]);
}else{//第i个物品不能被放入背包,则直接等于不能放入的价值
s[i][j]=s[i-1][j];
}
以图中染色的点作为w[b]==j4的点,即j4容量下,刚好可以把b物品放入背包,然后进行转移方程(放入还是不放入这是个问题)的考虑,那么很明显,在b物品行,j4之前的点都同上,即:
f([a,b],j1)=f([a],j1)
f([a,b],j2)=f([a],j2)
f([a,b],j3)=f([a],j3)
也就是所谓的算法2代码中的:s[i][j]=s[i-1][j],第i件物品不放入背包(因为放不下)
所以这里考虑的问题就是转移方程的描述:
f([a,b],j4)=max[f([a],j4),f([a],j4-w[b])+v[b]],b物品该行,j4之后数据也是这样进行计算,那么规则就出来了,观察下图:
为了避免一个搞不清楚,所以画了两个,在b行,j4及j4之后的所有值都是根据转移方程:f([a,b],j4)=max[f([a],j4),f([a],j4-w[b])+v[b]],因为f([a],j4)和f([a],j4-w[b])都是a行中的数据,也就是说f([a,b],j4),都是根据a行,j4及j4之前的数据而得来的,也就是上图中a行的灰色区域,b行的灰色区域数据都是根据a行的灰色区域数据得来的,而b行的红色区域数据都是照搬上一行的数据,即s[b][j1]=s[a][j1],s[b][j2]=s[a][j2],s[b][j3]=s[a][j3],因为在j1,j2,j3的容量下b物品根本放进去背包,所以不需要根据转移方程计算。
由此得出规律,上图中的计算,知道了a行数据后,b行数据由j5开始向j1移动,j4及j4之后数据,根据a行j4及j4之前数据计算,b行j4之前数据照搬a行即可,之所以由右向左计算,而不是算法2中的由左向右计算,是因为由右向左b行数据例如s[b][j4]、s[b][j5]的数据,即使存放在a行位置上也不会有任何影响,例如把s[b][j5]计算后得到的数据存放在s[a][j5]位置上,s[b][j4]数据存放在s[a][j4]位置上,对b行接下来的计算过程如s[b][j3]、s[b][j2]等,不会产生任何影响,now you get it,所以可以在得知a行数据后,将计算获取的b行数据都存放在a行对应的位置上,同理,后面的c行数据也存放在上一行的数据上,由此得出一维数据保存计算结果。
public static void method3(){
for(int i=0;i<n;i++){//n表示物品个数
for(int j=W;j>=0;j--){//W表示空间容量
if(i==0){//只有一个物品时,判断能否放入背包
if(j>=w[i]){
s1[j]=v[i];
}else{
s1[j]=0;
}
}else{//多于一个物品时,状态转移方程判断
if(j>=w[i]){
s1[j]=Math.max(s1[j],s1[j-w[i]]+v[i]);
}else{
s1[j]=s1[j];//这里当然不用写,不过写出来与上面对照
}
}
}
}
}
这里也就验证了之前提到,动态规划算法是建立在临时数据上的,是依赖于中间数据进行推导的