【啃不完的算法导论】- 动态规划 - 最长公共子序列(概念篇)

  以下内容纯是为了熟悉《算法导论》中的内容,高手可略过,其中涉及的书本内容的版权归原作者、译者、出版社所有

  ==================================================================

  求最长公共子序列,一个典型的 动态规划题 和 字符串处理算法,写在这里是希望自己以后能多来看看和改改,温故而知新,有时间的话再加入c/c++代码。

  下面进入正题:

  %% 什么是子序列?概念有点晕,不过拿来学习怎么来精确表达一种内容挺好 %%

  一个给定序列的子序列就是该给定序列中去掉零个或者多个元素。以形式化的方式来说,给定一个序列X=<x1, x2, ..., xm>,另一个序列Z=<z1, z2, ..., zk>是X的一个子序列,如果存在X的一个严格递增下标序列<i1, i2, ..., ik>,使得对所有的j=1, 2, ..., k,有Xij=zj。例如,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=<A, B, C, B, D, A, B>,Y=<B, D, C, A, B, A>,则序列<B, C, A>即为X和Y的一个公共子序列。但是序列<B, C, A>不是X和Y的一个最长公共子序列(Longest-Common-Subsequence, LCS),因为它的长度等于3,而同为X和Y的公共子序列<B, C, B, A>其长度等于4。序列<B, C, B, A>是X和Y的一个LCS,序列<B, D, A, B>也是,因为没有长度为5或更大的公共子序列。

  在最长子序列问题中,给定了两个序列X=<x1, x2, ..., xm>和Y=<y1, y2, ..., yn>,希望找出X和Y的最大长度公共子序列。

  步骤1:描述一个最长公共子序列

  %% 枚举方法没试过 %%

  解决LCS问题的一种强力方法是枚举出X的所有子序列,然后逐一检查看其是否为Y的子序列,并随时记录发现的最长子序列。X的每个子序列对应于X的下标集{1, 2, ..., m}的一个子集。X共有2m个子序列,因此这种方法需要指数时间,这对长序列来说是不实际的。

  %% 可以想想“最优子结构性质”是怎么描述的 %%

  然而LCS问题具有最优子结构性质,下面的定理说明了这一点。我们将看到,子问题的自然类对应于两个输入序列的成对“前缀”。(%% 自然类是什么? %%)准确的说,给定一个序列X=<x1, x2, ..., xm>,对i=0, 1, ..., m,定义X的第i个前缀为Xi=<x1, x2, ..., xi>。例如,如果X=<A, B, C, B, D, A, B>,则X4=<A, B, C, B>,而X0空序列。

  %% 最长公共子序列的最优子结构的准确描述 %%

  定理(LCS的最优子结构):

  设X=<x1, x2, ..., xm>和Y=<y1, y2, ..., yn>为两个序列,并设Z=<z1, z2, ..., zk>为X和Y的任意一个LCS。

  %% LCS可能不只有一个,如a,b,c和a,c,b %%

  %% 从xm,yn开始比较,是为了递归定义 %%

  1) 如果xm=yn,那么zk=xm=yn而且Zk-1是Xm-1和Yn-1的一个LCS。

  2) 如果xm≠yn,那么zk≠xm蕴含Z是Xm-1和Y的一个LCS。

  3) 如果xm≠yn,那么zk≠yn蕴含Z是X和Yn-1的一个LCS。

  1,2,3证明略。 %% 证明是《算法导论》里比较繁琐的一块内容,不过仔细看看过程很精彩 %%

  步骤2:一个递归解

  %% 重叠子问题在动态规划中很常见 %%

  由上述定理可以知道,在找X=<x1, x2, ..., xm>和Y=<y1, y2, ..., yn>的一个LCS时,可能要检查一个或两个子问题。如果xm=yn,必须找出Xm-1和Yn-1的一个LCS。将xm=yn添加到这个LCS上,可以产生X和Y的一个LCS。如果xm≠yn,就必须解决两个子问题:找出Xm-1和Y的一个LCS,以及找出X和Yn-1的一个LCS。这两个LCS中,较长的就是X和Y的一个LCS,因为这些情况涉及了所有的可能,其中一个最优的子问题解必须被使用在X和Y的一个LCS中。

  可以很容易地看出LCS问题中的重叠子问题性质。为找出X和Y的一个LCS,可能需要找出X和Yn-1的一个LCS以及Xm-1和Y的一个LCS。但这两个子问题都包含着找Xm-1和Yn-1的一个LCS的子子问题。还有许多其他的子问题共享子子问题。

  %% 递归式看着容易理解,要自己写的时候老是记不起来 %%

  像在矩阵链乘法问题中一样,LCS问题的递归解涉及到建立一个最优解的值的递归式。定义c[i, j]为序列Xi和Yi的一个LCS长度。如果i=0或j=0,其中一个的序列长度为0,因此LCS的长度为0。由LCS问题的最优子结构可得递归式

  c[i, j] = 0              如果 i=0 或 j=0

     = c[i-1, j-1] + 1       如果 i, j>0 和 xi=yi  %% 第i个字符满足条件,加到子序列中,长度加1 %%

     = max( c[i, j-1], c[i-1, j] )  如果 i, j>0 和 xi≠yi  %% 第i个字符不一样了,根据前面的计算结果来比较哪个长一点,再取值%%

  %% 下面的这个“子问题因为原问题的条件而被排除”没怎么明白,是条件不同考虑的子问题不同? %%

  观察这个递归公式,问题的一个条件限制了我们可能考虑的子问题。当xi=yi时,可以而且应该考虑寻找Xi-1和Yi-1的LCS的子问题。否则,应另外考虑寻找Xi和Yj-1以及Xi-1和Yj的LCS的两个子问题。在前面已经讨论的动态规划算法(装配线调度和矩阵链乘法)中,没有任何子问题因为原问题的条件而被排除。寻找LCS不是唯一的因为问题的条件而排除子问题的动态规划算法。例如,编辑距离也具有这个特征。

  步骤3:计算LCS的长度

  %% 自底向上的方法,一般是多重循环 %%

  根据公式,可以很容易地写出一个指数时间的递归算法,来计算两个序列的LCS的长度。因为只有Θ(mn)个不同的子问题,所以可以用动态规划来自底向上计算解。

  %% c表用来记录LCS的长度,b表用来跟踪当前选择的最优解,方便结束时构造出来LCS %%

  过程LCS-LENGTH以两个序列X=<x1, x2, ..., xm>和Y=<y1, y2, ..., yn>为输入。它把c[i, j]值填入一个按行计算表项的表c[0..m, 0..n]中。(也就是,c的第一行从左到右填入,然后开始第二行,等等)。它还维护表b[0..m, 0..n]以简化最优解的构造。从直觉上看,b[i, j]指向一个表项,对应于在计算c[i, j]时所选择的最优子问题的解。该程序返回表b和c;c[m, n]包含X和Y的一个LCS的长度。

  %% 有了递归式,代码就简单了,说明先定义递归式有多重要 %%

  伪代码如下:

  

View Code
 1 //LCS-LENGTH(X, Y)
 2 //
 3 // m <- length[X]
 4 // n <- length[Y]
 5 // 构造边界
 6 // for i <- 1 to m
 7 //     do c[i, 0] <- 0
 8 // for j <- 0 to n
 9 //     do c[0, j] <- 0
10 // 自底向上,选择最优结果
11 // for i <- 1 to m
12 //     do for j <- 1 to n
13 //         不同条件,不同选择
14 //         do if xi = yj
15 //                 then c[i, j] <- c[i-1, j-1] + 1
16 //                         b[i, j] <- left-up //指向左上方
17 //                 else if c[i-1, j] >= c[i, j-1]
18 //                         then c[i, j] <- c[i-1, j]
19 //                                 b[i, j] <- up //指向上方
20 //                         else c[i, j] <- c[i, j-1]
21 //                                 b[i, j] <- left //指向左边
22 // return c and b

 

  下图给出了在序列X=<A, B, C, B, D, A, B>和Y=<B, D, C, A, B, A>上,由LCS-LENGTH计算出的表。这个程序的运行时间为O(mn),因为每个表项的计算时间为O(1)。

  

  步骤4:构造一个LCS

  %% 箭头表示方法很形象 %%

  有LCS-LENGTH返回的表b可以被用来快速构造X=<x1, x2, ..., xm>和Y=<y1, y2, ..., yn>的一个LCS。首先从b[m, n]处开始,沿着箭头在表格中跟踪下去。每当在表项b[i, j]中遇到left-up时,即意味着xi=yj是LCS的一个元素。这种方法是按照反序来找LCS的每一个元素的。下面的递归过程按正常的前序输出X和Y的一个LCS。初始调用为PRINT-LCS(b, X, length[X], length[Y])。

  %% 这个递归打印结果也很好 %%

  伪代码如下:

  

View Code
 1 // PRINT-LCS(b, X, i, j)
 2 // 
 3 // if i=0 or j=0
 4 //     then return
 5 // if b[i, j] = left-up //指向左上方
 6 //     then PRINT-LCS(b, X, i-1, j-1)
 7 //         print xi
 8 // else if b[i, j] = up //指向上方
 9 //     then PRINT-LCS(b, X, i-1, j)
10 // else PRINT-LCS(b, X, i, j-1) //指向左边

 

  对图中的表b,此程序输出“B C B A”。因为在递归的每个阶段i和j至少有一个要减小,故该过程的运行时间为O(m+n)。

  改进代码

  一旦设计出某个算法之后,常常可以在时间或者空间上对该算法做些改进。对直观的动态规划算法来说尤其如此,有些改变可以简化代码并改进一些常数因子,但并不会带来算法性能方面的渐进改善。其他一些改变则可以在时间和空间上有相当大的渐进节省。

  %% 完全去掉表b(箭头表)没试过这种方法。 %%

  例如,我们可以完全去掉表b。每个表项c[i, j]仅依赖于另外三个c表项:c[i-1, j-1],c[i-1, j]和c[i, j-1]。给定c[i, j]的值,我们可以在O(1)时间内确定这三个值中的哪一个被用来计算c[i, j]的,而不检查表b。这样,利用一个类似于PRINT-LCS的过程,在O(m+n)时间内即可重构一个LCS。(练习中要求伪代码)。虽然用这种方法节省了Θ(mn)空间,但计算一个LCS时所需要的辅助空间并没有渐进地减少,因为表c总是需要占据Θ(mn)空间的。

  %% 渐进空间需求? 计算长度的话,表c实际上是只有两行有用,也没试过这种方法,不过要构造LCS还是需要一定的辅助信息 %%

  然而,我们能减少LCS-LENGTH的渐进空间需求,因为它一次只需表c的两行:正在被计算的一行和前面一行(实际上,仅需略多于表c一行的空间就可以计算一个LCS的长度。)。如果仅要求出一个LCS的长度,则这种改进是有用的;如果要重构一个LCS的元素,则小的表无法包含足够的信息来使我们在O(m+n)时间内重新执行以前各步。

 

posted @ 2012-10-14 22:22  CJ_Ruan  阅读(643)  评论(0编辑  收藏  举报