概念
序列的子序列,可以由从这个序列中去掉0个或多个元素而得来。所以子序列 可以是由其父序列中不连续的元素组成,但相对顺序不能改变。公共子序列指 的是,假如序列Z既是X的子序列,又是序列Y的子序列,那么称Z为X和Y的公共子序 列。两个序列最长的公共子序列就被称之为最长公共子序列。最长公共子序列, 又被称之为最长公共子串,译自英文名Longgest Common Subsequence,可以缩写 为LCS。求最长公共子序列是一个很有用的问题,它可以用来分析两段序列的相似 度,比方可以用来分析DNA串的相似度,也可以分析两段文字的相似度,来判断是 否剽窃,等等。
举一个实例来说,假如有序列X<A,B,C,B,D,A,B>和序列Y<B,D,C,A,B,A>,那么他 们的最长公共子序列为<B,C,D,B>。
动态规划法求最长公共子序列
求最长公共子序列最直接最暴力的一种方法当然是枚举出两个序列所有的子序列, 然后从中找出所有的公共子序列,再选出所有公共子序列中最长的那个。不过这 种粗暴的做法是很低效的,假如两个序列的长度分别为m和n,因为它们分别有 2m 和 2n 个子序列,那么这个算法的时间复杂度将是指数级别的 O(2m+n) ,对于长一些的序列这种方法是不实际的。撇开这种方法不谈, 我们将关注另一种方法——用动态规划策略来求最长公共子路径问题。
第一步:描述问题的最优子结构
之前一篇笔记有总结到,动态规划算法的运用有两个必要条件,一是问题包含最 优子结构,二是有重叠子问题。第一步我们要做的便是证明LCS问题确实包含有最 优子结构。定理15.1说明LCS包含有最优子结构,原书的证明已经清楚明了,下面 引用原书的定理15.1证明:
Theorem 15.1: (Optimal substructure of an LCS)
Let \( X = \) and Y=<y1,y2,…,yn> be sequences, and let \( Z = \) be any LCS of XandY .
- If xm=yn , then zk=xm=yn and Zk−1 is an LCS of Xm−1andYn−1 .
- If xm≠yn , then zk≠xm impliesthat\(Z is an LCS of Xm−1andY .
- If xm≠yn , then zk≠yn implies that Z is an LCS of XandYn−1 .
Proof (1) If zk≠xm , then we could append xm=yn to Z to obtain a common subsequence of XandY of length k+1 , contradicting the supposition that Z is a longest common subsequence of XandY . Thus, we must have zk=xm=yn .Now, the prefix Zk−1 is a length- (k−1) common subsequence of Xm−1andYn−1 . We wish to show that it is an LCS. Suppose for the purpose of contradiction that there is a common subsequenceW of Xm−1 and Yn−1 with length greater than k−1 . Then, appending xm=yn to W produces a common subsequence of X and Y whose length is greater than k, which is a contradiction.
(2) If xk≠xm , then Z is a common subsequence of Xm−1andY . If there were a common subsequence W of Xm−1andY with length greater than k, then W would also be a common subsequence of XmandY , contradicting the assumption that Z is an LCS of X and Y.
(3) The proof is symmetric to (2).
第二步:一个递归解
由定理15.1可以看出,找序列X和序列Y的LCS,我们有可能需要找出X和 Ym−1 的LCS,以及 Xn−1和Ym 的LCS。而这两个子问题,都拥有一个共同的子子问题, 便是求 Xn−1和Ym−1 的LCS。依次类推,还有很多其他的子问题会共有许多 其他的子子问题。这就满足了动态规划的第二点条件,拥有重叠的子问题。
定义c[i, j]为 Xi和Yj 的LCS长度,根据定理15.1可以得出下面的递归式:
第三步:计算LCS的长度
利用第二步的递归式15.9,可以很容易写出计算LCS长度的递归求解程序,但这种方 式并不比我们一开始提到的最简单粗暴的方法快(有可能还要慢),它同样是指数级 的复杂度。
一二步已经验证了动态规划策略的可行性,于是我们将用动态规划来求解LCS的长度。 下面的伪代码程序维护由两个表,表c和表b。表c用来记录c[i,j]的值,表b则用来方 便LCS的构造,它会记录一些信息,指引我们在构件最优解的时候,如何选择最优子问 题,下面是伪码:
LCS-LENGTH(X, Y) m = length[X] n = length[Y] for i = 1 to m c[i, 0] = 0 for j = 0 to n c[0, j] = 0 for i = 1 to m for j = 1 to n if xi = yj 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] = "←" return c and b
假设有序列X = 〈A, B, C, B, D, A, B〉和 Y = 〈B, D, C, A, B, A〉。那么通过执行LCS-LENGHT表c和表b存储的信息将如下:
说明:右图是将表b和表c的信息合二为一的显示, d第i行和第j列所指方块,记录了c[i,j]和b[i,j]中的信息。我们通过那些箭头来获得 问题的最优子问题的路径,路径上的"↖"表示 Xi=Yi ,所以为LCS上的一个 字母。
第四步:构建LCS
通过表b我们可以很快的构建出\( X = > \)的LCS。可以从b[m,n]开始跟踪路径,当b[i,j]为"↖"时,输出当前 字母。考虑到时从后往前追踪的,所以求出来的LCS将是反向的,所以在我们下面的递归伪 码中,将先递归再输出。以下为伪码:
PRINT-LCS(b, X, i, j) if i == 0 or j == 0 return if b[i, j] == "↖" PRINT-LCS(b, X, i - 1, j - 1) print xi else if b[i, j] == "↑" PRINT-LCS(b, X, i - 1, j) else PRINT-LCS(b, X, i, j - 1)
C++ 的实现
我先实现了一个简单的动态二维数组的分配代码,作为基础工程。
//create a dynamic doble array template<typename Type> Type** dob_array(int x, int y) { Type **b=new Type*[x]; for(int i=0; i!=x; ++i) { b[i]=new Type[y]; } return b; } //delete the dynamic double array template<typename Type> void delete_dob_array(Type** p_to_p, int x) { for(int i=0; i!= x; ++i) { delete [] p_to_p[i]; } delete p_to_p; }
下面为主体代码:
int lsc_lenght(std::string str1, std::string str2, int** count, int**path) { const int x=str1.size(); const int y=str2.size(); for(int i=1; i!=x; ++i) { count[i][0]=0; } for(int j=0; j!=y; ++j) { count[0][j]=0; } for(int i=1; i!=x+1; ++i) { for(int j=1; j !=y+1; ++j) { if(str1[i-1]==str2[j-1]) { count[i][j]=count[i-1][j-1]+1; path[i][j]=0; } else{ if(count[i-1][j] >count[i][j-1]) { count[i][j]=count[i-1][j]; path[i][j]=-1;} else{ count[i][j]=count[i][j-1]; path[i][j]=1;} } } } return count[x][y]; } void print_lcs(int **path, std::string str, int x, int y) { if(x==0 || y==0) return; switch(path[x][y]) { case -1: print_lcs(path,str ,x-1,y);break; case 0:{ print_lcs(path,str,x-1,y-1); std::cout<<str[x-1]; } break; case 1:print_lcs(path, str, x,y-1); break; default:return; } }
测试代码:
//一个产生随机字符串的函数。 string get_random_str(int size) { string base_str="aAbBcCdDeEfFgGhHiIjJkKlLmMnNoOpPqQrRsSTtUuVvWwXxYyZz"; string result; int i=size; while(i>0){ result+=base_str; i-=52; } std::random_shuffle(result.begin(), result.end()); result.resize(size); return result; } int main() { const int size=800; //初始化测试数据; string str1=get_random_str(size); string str2=get_random_str(size); // 表的下标从1开始,所以要多分配1 int **path=dob_array<int>(size+1 , size+1); int **count=dob_array<int>(size+1 , size+1); lsc_lenght(str1, str2, count, path); print_lcs(path, str1,size,size); delete_dob_array(path, size); delete_dob_array(count,size); std::cin.get(); }
思考
前面所述的求LCS的方式是自下而上的,但在这个问题中,并不一定每一个子问题都会有 用到,特别是当两段序列的相似度很高的时候,则更加明显。这一点可以从上面的那个图 中可以看出来。我于是想用自顶而下的动态规划方式,比一比两者的效率。自定而下的代 码如下:
//自定而下求最长公共子序列 int memorized_lsc_len(const char* str1, const char* str2 , int str1_len, int str2_len, int** count, int**path) { int result=0; if(str1_len==0 || str2_len==0) return 0; if(count[str1_len][str2_len]>0) return count[str1_len][str2_len]; if( str1[str1_len-1] == str2 [str2_len-1]) { result=memorized_lsc_len(str1,str2, str1_len-1, str2_len-1,count, path) ; count[str1_len][str2_len]=result+1; path[str1_len][str2_len]=0; } else { int result1=memorized_lsc_len(str1,str2, str1_len-1 , str2_len, count, path); int result2=memorized_lsc_len(str1, str2 , str1_len, str2_len- 1, count , path); if(result1>result2) { result=result1; path[str1_len][str2_len]=-1; } else { result=result2; path[str1_len][str2_len]=1; } count[str1_len][str2_len]=result; } return result; }
我的测试方式是,两段代码分别对随机而得的两个个字符串进行操作。不过比较遗憾 的是,我的机器内存不大,总共就2G,我大体得到了规模在1万以下的结论。规模在1万 以上的时候,我的机器就并不呢功能胜任了,因为内存的使用已经达到了90%以上,每 次得到的数据偏差较大,已经不准确。
当两个字符串长度在0~500的时候,自下而上的方式,速度要快的比较明显。然而当长 度超过500的时候,自顶而下的方式便开始具有比较不错的优势,大约有25%左右。不过 我的数据两只测到8000。另外很明显的一点是自顶而下的方式,消耗更多的内存。
我这样认为,当字符串较短的时候,因少计算的子问题带来的时间节省并不足以弥补递 归所带来的开销,另外特别是有由于随机而得的短字符串,LCS长度也很小,能少计算的 子问题并不多。当字符串长度增大时,这种少计算子问题的优势会有所体现,特别是当 两个字符串相似度很高的时候。当然,当字符串长度增长的时候,所多用的内存也会增 加。
要注意的一点是,测试的时候要把程序的栈空间调大一些,不然自顶而下的方式,可能 很快就爆栈了。
参考: introduction to algorithm –third edition