前言
推出一个新系列,《看图轻松理解数据结构和算法》,主要使用图片来描述常见的数据结构和算法,轻松阅读并理解掌握。本系列包括各种堆、各种队列、各种列表、各种树、各种图、各种排序等等几十篇的样子。
最长公共子序列
最长公共子序列,英文为Longest Common Subsequence,缩写LCS。一个序列,如果是某两个或多个已知序列的最长子序列,则称为最长公共子序列。
另外,要注意的是最长公共子序列与最长公共子串不一样,下面看一个例子就明白。
有序列S1和S2,其中S1=hello
,S2=hero
。那么最长公共子序列为heo
,而最长公共子串为he
。可以看到区别就在于一个允许不连续,一个要求必须连续,而共同特点就是都要保持顺序性。
暴力穷举法
暴力穷举法是最简单粗暴且直观的解决方法,既然是暴力了那效率肯定是最差。有和两个序列,穷举过程首先要枚举所有可能的子序列,对于序列X,它的子序列数量达到,因此这部分的时间复杂度达到。而每个子序列去匹配序列Y的时间复杂度为,所以整个过程的时间复杂度为。也就是说暴力穷举法的时间复杂度达到指数级,而实际中序列长度可能较长,这时几乎无法使用该方法。
子序列的数量为何是?某个序列的所有子序列可以看成是从某序列中移除若干个(0到m个)元素后组成的序列,比如ABC,移除0个元素时为{ABC},移除1个元素时为{BC,AC,AB},移除2个元素时为{C,B,A},移除3个元素时为空。
暴力穷举大致步骤:
- 对于序列X,枚举所有子序列;
- 对第1步中每个子序列匹配序列Y,记录匹配上的最长子序列;
动态规划
鉴于暴力穷举法的时间复杂度太大,需要另外一种方法解决该问题,动态规划。一般在能用动态规划解决的问题需要符合三个特征:最优子结构、重叠子问题和无后效性。刚刚好,最长公共子序列问题符合动态规划特征,下面对该问题具体分析。
最优子结构
假设有和两个序列,记X、Y两个序列对应的最长公共子序列为,确定的过程就是一个最优化问题。为了分析最优子结构,我们需要从序列X与序列Y的最后一个元素开始。分两种情况:
如果,即序列X与序列Y两个序列的最后一个元素相同,说明该元素一定是公共子序列的最后一个元素,此时原问题的状态转换公式为。可以看到这种情况下,原问题已经成功分解成子问题,而且每个阶段的最优解都可以通过子问题的最优解得到,符合最优子结构。
如果,即序列X与序列Y两个序列的最后一个元素不相同,此时需要考虑两种情况:
- 假如不是最长公共子序列的最后一个元素,则问题的状态转换公式为,即从和两个序列中找。
- 假如不是最长公共子序列的最后一个元素,则问题的状态转换公式为,即从和两个序列中找。
以上,成功将原问题分解成子问题,而且子问题的最优解最终组成整个问题的最优解,也就是说该问题具备最优子结构性质。
重叠子问题
经过以上分析,我们将原问题分解成三个子问题:
从中可以看出来子问题是存在重叠的,比如对于,当序列与序列的最后一个元素不相同时,子问题会继续分解成和,也就与前面的子问题中的重叠了。
所以,原问题具备重叠子问题性质。
无后效性
从前面子问题的转换公式可以看出,某阶段的子问题确定后不再受后面决策的影响,即后面过程不会影响前面已经确定的状态。反过来,也可以认为某阶段的子问题最优解由之前某些状态得到,而不用管之前的状态是如何得到的。
递归公式
所有问题都已经分析完毕,接下去定义递归公式,将所有状态及状态转移用递归公式描述清楚。
设为的长度,其中i = 0,1,2,...m
, j=0,1,2,...n
。
动态规划实现方式
动态规划的实现方式有两种,即自顶向下(Top-down)与自底向上(Bottom-up)。
自顶向下方式:这种方式直接使用递归公式计算得到结果,问题的解可以使用子问题的解递归地表示。另外,对于重叠的子问题可以将其记忆化,即保存在缓存表中,每次解决子问题之前先查缓存表,如果子问题已经解决,则我们可以直接使用它。对于还未解决过的子问题,我们先解决子问题,再把子问题的解存到缓存表中。
自底向上方式:相对于自顶向下,我们可以反向找到另外一种方式,以自底向上的方式重新构造问题。我们不直接解决原问题,而是先尝试解决子问题,然后通过子问题解决更大的子问题,不断向着更大问题迭代从而解决最终的问题。
文章太长,自顶向下方式先不发出来。
自底向上方式
在实际工程中自底向上方式可以使用一个二维表格来记录最长公共子序列的求解过程。
现在有序列X=HELLO,序列Y=HERO,用动态规划自底向上方式来求解它们的最长公共子序列。最开始时初始化整张表格,注意表格的两个维度都比各自序列长度大一维,多出的一维用于保存初始状态,初始状态都为0,在计算过程中要用到这些初始状态。
构建子问题表格
从序列X的第一个元素开始,与序列Y的第一个元素对比,因为H=H
,所以LCS(1,1)=LCS(0,0)+1=1。
接着H!=E
,于是LCS(1,2)=max(LCS(1, 1), LCS(0, 2)),即LCS(1,2)=LCS(1,1)=1。
接着H!=R
,于是LCS(1,3)=max(LCS(1, 2), LCS(0, 3)),即LCS(1,3)=LCS(1,2)=1。
接着H!=O
,于是LCS(1,4)=max(LCS(1, 3), LCS(0, 4)),即LCS(1,4)=LCS(1,3)=1。
同样地,对于序列X的第二个元素也分别与序列Y的每个元素对比,并将结果填入对应表格中,序列X的第二个元素对比完的结果如下。
序列X的第三个元素对比完的结果如下。
序列X的第四个元素对比完的结果如下。
序列X的第五个元素对比完,也就是最终的结果如下。
所以可以看到LCS(5,4)=3,也就是说序列X和序列Y的最长公共子序列的长度为3。
同时也可以看到,通过动态规划自底向上方法我们只需要构建一张表格就可以得到最终的结果,而构建表格的时间复杂度为O(m*n),时间复杂度大大降低。
构建最长公共子序列
有时候得到最长公共子序列的长度还不能满足我们的要求,我们想进一步得到长公共子序列,这时就需要依据已经构建好的表格反推回去,最终得到结果。
也就是说先判断两序列的指定元素是否相同,如果相同则斜着往回走一格,但如果不相同可以则往左或往上走一格,根据值的大小决定往左还是往上,值同样大的话则往左往上都可以。
接着上面的例子,经过前面过程后,表格已经构建成功,并且得到了最长公共子序列的长度。接下去我们来获取最长公共子序列。从最后一格开始,
因为O=O
,所以O属于最长公共子序列的元素,将其输出,接着斜着往回走一格。
因为L!=R
,所以两者都不属于最长公共子序列的元素,而且往左往上的值都相等,可任意选择,这里选择往上走一格。
继续,因为L!=R
,所以两者都不属于最长公共子序列的元素,而且往左往上的值都相等,可任意选择,这里选择往上走一格。
继续,因为E!=R
,所以两者都不属于最长公共子序列的元素,其中左边的值大于上边的值,选择往左走一格。
继续,因为E=E
,所以E属于最长公共子序列的元素,将其输出,接着斜着往回走一格。
继续,因为H=H
,所以H属于最长公共子序列的元素,将其输出,此时已经走到尽头,现在所有输出的即是最长公共子序列,即HEO
。而构建最长公共子序列的时间复杂度为O(m+n)。
————-推荐阅读————
我的开源项目汇总(机器&深度学习、NLP、网络IO、AIML、mysql协议、chatbot)
跟我交流,向我提问:
欢迎关注: