我奶奶都能学会的求最长公共子串长详详解

143 阅读16分钟

[本文主要参考](从优化到再优化,最长公共子串 - Ider - 博客园 (cnblogs.com))

本文作为笔记只对参考内容进行补充,请结合参考内容食用!文章后面就按照自己理解的思路放飞自我了,有很多和参考文章不一样的内容。

最长公共子串(Longest Common Substring)是一个非常经典的顺序表算法题目,在实际的程序中也有很高的实用价值,比如文本比较、数据压缩、DNA序列分析等。本文将从最原始的解法开始介绍该问题解法的不断优化过程,加深自己对这一知识的掌握,分享给后来人。

最最暴力的解法

#include <stdio.h>

int maxSub(int[], int, int[], int);
int compare(int[], int[], int, int, int, int);
int main(){
	int A[] = {1,2,3,4,5,6,32,432,4,234,23,4,324};
	int B[] = {23213,21,3,1321,321,3,123,4,5,6,32,89,23,321}; 
	int lenA = sizeof(A)/sizeof(int);
	int lenB = sizeof(B)/sizeof(int);
	
	if(lenA < lenB){
		printf("%d ", maxSub(A, lenA, B, lenB));
	}else{
		printf("%d ", maxSub(B, lenB, A, lenA));
	}
	
	return 0;
} 
int maxSub(int A[], int lenA, int B[], int lenB){ // 数组作为实参传递时,会退化为指向首元素的指针 
//	int lenA = sizeof(A)/sizeof(int);
//	int lenB = sizeof(B)/sizeof(int);
//	printf("%d %d ", lenA, lenB);   // 这里计算的长度会变成 int指针的大小(8)/int类型的大小(4) = 2 具体大小与编译器有关
	int maxLen = 0; 
	
	for(int i=0; i<lenA; i++){ 
		for(int j=i; j<lenA; j++){
			/*****这部分代码显然可以优化*****/
			for(int m=0; m<lenB; m++){ 
				for(int n=m; n<lenB; n++){
					// A数组的所有子串与B数组的所有子串进行比较 
					if(compare(A, B, i, j, m, n)){
						maxLen = (j-i+1) > maxLen ? (j-i+1) : maxLen;
					}
				}
			}
			/*******************************/
		}
	}
	
	return maxLen;
}
int compare(int A[], int B[], int i, int j, int m, int n){
	if((j-i) != (n-m)) return 0;
	
	for(i,m; i<=j && m<=n; i++,m++){
		if(A[i] != B[m]){
			return 0;
            }
	}
	return 1;
}

思路是找出两个字符串的所有子字符串并比较它们是否相同,然后取相同的中最长的那个。对于一个长度为n的字符串,它有n(n+1)/2个非空子串。所以假如两个字符串的长度同为n,这种解法不考虑compare()时间复杂度的话,执行compare()的次数可以表示为 [n(n+1)/2]*[n(n+1)/2],时间复杂度应为O(n^4)

为啥是n(n+1)/2个子串呢?

可能有人像我一样,连子串的定义都没搞清,一个字符串,如"abcdef",单个字母拎出来也是一个子串!这个字符串整体也是它自己的一个子串!我们取上述字符串以a开头的子串,可以有"a"、"ab"、"abc"、"abcd"、"abcde"、"abcdef" 六个,归纳法得如果一个字符串长度为n的第i个元素开头的字符串有n-i+1个,i1n,等差数列求和则有n(n+1)/2个。

最暴力的解法

#include <stdio.h>

int maxSub(int[], int, int[], int);
int compare(int[], int[], int, int, int);
int main(){
	int A[] = {1,2,3,4,5,6,32,432,4,234,23,4,324};
	int B[] = {23213,21,3,1321,321,3,123,4,5,6,32,89,23,321}; 
	int lenA = sizeof(A)/sizeof(int);
	int lenB = sizeof(B)/sizeof(int);
	
	if(lenA < lenB){
		printf("%d ", maxSub(A, lenA, B, lenB));
	}else{
		printf("%d ", maxSub(B, lenB, A, lenA));
	}
	
	return 0;
} 
int maxSub(int A[], int lenA, int B[], int lenB){ 
	int maxLen = 0; 
	
	for(int i=0; i<lenA; i++){ 
		for(int j=i; j<lenA; j++){
			for(int m=0; m<lenB; m++){ 
				// 只需要比较以B[m]开头长度与本次取的A的子串相同的B的子串即可 
				if(compare(A,B,i,m,j-i+1)){
				    maxLen = (j-i+1) > maxLen ? (j-i+1) : maxLen; 
				}
			}
		}
	}
	
	return maxLen;
}

int compare(int A[], int B[], int As, int Bs, int len){
	for(int i=0; i < len; i++){
		if(A[As+i] != B[Bs+i]){
			return 0; // 两子串不相等 
		}
	}
	return 1; // 相等 
}

我们将显然可以优化的地方优化了一下,只会比较B子串中和当前的A子串长度相同的,这样执行次数成了n[n(n+1)/2],时间复杂度是O(n^3),但是要考虑到compare()的时间复杂度O(n)的话,时间复杂度还是高达O(n^4)

还有哪些地方可以优化呢?

如果你能完全理解上面的算法就能知道,它们的最大公共子串是4,5,6,32的话,按照上面的代码,会compare()一次A4,B4,得到maxLen=1。再compare()一下4,5,这里A4B4又要重复比一次,得到maxLen=2...依次类推,这一个4,5,6,32就要重复compare()4次,才能知道maxLen=4,其中对子串前34,5,6那是一遍又一遍的比较,出现了重复的比较。那我们想,如果我一旦知道出现了公共子串,就看看子串的下一个元素和它是不是也能组成公共子串就好了。下面按照这一思路进行优化。

暴力的解法

#include <stdio.h>

int maxSub(int[], int, int[], int);
int main(){
	int A[] = {1,2,3,4,5,6,32,432,4,234,23,4,324};
	int B[] = {23213,21,3,1321,321,3,123,4,5,6,32,89,23,321}; 
	int lenA = sizeof(A)/sizeof(int);
	int lenB = sizeof(B)/sizeof(int);
	
	printf("%d", maxSub(A, lenA, B, lenB));
	
	return 0;
} 
int maxSub(int A[], int lenA, int B[], int lenB){ 
	int maxLen = 0; 
	
	for(int i=0; i<lenA; i++){
		for(int j=0; j<lenB; j++){
			int m = i;
			int n = j;
			int len = 0;
			while(m<lenA && n<lenB) {
				if(A[m] != B[n]) break;
				len++;
				m++;
				n++;
			}
			maxLen = len>maxLen ? len:maxLen;
		}
	}
	
	return maxLen;
}

暴力的解法的思路是只考虑子串的起始端点,比较AB两个数组每个起始端点组合的公共子串长度,找出最大值。当起始端点m/nm+1/n+1处的子串为公共子串时,会自动比较到m+2/n+2处。相较上一个解法减少了重复的比较。计算一下时间复杂度,如果考虑最坏的情况,即两个数组完全相同,最长子串为它们本身,lenA=lenB=n的话,while循环的次数(也就是if(A[m] != B[n])执行的次数)取决于 ij中较大的那个,当i=0时,执行次数是n、n-1、n-2、... 、1,当i=1时执行次数是n-1、n-1、n-2、...、1,并不是等差数列,而是等差数列加一个常数列。

最坏情况下执行次数计算公式可以总结为:i=0n1(ni)i+(ni)(ni+1)/2 \sum_{i=0}^{n-1} (n-i)*i+(n-i)(n-i+1)/2

整理得:i=0n1(ni)(n+i+1)/2 \sum_{i=0}^{n-1} (n-i)(n+i+1)/2

易知最坏时间复杂度为O(n^3)

下一步将介绍该问题的经典解法,这一解法也是动态规划的经典应用。

前面讲到为了解决“最暴力解法”中,“当最大公共子串是4,5,6,32时,会compare()一次A4,B4,得到maxLen=1。再compare()一下4,5,这里A4B4又要重复比一次,得到maxLen=2...依次类推,这一个4,5,6,32就要重复compare()4次,才能知道maxLen=4,其中对子串前34,5,6那是一遍又一遍的比较,出现了重复的比较。”的问题,我们按照子串的起始端点进行比较,前一项相等立刻比较后一项解决了上述问题。但是这样做还是有个问题。

虽然排除了起始端点都为4(即最大公共子串起始位置)的子串的重复比较,但是没有排除起始端点不同但是末尾端点都是32(即最大公共子串末尾位置)的重复比较。我们仍以最大公共子串是4,5,6,32为例,当我们比较以4开头的子串时,会分别比较Aの4Bの4Aの5Bの5Aの6Bの6Aの32Bの32各一次。按照“暴力的解法”,后面我们还会比较同以5开头的子串时,又会比较Aの5Bの5Aの6Bの6Aの32Bの32各一次。在已经知道4,5,6,32这一子串的长度后,这一子串的同尾子串的长度一定是小于它的,这样的一轮比较是毫无意义的。

具体是哪些部分进行了重复比较呢?

这其实是一件看似显而易见,实际上需要注意些细节的事情。下面我们以A = [4,5,6,32,5,0]; B = [5,4,5,6,32,0]两个数列按照“暴力的解法”穷举出所有子串起始节点组合,不难看出这样的组合一共有n*n=36种。(另外插一句,这样也可以很直白地看出如果数组的最长公共子串长是1,暴力解法进行比较(基本操作)的次数自然=n^2(每种起始节点组合都仅比较一次),也就是“暴力的解法”时间复杂度是在O(n^2)O(n^3)之间。)下图为所有的穷举结果:

image.png

可以看到,按照前文提到的重复比较问题,当比较完以A[0]B[1]开头的子串后,就没必要比较A[1],B[2];A[2],B[3];A[3],B[4]A[4],B[5]开头的子串了,注意这里A[4],B[5]并不是公共子串,但是由于它作为循环的终止条件,也会被多比较一次。同理的还有A[2],B[1]A[5],B[1]A[5],B[3]三个。 计算本次代码执行的比较总数为 sum=36+4+3+2+1+1+1+1sum = 36 + 4 + 3 + 2 + 1 + 1 + 1 + 1; 如果排除掉重复比较的部分(图片中划掉的),sumsum 抵消为 n*n=36

那怎么实现让AB的每个元素只相互比较一次就可以呢?显然我们可以把两元素之间比较的结果存起来,后面比较的时候直接用。也就是以空间换时间的方式。根据这个思路,有如下代码:

优化的解法

#include <stdio.h>

int maxSub(int[], int, int[], int);
int main(){
	int A[] = {4,5,6,4,5,9};
	int B[] = {1,4,5,6,4,5,6,4,5,9}; 
	int lenA = sizeof(A)/sizeof(int);
	int lenB = sizeof(B)/sizeof(int);
	
	printf("%d", maxSub(A, lenA, B, lenB));
	
	return 0;
} 
int maxSub(int A[], int lenA, int B[], int lenB){ 
	int maxLen = 0;
	// 这样可以给每个数组元素都赋初值0 
	int ignoreArr[100][100] = {0};  
	
	int compTimes = 0; 
	
	for(int i=0; i<lenA; i++){
		for(int j=0; j<lenB; j++){
			int m = i;
			int n = j;
			int len = 0;
			if(!ignoreArr[m][n]){ 
				while(m<lenA && n<lenB){
					if(A[m] != B[n]){
						break;
					}
					compTimes++;
					len++;
					m++;
					n++;
					ignoreArr[m][n] = 1; 
                                  // 为1时,以A[m],B[n]开头的组合就不用再进行while循环了 
				}
			}
			maxLen = len>maxLen ? len:maxLen;
		}
	}
	
	printf("compTimes: %d\n", compTimes);
	return maxLen;
}

代码中compTimes会记录比较(基本操作)次数,上面的代码执行结果是 compTimes=36,易知代码的最坏时间复杂度是O(n^2)

关于时间复杂度计算的补充

这里用的时间复杂度都是假设lenA=lenB=n,但其实,lenAlenB是两个完全不相关的量,针对两个完全不相关的量求时间复杂度,一般假设一个是lenA=n,一个是lenB=m,若A、B数组中的每一个元素值都一样,也就是最坏情况,则上面代码的时间复杂度其实是O(m*n),前文中的“最最暴力解法”“最暴力解法”“暴力解法”的时间复杂度计算同理。

动态规划的两大特点

上面那段代码已经很接近动态规划解法了,但是,它并没有存储所有子问题的解,更没有状态转移方程(这也是动态规划的两大特点),因此并不算是动态规划。按理说动态规划解法已经呼之欲出了,但是,在思考这个问题时,我又发现了另一个优化思路......

再优化的猜想

信息技术信息技术,只要我掌握更加深入的信息,似乎就可以简化自己的步骤。下面针对这个问题,我们可以挖掘我们现在所比较的子串的某些特征信息,这些特征信息似乎对下一步的操作有简化作用:

我现在有一个结论,如果我这次比较的子串的前n-i项和它的后n-i项完全一样(n为我比较的子串的长,n!=1),那在这一轮以这个子串的首节点为起始节点的比较中,只需要再比较这个子串的首节点为起始节点,和>=首节点+i为起始节点的子串就可以了。

下面来解释一下,以A = [4,5,6,4,5,9]; B = [1,4,5,6,4,5,6,4,5,9]两个数列为例: 当比较完A[0]B[1]开头的公共子串,得到4,5,6,4,5后,其实就没必要比较A[0]B[2]A[0]B[3]了因为根据上面的结论对于公共子串4,5,6,4,5,只有前2 = n-i个和后2 = n-i个是相同的,得到i=3,只需要从以A[0]B[1+3]开头的子串进行比较就可以了。

再优化的解法

下面这段代码只是实现了思路,还有很多优化的地方,这种解法并不经典,所以我就不再简化了。由于没有相关的文章参考,不保证代码的正确性。

#include <stdio.h>

int maxSub(int[], int, int[], int);
int main(){
	int A[] = {4,5,6,4,5,9};
	int B[] = {1,4,5,6,4,5,6,4,5,9}; 
	int lenA = sizeof(A)/sizeof(int);
	int lenB = sizeof(B)/sizeof(int);
	
	printf("%d", maxSub(A, lenA, B, lenB));
	
	return 0;
} 
int maxSub(int A[], int lenA, int B[], int lenB){ 
	int maxLen = 0;
	// 这样可以给每个数组元素都赋初值0 
	int ignoreArr[100][100] = {0};  
	
	int compTimes = 0; 
	
	for(int i=0; i<lenA; i++){
		for(int j=0; j<lenB; j++){
			int m = i;
			int n = j;
			int len = 0;
			if(!ignoreArr[m][n]){ 
				while(m<lenA && n<lenB){
					if(A[m] != B[n]){
						break;
					}
					compTimes++;
					len++;
					m++;
					n++;
					ignoreArr[m][n] = 1; 
                                  // 为1时,以A[m],B[n]开头的组合就不用再进行while循环了 
				}
				j = j + skipIndex(A,i,m-1,&compTimes);
			}
			maxLen = len>maxLen ? len:maxLen;
		}
	}
	
	printf("compTimes: %d\n", compTimes);
	return maxLen;
}


int skipIndex(int arr[], int s, int e, int* compTimes){
	int n = e-s+1; // 子串的长度 
	// 当子串长度是0、1时,不用比,因为即使出现前n个和后n个一样,也不需要改变下一次比较中B数组的起始位置
	if(n<=1){ 
		return 0; 
	}
		
	int A[n];
	memcpy(A,arr+s,n*sizeof(int));
	
	int i = 1; 
	for(i; i<n; i++){
		int x = i;
		int y = 0;
		while(x<n && y<n){
			if(A[x] != A[y]) {
				*compTimes++;
				break;
			}
			*compTimes++;
			x++;
			y++;
		}
		if(x == n){
			return i-1;
		}
	}
	return n-1;
}

下面是“优化的解法”和“再优化的解法”的执行结果,可以看到再优化的解法少了1次比较次数(苍蝇腿也是肉):

image.png

image.png

动态规划的解法

千呼万唤始出来,动态规划算法它终于来了,我们先把动态规划的思考步骤说一下, 1:找到递推关系 2:递归定义最优值 3:算出最优值 4:根据最优值推导出最优解 另外,动态规划的两大特点:记录所有子问题的解、状态转移方程

首先我们要知道,动态规划解法和“优化的解法”最终达到的效果其实是类似的,前者是将每个子问题的解都存起来,后者是将不必再解的子问题记录下来。都是以空间换时间。

1:找到递推关系

这一步纯看你有没有足够的perception,我们以AB数组每种元素组合起始节点的最大公共子串长为单一子问题,从A[lenA-1]B数组各元素为起始节点的最大公共子串长这一组问题开始推导递推关系公式(动态规划问题一般都是自下而上推导,因为最后的子问题往往不依赖其它子问题就能求解(base case),另外你也可以以AB数组每种元素组合终止节点的最大公共子串长为单一子问题,这样就要自上而下推导)。 下表展示了这一问题分解子问题的两种思路,两种思路都是对√的。

以每种元素组合为XX节点的最长公共子串起始终止
自下而上×
自上而下×

2:递归定义最优值

设A最后一个元素A[lenA-1]B数组各元素的子问题的解用二维数组表示为dp[lenA-1][0~lenB-1]表示,这一组问题的解很明显是由B中的元素是否与A[lenA-1]相等决定的,如果相等解为1,不相等解为0B[lenB-1]A数组各元素同理。

这样就可以得到数组dp[lenA][lenB]最后一行和最后一列的解,即右/下的解,而dp[i][j]的解与其右下角dp[i+1][j+1]的解直接相关,便可得出一个存储所有子问题解的数组,遍历这个数组即可找到整个问题的解。

3:算出最优值

#include <stdio.h>

int maxSub(int[], int, int[], int);
int main(){
	int A[] = {4,5,6,4,5,9};
	int B[] = {1,4,5,6,4,5,6,4,5,9};
	int lenA = sizeof(A)/sizeof(int);
	int lenB = sizeof(B)/sizeof(int);
	
	printf("\n result: %d", maxSub(A,lenA,B,lenB));
	
	return 0;
}
int maxSub(int A[], int lenA, int B[], int lenB){
	int dp[lenA][lenB];
	
	for(int i=0; i<lenA; i++){
		dp[i][lenB-1] = A[i] == B[lenB-1] ? 1 : 0;
	}
	for(int j=0; j<lenB-1; j++){
		dp[lenA-1][j] = A[lenA-1] == B[j] ? 1 : 0;
	}
	
	for(int i=lenA-2; i>=0 ; i--){
		for(int j=lenB-2; j>=0; j--){
		 	dp[i][j] = A[i] == B[j] ? dp[i+1][j+1] + 1 : 0; 
		} 
	}
	
	// 遍历解集,找出最长公共子串长
	int max = 0;
	for(int i=0; i<lenA; i++){
		for(int j=0; j<lenB; j++){
			printf("%d ", dp[i][j]);
			max = dp[i][j] > max ? dp[i][j] : max; 
		}
		printf("\n");
	} 
	
	return max;
}

image.png

4:根据最优值推导出最优解

该问题的最优解就是求得的最长公共子串,遍历存储子问题解集的二维数组dp就可以得到,这里不再实现代码。

动态规划解法优化

参考文章(从优化到再优化,最长公共子串 - Ider - 博客园 (cnblogs.com))最后一次优化的代码挺有意思的,确实是个好思路,从对角线斜着算dp数组,分别向右上和左下两个方向的斜对角线扩展,如果斜对角线的长度都比我已经算出来的最长公共子串长小,那小于等于这个斜对角线的它的公共子串一定更小。那就不用再算了。

image.png

什么是动态规划?哪里体现动态?哪里体现规划?

image.png 以上面这一问题为例,在计算二维数组dp的过程中,dp[m][n]由动态生成的dp[m+1][n+1]A[m]、B[n]的值共同决定,此处体现动态。DP思想主要解决最优解问题,多个可能的解中规划出最优的,此处体现规划

耗时1坤天,终于把这个问题理解到了我满意的程度,虽然是一个很简单的问题,但是却浪费了自己这么多学习时间,或许是万事开头难,希望后面的算法学习能够逐步提高效率。