算法导论随笔(十三):动态规划与最长公共子序列(LCS)

前言

动态规划(Dynamic programming)与前文算法导论随笔(二): 归并排序与分治策略中提到的分治策略类似,都是通过组合子问题的解来求解原问题。

分治策略不同的是,在分治策略中,每一个子问题是独立的,而动态规划中每一个子问题之间则有重叠的情况。举例来说,归并排序使用的是分治策略,因为它的每一个子问题都是将一个子数组中的数进行排序,而每个子问题中排序的子数组与其他子问题中的子数组并没有任何关系,即互相独立。因此,对于每一个子数组,归并排序算法都要对其进行重新排序。例如有两组长度为2的子数组,在对它们分别排序之后要进行归并操作,此时产生一个长度为4的数组,而该数组仍然需要重新排序。

动态规划适用的问题,比如本文中介绍的最长公共子序列问题中,每一个子问题并不是互相独立的,而且每一个子问题的解都是对该子问题的子问题的解的扩充。也就是说,一个子问题的解依赖于该子问题的子问题的解。假设原问题为A,A的子问题为B,B的子问题为C。那么对于最小的子问题C,我们就可以把它的解记录下来,在之后求解B的时候,它会使用到C的解,此时我们直接去查记录即可,不用再计算一次C的解。当B的解求出之后,我们也记录下B的解,这样求A的解时,查看记录中B的解即可。

上面的解释用书中的语言说,就是动态规划所具备的两个要素:最优子结构子问题重叠。最优子结构指的是,上文中A的解中,其实也包含了B的解和C的解(这也就解释了为什么A的解会依赖于B和C的解)。子问题重叠意味着我们可以使用一种缓存机制来存储子问题的解,并在之后要用到的时候通过直接查询缓存来获取。

最长公共子序列问题

最长公共子序列(Longest common subsequence, LCS)问题是一个经典的可以使用动态规划来求解的问题。给定两个字符串S1和S2,该问题要求我们求出它们的最长的公共子序列。这里我们先举个例子说一下子序列是什么。

对于字符串ABCDEFGHIJK,该字符串的一个子序列为ACEGIJK,另一个子序列为DFGHK。而DAGH则不是ABCDEFGHIJK的子序列。也就是说,子序列不要求相似的字母连续,这也是子序列子串的区别。

由此可知,对于字符串S1=“ABCD"和S2=“ACDE”,它们的公共子序列有"AC”,“CD"和"ACD”。其中最长的为ACD,因此ACD即为S1和S2的最长公共子序列。

暴力求解法

最长公共子序列问题的最基本解决方法就是暴力求解:先求出S1的所有子序列;然后遍历S1的每一个子序列,看它是否也是S2的子序列,如果是的话则把它记录下来;接着在所有的公共子序列中,找出长度最大的那个。这种求解方法的复杂度极高,甚至不是多项式时间内所能解决的。比如我们假定S1的长度为n,那么S1的子序列有2n个,所以仅仅是找出S1的所有子序列就需要O(2n)的时间。如果读者知道
f ( x ) = 2 x f(x) = 2^x f(x)=2x
这个函数的图像,那么就可以知道这个函数的增长率非常大。这是我们所不能接受的。

动态规划求解法

我们来看这个问题的动态规划的求解法。我们先来看伪代码,然后我再来解释原理。首先来定义一个二维数组c,其中c[i,j]表示S1=X[0…i]和S2=Y[0…j]的最长公共子序列的长度。接着使用一个二维数组b,用来构造最优解。
动态规划求解LCS长度的伪代码如下:
算法导论随笔(十三):动态规划与最长公共子序列(LCS)_第1张图片
下面我们用X(即S1)= ABCBDAB,Y(即S2)= BDCABA举例说明上面的伪代码。

下图中先将c[i, 0]和c[0, j]都初始化为0。即代码中第4行至第7行的代码。接着从第8行开始,有一个嵌套的循环。这里的嵌套循环主要的作用就是设置数组b和c的值。那么对于一组特定的i和j的值(i和j代表X与Y字符串中的index),如何确定b[i, j]和c[i, j]的值呢?

对于c[i, j]的值的设置是这样的。如果Xi等于Yi(即X.charAt(i)等于Y.charAt(j)),则c[i, j]的值为c[i-1, j-1]的值(即当前格子的左上角格子的c值)加1;否则比较C[i, j]在图中的左边一格和上边一格的C值: 如果上边一格的C值大于或等于左边一格的C值,即c[i-1, j] >= c[i, j-1],则把c[i, j]的值设置为c[i-1, j];否则把c[i, j]的值设置为c[i, j-1]。

对于b[i, j]的值的设置与C[i, j]类似。如果Xi等于Yi(即X.charAt(i)等于Y.charAt(j)),则b[i, j]的值为↖;否则比较C[i, j]在图中的左边一格和上边一格的C值: 如果上边一格的C值大于或等于左边一格的C值,即c[i-1, j] >= c[i, j-1],则把b[i, j]的值设置为↑;否则把b[i, j]的值设置为←。

例如对于图中红色方格,其对应的是c[1, 1]和b[1, 1]。先来看c[1, 1]的值。由于X[1]为A,而Y[1]为B,因此X[1]!=Y[1]。此时我们就要比较红色方格左边和上方方格中的C值。由于两个方格C值相同,因此将c[1, 1]的值设置为c[0, 1]的值,即0.
同样,我们可以看出b[1, 1]的值为↑。
算法导论随笔(十三):动态规划与最长公共子序列(LCS)_第2张图片
接下来我们按顺序对于图中所有剩余的方格都进行上述操作(这里的顺序既可以横向也可以纵向,只要保证遍历一个格子的时候它的左边格子和上方格子都已被遍历过即可,伪代码中使用的是纵向遍历),即可得到如下的表格:
算法导论随笔(十三):动态规划与最长公共子序列(LCS)_第3张图片
那么最右下角c[7, 6]的值4即为LCS的长度
从图中我们也可以看出,数组b所记录的箭头形成了一条从右下角向左上角的通路,而这条通路中↖即表示LCS中的一个字符。接下来只要按照这条路线做后序遍历并打印即可。从表格中我们也可以看出,X和Y的LCS为BCBA,其长度为4。
下面是打印该通路的代码。
算法导论随笔(十三):动态规划与最长公共子序列(LCS)_第4张图片

动态规划求解LCS问题的原理

前面我们说过,动态规划所具备的一个要素是最优子结构。如果一个问题的最优解包含其子问题的最优解,那么我们称此问题具有最优子结构性质。那么对于LCS问题,它的最优子结构是什么呢?书上是这样解释的:
算法导论随笔(十三):动态规划与最长公共子序列(LCS)_第5张图片
举例子来说:对于字符串X和Y,假定Z是X和Y的一个LCS。

那么第一条说的就是,如果X和Y的最后一个字符串相同,则X和Y把最后一个字符去掉之后,它们的一个LCS就是把Z的最后一个字符去掉后剩余的字符串。例如X=ABCD,Y=ACED,Z=ACD,则X,Y和Z分别去掉最后一个字符D后,X=ABC,Y=ACE,而Z=AC仍然是X和Y的一个LCS。

第二条说的是,若X和Y的最后一个字符串不相同,那么把X的最后一个字符去掉,Z仍然是X和Y的一个LCS。例如X=ABCD,Y=ACEG,Z=AC,则X去掉最后一个字符D后,X=ABC,Y=ACEG,而Z=AC仍然是X和Y的一个LCS。

第三条是第二条的镜像版,即若X和Y的最后一个字符串不相同,那么把Y的最后一个字符去掉,Z仍然是X和Y的一个LCS。这里不再举例。

从这三条中我们可以看出最优子结构的思想,即我们可以把两个长字符串的LCS问题逐步分解为比两个长字符串的substring的LCS问题,而原问题的最优解也一定是由子问题的最优解组合而成。

另外我们也可以看出子问题重叠的特点。比如求X=ABCD,Y=ACED的LCS(设该问题为q0)的子问题q1为X=ABC,Y=ACE的LCS,而这个子问题的子问题q2又可以表示为X=ABC,Y=AC的LCS。我们可以看出,q1和q2是有重叠部分的,而求解q1的时候可以将q2的解作为中间结果。此时直接读取q2的解即可。这也解释了为什么伪代码中要使用数组b和c:它们都保存了一个子问题的解法,因此当计算后面的子问题时,可以直接将前面子问题的答案拿来用,而不用去重新计算(与分治策略的区别)。

复杂度分析

上面LCS-length的伪代码非常直观,有两个嵌套循环,外层循环m次,内层循环n次。这里m和n指的是字符串X和Y的长度。因此,该代码的时间复杂度为
T ( n ) = O ( n m ) T(n) =O(nm) T(n)=O(nm)
当n大于m时,可以看做
T ( n ) = O ( n 2 ) T(n) =O(n^2) T(n)=O(n2)
可以看出比暴力求解法的复杂度低了很多。

你可能感兴趣的:(算法,动态规划,算法)