最长公共子序列(LCS问题)

时间:2023-11-10 09:53:14

先简单介绍下什么是最长公共子序列问题,其实问题很直白,假设两个序列X,Y,X的值是ACBDDCB,Y的值是BBDC,那么XY的最长公共子序列就是BDC。这里解决的问题就是需要一种算法可以快速的计算出这个最大的子序列,当然,用最简单的方法就是列出XY全部的子系列然后一个个对比,但这样的时间复杂度是绝对不能接受的。假设X的长度是m,Y的长度是n,拿X的一个子序列和Y进行对比的时间是n,计算X的全部子序列的时间是2^m,所以,如果采用的是一个个全部计算的话,将会花费n*2^m的时间,指数级别的时间复杂度是爆炸式的。我们这里解决的方法是采用动态规划的方式,所以再讲问题之前,先简单提下动态规划的概念。

动态规划

动态规划是通过组合子问题的解而解决整个问题的,说到这里,是不是有些熟悉?在先前的归并排序中采用的分治法其实也是对子问题进行分析,但有所不同的是,分治法的思想是通过将问题分解为多个子问题,然后一一解决,最后合并子问题就得到了原问题的答案。当然,分治法所适用的领域就是子问题没有相互的关联,而动态规划所就没那么简单了,它的子问题一般都是由相互关联的情况,也就是说子问题包含了公共的子子问题。什么叫公共的子子问题,就是一个子问题继续分解,另一个子问题也继续分解,然后它们惊讶的发现它们分解出来的问题竟然是一样的。所以假设使用分治法来计算这种问题的话,就会产生许多不必要的重复计算,而动态规划的目标之一就是去除这种重复的计算,而方法就是讲结果放在一张表中,具体的怎么弄可以详细看最长公共子序列问题怎么解的。

动态规划常常用于最优解问题,具体的设计可以参考下面的部分:

1.描述最优解的结构

2.递归定义最优解的值

3.按自底向上的方式计算最优解的值

4.由计算结果构造一个最优解

这几个步骤等等就会被用于求解最长公共子序列的问题上,具体步骤请对号入座。

最长公共子序列可以用来干嘛?

在解决这个问题之前,我们当然需要刚清楚自己算出来的东西有什么用处吧~就直接说书上的例子吧,生物里面的DNA由ACGT四种碱基构成,两种生物的DNA序列就是这四种碱基的集合,而有时候就是需要检测两种生物DNA的近似度,一般采用的方法比较多,可以检测一种生物的DNA是不是另一种生物DNA的子集,或者另一种方式,就是如果有第三条DNA序列同时出现在前面两条DNA之中,DNA出现的序列顺序必须相同,但并不是一定要连续出现。所以,寻找到的那个公共的DNA序列越长,那么两个生物DNA就越近似。

应用先将这么多,首先需要详细定义最大公共子序列问题。

一个给定序列的子序列就是该给定序列中去掉零个或者多个元素。另一种形式化的定义(看不看无所谓,看懂开篇说明的话就可以略过),给定一个序列X=<x1,x2,...,xm>,另一个序列Z=<z1,z2,...,zk>是X的一个子序列,如果存在X的一个严格递增下标序列<i1,i2,...,ik>,使得对所有的j=1,2,...,k有xij=zj(注意这里左边j是i的下标)。

给定两个序列X和Y,Z是X和Y的公共子序列,假若Z的长度是所有的X和Y的公共子序列中最长的,那么称Z为最长公共子序列,算法的目的就是为了求解这些最长公共子序列(注意,Z并不一定唯一)

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

对于以下的最长公共子序列问题,简称为LCS问题。之前提到的将XY中所有的子序列全部计算出来再进行比较显然是不现实的,但是,对于LCS问题而言,具有最优子结构特征,具体的说明在下面的定理中。这里,我们先给出前缀的概念,具体定义就不提了,就举一个例子,假设X=<A,B,C,B,D,A,B>,那么X4=<A,B,C,B>就是X的一个前缀,显然,X0为空。而下面分解的子问题其实就是分解为前缀。

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

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

1.如果xm=yn,那么zk=xm=yn而且Z(k-1)是X(m-1)和Y(n-1)的一个LCS

2.如果xm!=yn,那么zk!=xm蕴含Z是X(m-1)和Y的一个LCS

3.如果xm!=yn,那么zk!=yn蕴含Z是X和Y(n-1)的一个LCS

这些性质的作用其实就是为了说明两个序列的LCS包含了两个序列前缀的LCS,那么LCS就具有最优子结构性质(最优子结构就是一个最优解包含了子结构的最优解)。

步骤二:一个递归解

从上面的定理我们可以知道,在寻找LCS的时候,一般就是从子序列中寻找LCS,这样就要检查一个或者两个字问题。如果xm=yn,那么就要找出X(m-1)和Y(n-1)的LCS,然后将xm=yn加上去,就是X和Y的LCS了。但如果xm!=yn的话,那么就要分别计算X(m-1)和Y以及X和Y(n-1)的两个LCS,比价长的LCS就是要找的。这样虽然表面上是递归分治的问题,但实际上如果仔细观察的话就会发现LCS问题中的重叠子问题,比如对于X(m-1)和Y的子问题,和X和Y(n-1)的子问题中,同时包含了X(m-1)和Y(n-1)的子子问题,这样就导致了重复计算的问题,这样使用分治直接解决就不适用了。具体的解决方法后文将会描述。

要求出最长的子序列,首先要知道的是最长有多长。这里定义C[i,j]为序列Xi和Yj的一个LCS的长度,可得到递归式:

最长公共子序列(LCS问题)

步骤三:计算LCS的长度

其实就是根据上面的递归式给出程序,这里先给出伪代码,具体程序最后给出:

最长公共子序列(LCS问题)

最长公共子序列(LCS问题)

可以看到,在程序中有两个二维数组c和b,c用来记录最长子序列的长度,b的话相当于记录了轨迹,在最后构造LCS的时候起到作用。

下图展示的就是程序运行后表格c和b的具体情形(画在了一张表里)

最长公共子序列(LCS问题)最长公共子序列(LCS问题)

步骤四:构造一个LCS

只要得到了表b,就可以快速构造出LCS,伪代码如下:

最长公共子序列(LCS问题)

这是一个递归算法,一开始i和j的值为m和n,就是从表格的右下角开始回溯,从而得到LCS。

下面给出具体的C语言实现过程:

#include <stdio.h>
#include <stdlib.h> #define m 7
#define n 6 int c[m+1][n+1];
char b[m+1][n+1]; void LCS_LENGTH(char* X,char* Y)
{
int i,j;
for(i=1;i<=m;i++)
c[i][0]=0;
for(j=0;j<=n;j++)
c[0][j]=0;
for(i=1;i<=m;i++)
{
for(j=1;j<=n;j++)
{
if(X[i]==Y[j])
{
c[i][j]=c[i-1][j-1]+1;
b[i][j]='\\';
}
else
{
if(c[i-1][j]>=c[i][j-1])
{
c[i][j]=c[i-1][j];
b[i][j]='|';
}
else
{
c[i][j]=c[i][j-1];
b[i][j]='-';
}
}
}
}
} void PRINT_LCS(char* X,int i,int j)
{
if(i==0 || j==0)
return;
if(b[i][j]=='\\')
{
PRINT_LCS(X,i-1,j-1);
printf("%c ",X[i]);
}
else if(b[i][j]=='|')
PRINT_LCS(X,i-1,j);
else
PRINT_LCS(X,i,j-1);
} int main(void)
{
int i,j;
char X[m+1]={'X','A','B','C','B','D','A','B'};
char Y[n+1]={'Y','B','D','C','A','B','A'};
LCS_LENGTH(X,Y);
printf("LCS长度表c打印出来是这个样子:\n");
for(i=1;i<=m;i++)
{
for(j=1;j<=n;j++)
printf("%d ",c[i][j]);
printf("\n");
}
printf("路径表b打印出来是这个样子\n");
for(i=1;i<=m;i++)
{
for(j=1;j<=n;j++)
printf("%c ",b[i][j]);
printf("\n");
}
printf("\nLCS的具体值是:\n");
PRINT_LCS(X,m,n);
return 0;
}

当然了,改进代码的方式也有很多,对空间的改进可以完全不使用表b等等,这里就不再详细叙述了。