【笔记】《算法设计与分析(第三版)》-王晓东著-第三章-动态规划

第三章 动态规划

动态规划算法与分治法类似,其基本思想也是将待求解问题分解成若干个子问题,先求解子问题,然后从这些子问题的解得到原问题的解。

与分治法不同的是,适合于用动态规划法求解的问题,经分解得到的子问题往往不是互相独立的。若用分治法解这些问题,则分解得到的子问题数目太多,以至于最后解决原问题需要耗费指数时间。然而,不同子问题的数目常常只有多项式量级。在用分治法求解时,有些子问题被重复计算了许多次。如果能够保存已解决的子问题的答案,而在需要时再找出已求得的答案,就可以避免大量重复计算,从而得到多项式时间算法。

为了达到这个目的,可以用一个表来记录所有已解决的子问题的答案。不管该子问题以后是否被用到,只要它被计算过,就将其结果填入表中。这就是动态规划法的基本思想。具体的动态规划算法是多种多样的,但它们具有相同的填表样式。

动态规划算法适用于解最优化问题。通常可以按一下步骤设计动态规划算法:

(1)找出最优解的性质,并刻画其结构特征;

(2)递归地定义最优值;

(3)以自底向上的方式计算出最优值;

(4)根据计算最优值时得到的信息,构造最优解。

步骤(1)~(3)是动态规划算法的基本步骤。在只需要求出最优值的情形,步骤(4)可以省去。若需要求问题的最优解,则必须执行步骤(4)。此时,在步骤(3)中计算最优值时,通常需记录更多的信息,以便在步骤(4)中,根据所记录的信息,快速构造出最优解。

3.1 矩阵连乘问题

矩阵A和B可乘的条件是矩阵A的列数等于矩阵B的行数。若A是一个p*q矩阵,B是一个q*r矩阵,则其乘积C=AB是一个p*r矩阵。在上述计算C的标准算法中,主要计算量是3重循环,总共需要pqr次数乘。

矩阵连乘积的最优计算次序问题:对于给定的相继n个矩阵{A1,A2,…,An}(其中矩阵Ai的维数为p(i-1)*pi,i=1,2,…,n),如何确定计算矩阵连乘积A1A2…An的计算次序(完全加括号方式),使得依此次序计算矩阵连乘积需要的数乘次数最少。

穷举搜索法是最容易想到的方法。也就是列举出所有可能的计算次序,并计算出每一种计算次序相应需要的数乘次数,从中找出一种数乘次数最少的计算次序。这样做计算量太大。事实上,对于n个矩阵的连乘积,设其不同的计算次序为P(n)。由于可以先在第k个和第k+1个矩阵之间将原矩阵序列分为2个矩阵子序列,k=1,2,…,n-1;然后分别对这2个矩阵子序列完全加括号;最后对所得的结果加括号,得到原矩阵序列的一种完全加括号方式。由此,可以得到关于P(n)的递推式如下:

P(n) = 1 n=1
	(k=1~n-1)∑P(k)P(n-k) n>1

解此递归方程可得,P(n)实际上是Catalan数,即P(n)=C(n-1),其中,C(n)=1/(n+1)\*二项分布系数(2n n)=Ω(4^n/n^(3/2))

也就是说,P(n)是随n的增长呈指数增长的。因此,穷举搜索法不是一个有效的算法。

下面考虑用动态规划法解矩阵连乘积的最优计算次序问题。

1.分析最优解的结构

这个问题的一个关键特征设:计算A[1:n]的最优次序所包含的计算矩阵子链A[1:k]和A[k+1:n]的次序也是最优的。事实上,若有一个计算A[1:k]的次序需要的计算量更少,则用此次序替换原来计算A[1:k]的次序,得到的计算A[1:n]的计算量将比按最优次序计算所需计算量更少,这是个矛盾。(反证法)同理可知子链A[k+1:n]也满足

因此,矩阵连乘积计算次序问题的最优解包含着其子问题的最优解。这种性质称为最优子结构性质。问题的最优子结构性质是该问题可用动态规划算法求解的显著特征。

2.建立递归关系

设计动态规划算法的第二步是递归地定义最优值。对于矩阵连乘积的最优计算次序问题,设计算A[i:j],1<=i<=j<=n,所需的最少数乘次数为m[i][j],则原问题的最优值为m[1][n]。

当i=j时,可利用最优子结构性质计算m[i][j].事实上,若计算A[i:j]的最优次序在Ak和A(k+1)之间断开,i<=k<j,则m[i][j]+m[k+1][j]+p(i-1)*pk*pj。由于在计算时并不知道断开点k的位置,所以k还未定。不过k的位置只有j-i种可能,即k∈{i,i+1,…,j-1}。因此,k是这j-i个位置中使计算量达到最小的那个位置。

若将对应于m[i][j]的断开位置k记为s[i][j] ,在计算出最优值m[i][j]后,可递归地由s[i][j]构造出相应的最优解。

3. 计算最优值

public static void matrixChain(int []p, int [][]m, int [][]s)
{
	int n=p.length-1;
	for(int i=1;i<=n;i++) m[i][i]=0;
	for(int r=2;r<=n;r++)
		for(int i=1;i<=n-r+1;i++){
			int j=i+r-1;
			m[i][j]=m[i+1][j]+p[i-1]*p[k]*p[j];
			s[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(t<m[i][j]){
					m[i][j]=t;
					s[i][j]=k;
				}
			}
		}
}

算法matrixChain的主要计算量取决于算法中对r,i和k的3重循环。循环体内的计算量为O(1),而3重循环的总次数为O(n3)。因此该算法的计算时间上界为O(n3)。算法所占用的空间显然为O(n^2)。由此可见,动态规划法比穷举搜索法有效得多。

4. 构造最优解

动态规划算法的第四部是构造问题的最优解。算法matrixChain只是计算出了最优值,并未给出最优解。也就是说,通过算法matrixChain的计算,只知道最少乘次数,还不知道具体应按什么次序做矩阵乘法才能达到最少的数乘次数。

事实上,算法matrixChain已记录了构造最优解所需要的全部信息。

下面的算法traceback按算法matrixChain计算出的断点矩阵s指示的加括号方式输出计算A[i:j]的最优计算次序。

public static void traceback(int [][]s, int i, int j)
{
	if(i==j)return;
	traceback(s,i,s[i][j]);
	traceback(s,s[i][j]+1,j);
	System.out.println("Multiply A"+i+","+s[i][j]+"and A"+(s[i][j]+1)+","+j);
}

3.2 动态规划算法的基本要素

从计算矩阵连乘积最优计算次序的动态规划算法可以看出,该算法的有效性依赖于问题本身所具有的两个重要性质:最优子结构性质和子问题重叠性质。从一般的意义上讲,问题所具有的这两个重要性质是该问题可用动态规划算法求解的基本要素。

下面着重研究动态规划算法的这两个基本要素以及动态规划法的变形——备忘录方法。

1. 最优子结构

设计动态规划算法的第一步通常是刻画最优解的结构。当问题的最优解包含了其子问题的最优解时,称该问题具有最优子结构性质。问题的最优子结构性质提供了该问题可用动态规划算法求解的重要线索。

在动态规划算法中,利用问题的最优子结构性质,以自底向上的方式递归地从子问题的最优解逐步构造出整个问题的最优解。算法考查的子问题空间规模较小。

2. 重叠子问题

可用动态规划算法求解的问题应该具备的另一个基本要素是子问题的重叠性质。也就是说,在用递归算法自顶向下求解问题时,每次产生的子问题并不总是新问题,有些子问题被反复计算多次。动态规划算法正式李永乐这种子问题的重叠性质,对每一个子问题都只解一次,而后将其解保存在一个表格中,当再次需要解此子问题时,只是简单地用常数时间查看一下结果。通常,不同的子问题个数随问题的大小呈多项式增长。因此,用动态规划算法通常只需要多项式时间,从而获得较高的解题效率。

3. 备忘录方法

备忘录方法是动态规划算法的变形。与动态规划算法一样,备忘录方法用表格保存已解决的子问题的答案,在下次需要解此子问题时,只要简单地查看该子问题的解答,而不必重新计算。

与动态规划算法不同的是,备忘录方法的递归方式是自顶向下的,而动态规划算法是自底向上递归的。因此,备忘录方法的控制结构与直接递归方法的控制结构相同,区别在于备忘录方法为每个解过的子问题建立了备忘录以备需要时查看,避免了相同子问题的重复求解。

备忘录方法为每个子问题建立一个记录项,初始化时,该记录项存入一个特殊值,表示该子问题尚未求解。在求解过程中,对每个待求子问题,首先查看其相应的记录项。若记录项中存储的是初始化时存入的特殊值,则表示该子问题是第一次遇到,此时计算出该子问题的解,并保存在其相应的记录项中,以备以后查看。若记录项中存储的已不是初始化时存入的特殊值,则表示该子问题已被计算过,其相应的记录项中存储的是该子问题的解答。此时,只要从记录项中取出该子问题的解答即可,而不必重新计算。

下面算法memorizedmatrixChain是解矩阵连乘积最优计算次序问题的备忘录方法:

public static int memorizedmatrixChain(int n)
{
	for(int i=1;i<=n;i++)
		for(int j=i;j<=n;j++)
			m[i][j]=0;
	return lookupChain(1,n);
}
private static int lookupChain(int i, int j)
{
	if(m[i][j]>0)return m[i][j];
	if(i==j)return 0;
	int u=lookupChain(i+1,j)+p[i-1]*p[i]*p[j];
	s[i][j]=i;
	for(int k=i+1;k<j;k++){
		int t=lookupChain(i,k)+lookupChain(k+1,j)+p[i-1]*p[k]*p[j];
		if(t<u){
			u=t;
			s[i][j]=k;}
	}
	m[i][j]=u;
	return u;
}

一般来讲,当一个问题的所有子问题都至少要解一次时,用动态规划算法比用备忘录方法好。此时,动态规划算法没有任何多余的计算。同时,对于许多问题,常可利用其规则的表格存取方式,减少动态规划算法的计算时间和空间需求。当子问题空间中的部分子问题可不必求解时,用备忘录方法则较有利,因为从其控制结构可以看出,该方法只解那些确实需要求解的子问题。

3.3 最长公共子序列

一个给定序列的子序列是在该序列中删去若干元素后得到的序列。确切地说,若给定序列X={x1,x2,…,xm},则另一序列Z={z1,z2,…,zk},X的子序列是指存在一个严格递增的下表序列{i1,i2,…,ik}使得对于所有j=1,2,…,k有zj=x(ij)。例如,序列Z={B,C,D,B}是序列X={A,B,C,B,D,A,B}的子序列,相应的递增下标序列为{2,3,5,7}。

给定两个序列X和Y,当另一序列Z既是X的子序列又是Y的子序列时,称Z是序列X和Y的公共子序列

最长公共子序列问题:给定两个序列X和Y,找出X和Y的最长公共子序列。

1. 最长公共子序列的结构

穷举搜索法是最容易想到的算法。对X的所有子序列,检查它是否也是Y的子序列,从而确定它是否为X和Y的公共子序列。而且在检查过程中记录最长的公共子序列,X的所有子序列都检查过后即可求出X和Y的最长公共子序列。X的每个子序列相应于下标集{1,2,…,m}的一个子集。因此,共有2^m个不同子序列,从而穷举搜索法需要指数时间。

事实上,最长公共子序列问题具有最优子结构性质。

设序列X={x1,x2,…,xm}和Y={y1,y2,…,yn}的最长公共子序列为Z={z1,z2,…,zk},则

(1)若xm=yn,则zk=xm=yn,且Zk-1是Xm-1和Yn-1的最长公共子序列;

(2)若xm≠yn且zk≠xm,则Z是Xm-1和Y的最长公共子序列;

(3)若xm≠yn且zk≠yn,则Z是X和Yn-1的最长公共子序列。

其中,Xm-1={x1,x2,…,xm-1};Yn-1={y1,y2,…,yn-1};Zk-1={z1,z2,…,zk-1}。

由此可见,两个序列的最长公共子序列包含了这两个序列的前缀的最长公共子序列。因此,最长公共子序列问题具有最优子结构性质。

2. 子问题的递归结构

由最长公共子序列问题的最优子结构性质可知,要找出X和Y的最长公共子序列,可按以下方式递归计算:

当xm=yn时,找出Xm-1和Yn-1的最长公共子序列,然后在其尾部加上xm(=yn)即可得X和Y的最长公共子序列。

当xm≠yn时,必须解两个子问题,即找出Xm-1和Y的最长公共子序列及X和Yn-1的一个最长公共子序列。这两个公共子序列较长者即为X和Y的最长公共子序列。

由此递归结构容易看到最长公共子序列问题具有子问题重叠性质。例如在计算X和Y的最长公共子序列时,可能要计算X和Yn-1及Xm-1和Y的最长公共子序列。而这两个子问题都包含一个公共子问题,即计算Xm-1和Yn-1的最长公共子序列。

首先建立子问题最优值的递归关系。用c[i][j]记录序列Xi和Yj的最大公共子序列的长度。当i=0或j=0时,空序列是Xi和Yj的最长公共子序列,故此时c[i][j]=0.在其他情况下,由最优子结构性质可建立递归关系如下:

c[i][j] = 0 i=0,j=0
	c[i-1][j-1]+1 i,j>0;xi=yj
	max{c[i][j-1],c[i-1][j]} i,j>0;xi≠yj

3. 计算最优值

直接利用递归式容易写出计算c[i][j]的递归算法,但其计算时间是随输入长度指数增长的。由于在所考虑的子问题空间中,总共有θ(mn)个不同的子问题,因此,用动态规划算法自底向上地计算最优值能提高算法的效率。

计算最长公共子序列长度的动态规划算法lcsLength以X和Y作为输入。输出两个数组c和b。其中c[i][j]存储Xi和Yj的最长公共子序列的长度,b[i][j]记录c[i][j]的值是由哪一个子问题的解得到的,这在构造最长公共子序列时要用到。问题的最优值,即X和Y的最长公共子序列的长度记录于c[m][n]中。

public static int lcsLength(char []x, char[]y, int [][]b)
{
	int m=x.length-1;
	int n=y.length-1;
	int [][]c=new int [m+1][n+1];
	for(int i=1;i<=m;i++)c[i][0]=0;
	for(int i=1;i<=n;i++)c[0][i]=0;
	for(int i=1;i<=m;i++)
		for(int j=1;j<=n;j++){
			if(x[i]==y[j]){
				c[i][j]=c[i-1][j-1]+1;
				b[i][j]=1;
			}
			else if(c[i-1][j]>=c[i][j-1]){
				c[i][j]=c[i-1][j];
				b[i][j]=2;
			}
			else{
				c[i][j]=c[i][j-1];
				b[i][j]=3;
			}
		}
	return c[m][n];
}

由于每个数组单元的计算耗费O(1)时间,算法lcsLength耗时O(mn)。

4. 构造最长公共子序列

由算法lcsLength计算得到的数组b可用于快速构造序列X和Y的最长公共子序列。首先从b[m][n]开始,依其值在数组b中搜索。当b[i][j]=1时,表示Xi和Yj的最长公共子序列是由Xi-1和Yj-1的最长公共子序列在尾部加上xi所得到的子序列;当b[i][j]=2时,表示Xi和Yj的最长公共子序列与Xi-1和Yj的最长公共子序列相同;当b[i][j]=3时,表示Xi和Yj的最长公共子序列与Xi和Yj-1的最长公共子序列相同。

public static void lcs(int i, int j, char []x, int [][]b)
{
	if(i==0||j==0)return;
	if(b[i][j]==1){
		lcs(i-1,j-1,x,b);
		System.out.print(x[i]);
	}
	else if(b[i][j]==2)lcs(i-1,j,x,b);
		else lcs(i,j-1,x,b);
}

算法lcs中,每一次递归调用使i或j减1,因此算法的计算时间为O(m+n)。

5. 算法的改进

只需要计算最长公共子序列的长度,则算法的空间需求可大大减少。事实上,在计算c[i][j]时,只用到数组c的第i行和第i-1行。因此,用两行的数组空间就可以计算出最长公共子序列的长度。进一步的分析还可以将空间需求减至O(min{m,n})。

猜你喜欢

转载自blog.csdn.net/ZeromaXHe/article/details/89206270