动态规划
动态规划算法与分治法类似,其基本思想也是将待求解问题分解成若干个子问题,先求解子问题,然后从这些子问题的解得到原问题的解。与分治法不同的是,适用于用动态规划求解问题的时候,经分解得到的子问题往往不是相互独立的。若用分治法解这类问题,则分解得到的子问题数目太多,以至于最后解决原问题需要消耗指数时间。然而,不同子问题的数目常常只有多项式量级。在用分治法求解时,有些子问题被重复计算了许多次。如果能够保存已解决的子问题的答案,而在需要时再找出已求得的答案,就可以避免大量重复的计算,从而得到多项式时间算法。为了达到这个目的,可以用一个表来记录所有已解决的子问题的答案。不管该子问题以后是否被利用到,只要它被计算过,就将其结果填入表中。这就是动态规划法的基本思想。具体的动态规划算法是多种多样的,但它们具有相同的填表格式。
动态规划算法适用于解最优化问题。通常可以按以下步骤设计动态规划算法:
- 找出最优解的性质,并刻画其结构特征。
- 递归地定义最优值
- 以自底向上的方式计算出最优值
- 根据计算最优值时得到的信息,构造最优解
矩阵连乘问题(最优计算次序问题)
什么是矩阵连乘?
当前一个矩阵的行数等于下一个矩阵的列数,就可以进行矩阵乘法运算,如果给定n个矩阵{A1,A2,……An}其中两两矩阵是可乘的,(23,34,4*5……)这就是矩阵连乘。
那我们每次都要按顺序乘吗?
由于乘法满足结合律,所以我们有多种选择。比如A1A2A3A4就有五种乘法
(A1(A2(A3A4)))
(A1((A2A3)A4))
((A1A2)(A3A4))
((A1(A2A3))A4)
(((A1A2)A3)A4)
那这些选择导致的结果一样吗?
结果是一样,但如果我们考虑到计算效率的问题(计算量),就值得深究了。
两个矩阵连乘的计算量:是前一个矩阵的行和列后一个矩阵的列
(32*3)
给出计算两个矩阵相乘的计算量的代码
public static void MatrixMultiply(int [][]a,int [][]b,int [][]c,
int ra,int ca,int rb,int cb)
{
if(ca != rb) { }
for(int i = 0;i < ra; ++i)
{
for(int j = 0;j<cb; ++j)
{
int tmp = 0;
for (int k = 0; k < ca; ++k)
{
tmp += a[i][k] * b[k][j];
}
c[i][j] = tmp;
}
}
}
比如 5100 1008 860这三个矩阵相乘;
1)(5100 1008)860
先计算前两个矩阵相乘,这样需要乘51008次,然后再和第三个矩阵乘 总共需要51008+5860=6400次
2)5100(1008860)
先计算后两个,需要乘100860次,然后在和第一个相乘,总共需要100860+5100*60=78000次
这样经过对比,很明显前者的计算次数少很多,效率更高,所以我们在计算矩阵连乘时,不能使用穷举搜索法一个个计算,而是要使用方法,否则offer便飞走了。
我们现在考虑用动态规划来解决?
第一步:分析最优解结构
假设i–j个矩阵
简记为A【i,j】 我们可以在Ak和Ak+1之间断开为 A【i,k】【 k+1,j】
按照此次序,先计算i–k,再计算k+1–j 然后再加上A【i,k】和A【 k+1,j】相乘的计算量就是总共的计算量了。这里的每个部分如果取到最优解,那么结果便是最优解。
矩阵连乘计算次序问题的最优解包含着其子问题的最优解。这种性质称为最优子结构性质。问题的最优子结构性质是该问题可用动态规划算法求解的显著特征。
第二步:建立递归关系
我一直觉得动态规划和递归与分治策略思想分不开,可以说动态规划是递归与分治策略的升华。
而找到递归关系我们需要清楚什么时候从递归退出:
当i==j时,矩阵为单一矩阵,无需计算 计算量m【i】【j】=0
当i<j时,m【i】【j】=min{m【i】【k】+m【k+1】【j】+qqq}(k=i,i+1……j-1)
根据最优解结构可以知道qqq就是A【i,k】和A【 k+1,j】相乘的计算量了,那具体怎么算呢?
假如矩阵的维数给定
A1 A2 A3 A4
36 68 87 715
将维数入到数组里:p=【3,6,8,7,15】重复的不入
当k=2时,也就是从第二个划分开,则 A【1,2】和A【 3,4】相乘的计算量=3815 ,也就是**p[i-1]p[k]p[j]
所以最后的表达式为:
3、计算最优值
其实我们要做的就是把数学公式用代码表示出来。
可是如果我们只用递归算法计算,可以看到将耗费指数时间。在递归计算时,许多子问题被重复计算多次。这也是该问题可用动态规划算法求解的又一显著特征。
用动态规划算法解此问题,可依据其递归式以自底向上的方式进行计算。在计算过程中,保存子问题答案。每个子问题只计算一次,而在后面需要时只需要简单的查一下,从而避免出现大量的重复计算。
下面的代码是如何查找子问题答案的呢?
可以看到保存在数组里,然后判断数组里存的值,如果<=0,则不需要计算。
1)递归计算
public static int MatrixChain(int []p,int i,int j,int [][]m,int [][]c) {
if(i == j) return 0;
else if(m[i][j] <= 0) {
m[i][j] = MatrixChain(p, i + 1, j, m, c) + p[i - 1] * p[i] * p[j]; //k = i;
c[i][j] = i;
for (int k = i + 1; k < j; ++k) {
int t = MatrixChain(p, i, k, m, c) + MatrixChain(p, k + 1, j, m, c) + p[i - 1] * p[k] * p[j];
if (m[i][j] > t) {
m[i][j] = t;
c[i][j] = k;
}
}
}
return m[i][j];
}
2)非递归计算
非递归计算时可以看到在计算m【i】【j】时,只用到已计算出的m【i】【k】和m【k+1】【j】
public static int NiceMatrixChain(int []p,int n,int [][]m,int [][]c) {
for(int i = 1;i<=n;++i) m[i][i] = 0;
for(int s = 2;s<=n;++s)
{
for(int i = 1;i<=n-s+1;++i) {
int j = i + s - 1;
m[i][j] = m[i][i] + m[i + 1][j] + p[i - 1] * p[i] * p[j];
c[i][j] = i;
for (int k = i + 1; k < j; ++k) {
int t = m[i][k] + m[k + 1][j] + p[i - 1] * p[k] * p[j];
if (m[i][j] > t) {
m[i][j] = t;
c[i][j] = k;
}
}
}
Print_Array(m,n+1,n+1);
}
return m[1][n];
}
3)控制台输出
final int n=6;
int []p = {30,35,15,5,10,20,25};
int [][]m = new int[n+1][n+1];
int [][]c = new int[n+1][n+1];
int minv=MatrixChain(p,1,n,m,c);
System.out.println(minv );
Print_Array(m,n+1,n+1);
可以看到m矩阵为如图所示
我们再打印出c矩阵,当i=1 ,j=6是,k=3。
可以看出此算法的时间复杂度为 O(ik+1j)
即O(n^3)比指数次方效率高很多。
再调用此方法看我们具体是怎么打印的
public static void MatrixBack(int [][]c,int i,int j)
{
if(i == j) return ;
MatrixBack(c,i,c[i][j]);
MatrixBack(c,c[i][j]+1,j);
System.out.printf("Matrix A %d * %d And %d * %d \n",i,c[i][j],c[i][j]+1,j);
}
打印函数
public static void Print_Array(int [][]c,int n,int m)
{
for(int i = 0;i<n;++i)
{
for(int j = 0;j<m;++j)
{
System.out.printf("%8d",c[i][j]);
}
System.out.println();
}
System.out.println();
}
动态规划算法的基本要素
可以用动态规划算法的性质有:
1)最优子结构性质
2)子问题重叠性质
1、最优子结构
设计动态规划算法的第一步通常是刻画最优解的结构。当问题的最优解包含了子问题的最优解时,称该问题具有最优子结构。利用问题的最优子结构性质,以自底向上的方式递归地从子问题的最优解逐步构造出整个问题的最优解。
2、重叠子问题
可用动态规划算法求解的问题应该具备的另一个基本要素是子问题的重叠性质。也就是说,在用递归算法自顶向下求解问题时,每次产生的子问题并不总是新问题,有些子问题被反复计算多次。动态规划算法正是利用了这种子问题的重叠性质,对每一个子问题只解一次,而后将其解保存在一个表格中,当再次需要解此子问题时,只是简单地用常数时间查看一下结果。
以矩阵连乘问题为例:递归树的表示
A【1,4】
3、备忘录方法
备忘录方法是动态规划算法的变形。
- 相同点:备忘录方法用表格保存已解决的子问题的答案,在下次需要解决此问题时,只要简单地查看该子问题的答案,而不必重新计算。
- 不同点:备忘录方法的递归是自顶向下的,而动态规划算法则是自底向上递归的
最长公共子序列
给定两个序列X,Y,当另一序列Z既是X的子序列,又是Y的子序列,称Z是序列X,Y的公共子序列
若X={A,B,C,B,D,A,B},Y={B,D,C,A,B,A},序列{B,C,A}是X,Y的一个公共子序列,但它不是X,Y的最长公共子序列。序列{B,C,B,A}也是X,Y的一个公共子序列,它的长度为4,而且它是X,Y的最长公共子序列。
最大公共子序列问题:给定两个序列X={x1,x2,……,xm}和Y={y1,y2,……,yn},找出X和Y的最长公共子序列。
1、最长公共子序列的结构
穷举法是最容易想到的算法。对X的所有子序列,检查它是否也是Y的子序列,从而确定它是否为X和Y的公共子序列。并且在检查过程中记录最长的公共子序列。X的所有子序列都检查过后即可求出X和Y的最长公共子序列。假设X的序列有m个元素,则X下共有2^m个不同子序列,从而穷举搜索法需要指数时间。
事实上,最长公共子序列问题具有最优子结构性质。
设序列X={x1,x2,……,xm}和Y={y1,y2,……yn}的最长公共子序列为Z={z1,z2,……,zk}则:
- 若xm=yn,则zk=xm=yn,且Zk-1是Xm-1和Yn-1的最长公共子序列;
- 若xm!=yn,且zk!=xm,则Z是Xm-1和Y的最长公共子序列;
- 若xm!=yn,且zk!=yn,则Z是X和Yn-1的最长公共子序列。
其实考虑这个问题时就要考虑如何缩小规模,从后往前走,其实就是排除不一样的,遇到不一样的将问题规模缩小。
2、子问题的递归结构
由最优子结构得其实这个是递归的,不断递归缩小规模,我们用c[i][j]来记录序列最长公共子序列的长度。
写出公式后写代码其实很容易,一开始在想公式时想了太多种情况,其实程序不能像数学证明一样考虑太多可能,代码可以做很多事情。
3、计算最优值
如果我们用递归算法就是这样:
public static int LCSLength(String X,String Y,int i,int j)
{
if(i == 0 || j == 0) return 0;
if(X.charAt(i) == Y.charAt(j))
{
return LCSLength(X,Y,i-1,j-1) + 1;
}else
{
int max1 = LCSLength(X,Y,i-1,j);
int max2 = LCSLength(X,Y,i,j-1);
return max1 > max2? max1:max2;
}
}
可是当我们粗略地画出递归树时,会发现进行了太多的重复计算,这个时候使用动态规划可以减少时间复杂度。
public static int num = 0;
public static int LCSLength(String X,String Y,int i,int j,
int [][]c)
{
num+=1;
if(i == 0 || j == 0) return 0;
if(c[i][j] > 0) return c[i][j];
else if(X.charAt(i) == Y.charAt(j))
{
c[i][j] = LCSLength(X,Y,i-1,j-1,c) + 1;
}else
{
int max1 = LCSLength(X,Y,i-1,j,c);
int max2 = LCSLength(X,Y,i,j-1,c);
c[i][j] = max1 > max2? max1:max2;
}
return c[i][j];
}
c[][]二维数组相当于一个记录表,可以把结果记录下来需要的时候直接拿
public static void main(String[] args)
{
String X= new String("#ABCBDAB");
String Y= new String("#BDCABA");
// 0123456
int [][]c = new int[X.length()][Y.length()];
int [][]s = new int[X.length()][Y.length()];
//Print_Array(c,X.length(),Y.length());
int maxlen = LCSLength(X,Y,X.length()-1,Y.length()-1,c,s);
System.out.printf("maxlen: %d \n",maxlen);
System.out.printf("num: %d \n",num);
Print_Array(c,X.length(),Y.length());
System.out.println();
Print_Array(s,X.length(),Y.length());
TrackBack(s,X,X.length()-1,Y.length()-1);
}
这就是c[][]数组中的数据,最终输出结果,最大长度为4
我们可以利用函数将子序列打印出来
public static void TrackBack(int[][]s,String X,int i,int j)
{
if(i == 0 || j == 0) return ;
if(s[i][j] == 1)
{
TrackBack(s,X,i-1,j-1);
System.out.printf("%c ",X.charAt(i));
}else if(s[i][j] == 2)
{
TrackBack(s,X,i-1,j);
}else
{
TrackBack(s,X,i,j-1);
}
}
下面我们再来看看非递归算法,其实就是将表填充完整
public static int LCSLength(String X,String Y,int m,int n,
int [][]c,int [][]s)
{
for(int i = 1;i<=m;++i)
{
for(int j = 1;j<=n;++j)
{
if(X.charAt(i) == Y.charAt(j))
{
c[i][j] = c[i-1][j-1]+1;
s[i][j]= 1;
}else
{
if(c[i-1][j] > c[i][j-1])
{
c[i][j] = c[i-1][j];
s[i][j]= 2;
}
else
{
c[i][j] = c[i][j-1];
s[i][j] = 3;
}
}
}
}
return c[m][n];
}
0-1背包问题
给定n种物品和一背包。物品i的重量是wi,其价值为vi,背包的容量为C。问:应该如何选择装入背包的物品,使得装入背包中物品的总价值最大?
在选择装入背包的物品时,对每种物品i只有两种选择,即装入背包或不装入背包,不能将此物品i装入背包多次,也不能只装入部分的物品i。因此,该问题成为0-1背包问题。
其实就是对wixi求和<=C,而且使得vixi的求和达到最大。因此,0-1背包问题是一个特殊的整数规划问题。
1、最优子结构性质
其实这个问题非常具有实际意义,也就是在一定的条件下利益最大化
这就是最优子结构,在不超过包重的情况下,价值最大化
2、递归关系
我们在每次装物品后要缩小规模,先来一个从前向后的缩小规模,每次i+1;
我们的数学公式如下图所示:
m(i,j)代表的是从i-n,背包重量为j的总价值,i代表的是一个区间,这个是关键。
- 当i==n时,就是只有一件物品,当包的重量j大于wi的时候时,就可以放进去,价值就是vi,反之则为0;
- 当i<n时,说明不止一个,这时候就要分情况讨论:
1)当当前背包的质量小于物品的重量(j<wi)这个时候就是放不了,继续扩大规模m(i+1,j);
2)当当前背包的质量大于物品的重量时,这个时候我们就要比较放与不放哪个价值更大:
首先明确概念,i代表的是区间,m(i,j)代表的是从i-n,重量为j,
加入一组数据:1-2 -8 2-3-2 3-6-4 j=6
我们放进去第一个第二个,等到放第三个时候;如果方进去就是m(3,0)+4,,没有不放的时候的价值大,其实就是价值和重量不成正比,所以我们不能确定。
3、算法描述
这次直接写用动态规划解决的,设计一个二维数组,用它来记录:
public static int Knapsack(int []W,int []V,int i, int j,int n,
int [][]m)
{
if(i == n)
{
m[i][j]= j>=W[n] ? V[n]:0;
} else if (m[i][j] <= 0)
{
if(j < W[i])
{
m[i][j]=Knapsack(W,V,i+1,j,n,m);
}
else
{
int max1 = Knapsack(W,V,i+1,j,n,m);
int max2 = Knapsack(W,V,i+1,j-W[i],n,m) + V[i];
m[i][j]= max1 > max2 ? max1:max2;
}
}
return m[i][j];
}
非递归写呢就是这样:(填表)这个是向前缩小规模,其实就是±号和大于小于号变了一下
public static int NiceForKnapack(int[] W, int[] V, int n, int c, int[][] m)
{
for(int i = 1;i<=n;++i)
{
for(int j = 1;j<=c;++j)
{
if(j < W[i])
{
m[i][j] = m[i-1][j];
}
else
{
m[i][j] = Math.max(m[i-1][j],m[i-1][j-W[i]]+V[i]);
}
}
}
return m[n][c];
}
我们还提供了打印函数,打印哪个物品被放进去了:
public static void IsForTackBack(int []W,int [][]m,int n,int c,boolean []X)
{
for(int i = 1;i<n;++i)
{
if(m[i][c] != m[i+1][c])
{
X[i] = true;
c -= W[i];
}
}
if(m[n][c]!=0){
X[n]=true;
}
}
main函数调用如下:
final int c = 10;
final int n = 5;
int []W = {0,2,2,6,5,4};
int []V = {0,6,3,5,4,6};
boolean []X = {false,false,false,false,false,false};
// 0 1 2 3 4 5
int [][]m = new int[n+1][c+1];
// 0 1 2 3 4 5
int maxv = Knapsack(W,V,1,c,n,m);
System.out.println(maxv);
Print_Array(m,n+1,c+1);
IsForTackBack(W,m,n,c,X) ;
System.out.println(Arrays.toString(X));
for(int i=1;i<=n;i++){
if(X[i]){
System.out.println(X[i]);
}
}
这样呢我们打印的结果就是:可以看到第2,3,6个被放进去了。
小结
动态规划算法与分治法类似,基本思想也是先求出局部最优再求全局最优,逐渐缩小规模,不同的是分治法可能要进行很多重复运算,而动态规划是用一个表来记录所有已解决的问题的解,下次用只需要查找即可。前提条件是问题要满足最优解结构性质。
当递归函数不理解时,多调试看编译器是如何运行的。