动态规划DP持续分析(update)

来源:互联网 发布:ipad版淘宝怎么开店 编辑:程序博客网 时间:2024/06/14 16:24

动态规划DP
动态规划是什么?一种算法?NO。DP是一种解决一类问题的方法,不是某一种特定的算法。说到DP,不得不提的就是最长公共子序列(LCS)问题。什么是最长公共子序列呢?如果序列Z分别是两个或多个已知列的子序列,且是所有符合此条件序列中最长的,则Z 称为已知序列的最长公共子序列。那什么是子序列呢?子序列是从不改变原来序列的顺序,而从原来序列中去掉任意的元素而获得的新序列区别子串,子串是串的一个连续的部分子串的字符的位置必须连续,子序列LCS则不必连续。比如字序列ACDFGAKDFC的最长公共子串为DF,而他们的最长公共子序列LCSADF搞清楚了什么是LCS,那我们要解决的问题就是求LCS的长度。就像这个题目:请编写一个函数,输入两个字符串,求它们的最长公共子串,并打印出最长公共子串。输入两个字符串BDCABA和ABCBDAB,很明显可以看出LCS=BDAB,BCAB,BCBA;LCS的长度都是4。这里要明确一点,LCS并不唯一,可是LCS的长度是唯一的。给定两个序列X[1-m]={X1,X2,X3...Xm}和Y[1-n]={Y1,Y2,Y3....Yn},如何求得其LCS?怎么求解呢?我们当然知道可以用DP来解决。可是在此之前,我们分析一下一般的方法。暴力求解,穷举法。求两个序列X,Y的子序列Z,不就是可以先穷举X的子序列,然后看看是不是在Y里面,如果在就记录下来,相互比较最后求得最长的公共子序列LCS。先不说这需要多少的空间,就看X的子序列有多少可能,X共有2^m (包括长度为0和m)个子序列,而查看Y所需要的时候是线性。这要需要指数级的时间复杂度吧,T(n)=O(2^m)O(n)=O(n2^m)。大哥,你能承受的了吗??好吧,那我们就用动规来解决一下吧。想一想,我们如何找到LCS?假设LCS的长度是K,我们用Z[1-k]={Z1,Z2,Z3...Zk}来表示。如果我们能找到LCS最后一个Zk,那我们剩下的问题就是求LCS前k-1个了。是不是,这样你能想到什么?前缀?YES!最优子结构性质,这也是标志一个问题可以用DP来解决的一个元素性质。最优子结构:如果一个问题的最优解包含子问题的最优解,则该问题具有最优子结构。我们可以利用子问题的最优解来得到原问题的最优解。如何寻找最优子结构?我们都遵循一个共同的模式:1 问题的一个解,也就是一个子结构,就像是做一个选择。2 假设对于给定的问题我们已知了最优解的选择,尽管假设。这样才会有最优子结构。3 确定最优解之后,随之而来的就是有多少个子问题,也就是有多少子结构。4 利用剪贴法来证明问题的最优解中子问题的解也是最优的。假设X[1-i]=﹤X1,X2……Xi﹥即X序列的前i个字符 (1≤i≤m)(前缀)Y[1-j]=﹤Y1,Y2……Yj﹥即Y序列的前j个字符 (1≤j≤n)(前缀)Z[1-k]=﹤Z1,Z2……Zk﹥LCS(X[1-i],Y[1-j])。(这里用,意思是Z只是LCS中的一个)用C[i,j]记录序列X[1-i]和Y[1-j]的最长公共子序列的长度,即C[i,j]=|LCS(X[1-i],Y[1-j])|,我们要求的结果就是C[m,n]。我们来分析一下:若Xm=Yn(最后一个字符相同),则该字符必是X[1-m]与Y[1-n]的任一最长公共子序列Z[1-k]的最后一个字符,即有Zk = Xm = Yn 且有Z[1-k]LCS(X[1-(m-1)] , Y[1-(n-1)])即Z的前缀Zk-1是Xm-1与Yn-1的LCS。我们可以用“剪贴法”证明,假设Zk-1不是Xm-1与Yn-1的LCS,则肯定有一个长度大于k的LCS,假设是W,即|W|>k。那么把最后相同的Xm加入LCS中,则原问题的LCS比如大于k,这与原来X,Y的LCS是k相矛盾,假设不成立,得证。此时问题化归成求Xm-1与Yn-1的LCS。此时C[m,n]=C[m-1,n-1]+1.若Xm≠Yn,则要么Z∈LCS(Xm-1,Y),要么Z∈LCS(X,Yn-1)。由于Zk≠Xm与Zk≠Yn其中至少有一个必成立,若Zk≠Xm则有ZLCS(Xm-1 , Y)。若Zk≠Yn 则有ZLCS(X , Yn-1)。同样可以利用剪贴法来证明,假设Z不是LCS,那我们可以找到一个大于k的LCS,这也就是X,Y的LCS,与原来的长度k矛盾,得证。此时问题化归成求Xm-1与Y的LCS及X与Yn-1的LCS。此时C[m,n]=Max{C[m,n-1],C[m-1,n]}这就是LCS问题的最优子结构。对于最优子结构我还有话要说:最优子结构在问题域中以两种方式变化: 1 有多少个子问题被使用在原问题的最优解中呢? 2 觉得一个最优解的时候我们有多少选择呢?在这个LCS问题中我们有多少子问题呢?O(mn)个不同的子问题,为啥呢?因为对于每一个Y,X有m种可能(这是因为子问题都是从1开始的),所以有mn个不同的子问题。这些子问题空间要尽量小,这个后面再说。我们有多少选择?两种。Xm和Yn相等和不相等两种。DP问题的算法的时间复杂度是子问题数和选择的数目相乘。问题的代价就是子问题的代价加上选择的代价。也就是说LCS的时间复杂度是O(n)=O(2mn)=O(mn)。最优子结构不是什么问题都有的,不能胡乱假设。比如找无权最长的简单路径,这个问题就不具有最优子结构。因为它的子问题不是互相独立的,什么是独立子问题,就是一个子问题的解不影响同一个问题的另一个子问题的解求无权最长会导致子问题的资源互相占用。说独立,和DP解决问题的子问题不是相互独立的(分治法解决的子问题都是相互独立的,而DP解决的子问题中包含公共的子子问题)似乎相互矛盾,其实是不矛盾的。DP说不独立,是相对来说,说不同的子问题包含共同的子子问题,也就是说A,B是问题的子问题,他们包含公共的子子问题C,分治法的A,B就不是,是独立的。而刚才又说最优子结构中子问题要相互独立,是说A,B要相互独立,不能互相影响。没明白?这两个独立不是一个意思?独立子问题,就是一个子问题的解不影响同一个问题的另一个子问题的解。A,B并不是互相影响,这是包含共同的子问题C而已。这不是影响,影响指的是A,B中资源相互占用,互相影响。这还牵涉到一个重叠子问题,一会再说。说了这么多,我们刚才弄了半天得到了什么?我们得到一个递归表达式,也叫状态转移方程

写出为代码
LCS_LENGTH(X,Y,i,j)if x[i]=y[j]     then C[i,j]=LCS_LENGTH(X,Y,i-1,j-1)+1else C[i,j]=Max(LCS_LENGTH(X,Y,i,j-1),LCS_LENGTH(X,Y,i-1,j))return C[i,j]

可是我们会发现这里面有很多重复的子问题。比如在求解X和Y的最长公共子序列时,可能要求解出X和Yn-1及Xm-1和Y的最长公共子序列。而这两个子问题都包含一个公共子问题,即求解Xm-1和Yn-1的最长公共子序列。如果全部求出来,那恐怕都是指数级的复杂度了。
举例 m=7,n=6。看一下如果Xm不等于Yn时坐标的变化:


重复的很多啊,树的高度是O(m+n)(m,n每次减一),则算法的时间复杂度是O(2^(m+n)),很拙计吧。优化?这也是标志一个问题可以用DP来解决的另一个元素性质,重叠子问题性质。重叠子问题要求最优子结构要很小,为啥呢?因为要求可以反复递归解决同样的子问题,而不是不停的产生新的子问题(分治法每次递归都产生新的子问题)。当一个递归算法不断重复调用同一个子问题时,我们就说该问题包含重叠子问题,重叠子问题性质刚才也说了子问题空间是O(mn)。因为这样的话DP就可以利用相同子问题每次只求一次,把解保存起来每次查看就好了。标志一个问题可以用DP来解决还有一个性质就是无后效性某阶段的状态一旦确定,则此后过程的演变不再受此前各种状态及决策的影响,简单的说,就是“未来与过去无关”,当前的状态是此前历史的一个完整总结,此前的历史只能通过当前的状态去影响过程未来的演变。具体地说,LCS问题变成求Xm-1与Yn-1的LCS之后,这之后过程不受Xm和Yn的影响,只与Xm-1与Yn-1有关系。刚才说到重叠子问题的时候说到了一个方法,就是备忘录法(这个方法和DP没有关系)。把求得子问题的解保存起来,以后每次求解的时候Check一下就OK了。
LCS_LENGTH(X,Y,i,j)if C[i,j]=nullthen if x[i]=y[j]    then C[i,j]=LCS_LENGTH(X,Y,i-1,j-1)+1      else C[i,j]=Max(LCS_LENGTH(X,Y,i,j-1),LCS_LENGTH(X,Y,i-1,j))return C[i,j]else  return C[i,j]

可是这毕竟是递归,即便是采用了备忘录法。因为每个子问题至少要求解一次。而用自底向上来求解就不必这样(尽管LCS问题是要求每一个,但其他的问题未必),仅仅只需要求必须求解的子问题即可。而且空间复杂度有时候会减少(好上一个常数因子),LCS问题也是这样,现在看来辅助空间是O(mn),一会就变成了O(min(m,n)),见下文。
求解LCS长度的动态规划算法LCS_LENGTH(X,Y)以序列X=<x1, x2, …, xm>和Y=<y1, y2, …, yn>作为输入。输出两个数组C[0..m ,0..n]和b[1..m ,1..n]。其中C[i,j]存储Xi与Yj的最长公共子序列的长度,b[i,j]记录指示C[i,j]的值是由哪一个子问题的解达到的,这在构造最长公共子序列时要用到。最后,X和Y的最长公共子序列的长度记录于C[m,n]中。求解LCS长度的伪代码
LCS_LENGTH(X,Y)   m=length[X]  n=length[Y]  for i=1 to m     do C[i,0]=0  for j=1 to n     do C[0,j]=0  for i=1 to m do      for j=1 to n do           if x[i]=y[j]         then  C[i,j]=C[i-1,j-1]+1              b[i,j]="↖"          else if C[i-1,j]≥C[i,j-1] then                 then  C[i,j]=C[i-1,j]                 b[i,j]="↑"           else                 C[i,j]=C[i,j-1]            b[i,j]="←"   return(C,b)

时间复杂度是O(mn),空间复杂度是O(mn)。可是空间复杂度可以优化吗?
得到LCS长度了,那LCS如何求得呢?不要忘了刚才b数组,保存的就是求解C[i,j]时所选择的最优子问题的解。利用b数组重建LCS,从最后一个开始,一直到C[0]。
首先从b[m,n]开始,沿着其中的箭头所指的方向在数组b中搜索。
当b[i,j]中遇到"↖"时(意味着xi=yi是LCS的一个元素),表示Xi与Yj的最长公共子序列是由Xi-1与Yj-1的最长公共子序列在尾部加上xi得到的子序列;
当b[i,j]中遇到"↑"时,表示Xi与Yj的最长公共子序列和Xi-1与Yj的最长公共子序列相同;
当b[i,j]中遇到"←"时,表示Xi与Yj的最长公共子序列和Xi与Yj-1的最长公共子序列相同;
设所给的两个序列为X=<A,B,C,B,D,A,B>和Y=<B,D,C,A,B,A>。由算法LCS_LENGTH和LCS计算出的结果如下图所示:

打印LCS的伪代码:
PRINT_LCS(b,X,i,j);   if i=0 or j=0 then return  if b[i,j]="↖" then  PRINT_LCS(b,X,i-1,j-1)             print x[i] else if b[i,j]="↑" then  PRINT_LCS(b,X,i-1,j)   else PRINT_LCS(b,X,i,j-1) 

PRINT_LCS中,每一次的递归调用使i或j减1,因此算法的时间复杂度O(m+n),空复杂度也是O(m+n)我们可以在空间上做一些改进优化。针对LCS_LENGTH,我们完全可以去掉b数组。事实上,C[i,j]的值仅仅由C[i-1,j-1],C[i-1,j]和C[i,j-1]三个值确定,而b[i,j]也只是用来指示C[i,j]究竟由哪个值确定。所以来说,我们完全可以不借助于数组b而借助于数组C本身临时判断C[i,j]的值是由C[i-1,j-1],C[i-1,j]和C[i,j-1]中哪一个数值元素所确定,代价是O(1)时间。既然b对于算法LCS不是必要的,那么算法LCS_LENGTH便不必保存它。这一来,可节省O(mn)的空间。不过,由于数组C仍需要O(mn)的空间,因此这里所作的改进,只是在空间复杂度的常数因子上的改进。    如果我们仅仅只需要求LCS的长度,则算法的空间需求还可大大减少。其实在求解C[i,j]时,只用到数组C的第i行和第i-1行。因此,只要用2行的数组空间就可以求解出LCS的长度。辅助空间变为2min(m, n)=O(min(m,n))。(这不是空间复杂度)。如果还要重构LCS,两行的数组空间是不够的。
整个LCS问题的分析就是这些了,这也得出DP分析的一些步骤
1 描述最优解的结构
2 递归定义最优解的值
3 按自底向上的方式计算最优解的值   //此3步构成动态规划解的基础。
4 由计算出的结果构造一个最优解。    //此步如果只要求计算最优解的值时,可省略。这些步骤很重要,对于分析一个可以用DP来解决的问题。
接下来就是编码实现的过程了

#include <iostream>   using namespace std;    enum{LEFTUP=1,LEFT,UP};  //定义数组LcsDirection的方向变量const int XLENGTH=7;const int YLENGTH=6;//因为CommonLength数组只是需要找到长度即可,所以可以在函数里面定义//而LcsDirection数组要返回值,要构造LCS,而且LcsDirection长度是X+Y长度,已定义好int LcsLength(char* XString,int Xstart,int Xend,char* YString,int Ystart,int Yend,int **LcsDirection);//利用LcsDirection数组打印LCSint PrintLcs(int **LcsDirection,char* XString,int Xi,int Yj);int main(){int i,j;char XString[XLENGTH+1]="ABCBDAB";//不要忘了字符数组最后一个是‘\0’.定义字符指针好些char YString[YLENGTH+1]="BDCABA";int **LcsDirection=new int*[XLENGTH+1];  //注意细节是+1,因为LCS长度数组定义+1,而0位置用不着for(i=0;i<XLENGTH+1;i++)*(LcsDirection+i)=new int[YLENGTH+1]; //为每一列申请内存int length=LcsLength(XString,0,XLENGTH-1,YString,0,YLENGTH-1,LcsDirection);cout<<"LcsLength="<<length<<endl;cout<<"LCS:";PrintLcs(LcsDirection,XString,XLENGTH,YLENGTH);cout<<endl;return 0;}//因为CommonLength数组只是需要找到长度即可,所以可以在函数里面定义//而LcsDirection数组要返回值,要构造LCS,而且LcsDirection长度是X+Y长度,已定义好int LcsLength(char* XString,int Xstart,int Xend,char* YString,int Ystart,int Yend,int **LcsDirection){int Xlength=Xend-Xstart+1;int Ylength=Yend-Ystart+1;int i,j;int **CommonLength=new int*[Xlength+1]; //Xlength是二维数组的行,+1是为方便计算CommonLength的值for(i=0;i<Xlength+1;i++)*(CommonLength+i)=new int[Ylength+1];//为每一列申请内存,等价CommonLength[i]for(j=0;j<Ylength+1;j++)CommonLength[0][j]=0;    //初始化CommonLength数组的第0行for(i=0;i<Xlength+1;i++)CommonLength[i][0]=0;   //初始化CommonLength数组的第0列for(i=1;i<Xlength+1;i++)  //+1?还是<=?这一点有点意思。+1可以明确说明数组的长度含义是啥{for(j=1;j<Ylength+1;j++){if(XString[i-1]==YString[j-1]) //细节注意,虽然是i,j相互比较,但是数组的元素和CommonLength的下标是不对应的。参看构造LCS的图{*(*(CommonLength+i)+j)=CommonLength[i-1][j-1]+1;//*(*(CommonLength+i)+j)=CommonLength[i][j]LcsDirection[i][j]=LEFTUP;}else if(CommonLength[i-1][j]>=CommonLength[i][j-1]){CommonLength[i][j]=CommonLength[i-1][j];LcsDirection[i][j]=UP;}else{CommonLength[i][j]=CommonLength[i][j-1];LcsDirection[i][j]=LEFT;}}}return CommonLength[Xlength][Ylength];}//利用LcsDirection数组打印LCSint PrintLcs(int **LcsDirection,char* XString,int Xi,int Yj){if(Xi==0||Yj==0)return 0;if(LcsDirection[Xi][Yj]==LEFTUP){PrintLcs(LcsDirection,XString,Xi-1,Yj-1);//输出哪一个都可以的。。cout<<XString[Xi-1]<<" ";      //下标不对应,要-1}else if(LcsDirection[Xi][Yj]==UP)PrintLcs(LcsDirection,XString,Xi-1,Yj);elsePrintLcs(LcsDirection,XString,Xi,Yj-1);//return 0; 不需要} 

写代码看起来很简单,我就照着伪代码写来着,居然遇到了很多的问题。总之一句话,自己的编程能力还很差。碰到了动态数组参数传递和静态数组参数传递的问题,这和多重指针有关系。碰到了数组下标不对应的问题,这个问题我调试了好久,终于发现bug所在。就像构造LCS的图一样。我的字符数组下标对于与0 1 2 3 4 5 ,而图上的对应于1 2 3 4 5 6 所以碰到了无法可读的内存,还出现了小写a(数组里都是大写的)。最后我把代码改了。数组的相互比较改成-1,这样才是我本身数组的下标,这样才了结了这个BUG。汗颜啊。。
后续还会有很多的DP例子更新,DP要好好练习才能真正掌握,只有理论是不行的。
例子
求数组的连续子数组之后的最大值(一维二维)
求数组中最长递增子序列
HDOJ 2084 数塔【简单DP】
20130801update)
HDOJ 1058 Humble Numbers解题报告【DP】
20130801update)
HDOJ1069 猴子和香蕉【DP】(20130801update)
转载请注明出处http://blog.csdn.net/liangbopirates/article/details/9393161