动态规划DP动态规划是什么?一种算法?NO。DP是一种解决一类问题的方法,不是某一种特定的算法。 说到DP,不得不提的就是最长公共子序列(LCS)问题。 什么是最长公共子序列呢?如果序列Z分别是两个或多个已知序列的子序列,且是所有符合此条件序列中最长的,则Z 称为已知序列的最长公共子序列。那什么是子序列呢?子序列是从不改变原来序列的顺序,而从原来序列中去掉任意的元素而获得的新序列。区别子串,子串是串的一个连续的部分。子串的字符的位置必须连续,子序列LCS则不必连续。比如字序列ACDFG和AKDFC的最长公共子串为DF,而他们的最长公共子序列LCS是ADF。 搞清楚了什么是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则有Z∈LCS(Xm-1 , Y)。若Zk≠Yn 则有Z∈LCS(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)+1 else 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]=null then 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数组打印LCS int 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数组打印LCS int 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); else PrintLcs(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要好好练习才能真正掌握,只有理论是不行的。
转载请注明出处http://blog.csdn.net/sustliangbo/article/details/9393161
作者:sustliangbo 发表于2013-7-21 0:10:07 原文链接
阅读:147 评论:0 查看评论