【啃不完的算法导论】- 动态规划 - 最长公共子序列(概念篇)
以下内容纯是为了熟悉《算法导论》中的内容,高手可略过,其中涉及的书本内容的版权归原作者、译者、出版社所有
==================================================================
求最长公共子序列,一个典型的 动态规划题 和 字符串处理算法,写在这里是希望自己以后能多来看看和改改,温故而知新,有时间的话再加入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的长度。
%% 有了递归式,代码就简单了,说明先定义递归式有多重要 %%
伪代码如下:
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])。
%% 这个递归打印结果也很好 %%
伪代码如下:
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)时间内重新执行以前各步。