假设有两个字符串A和B,A字符串的组成为A=A0A1A2......An−1A = A_0A_1A_2......A_{n-1}A=A0A1A2......An−1
B=B0B1B2......Bm−1B=B_0B_1B_2......B_{m-1}B=B0B1B2......Bm−1
要寻找这两个字符串的公共子序列还是最长的那个,这怕是有点难哦!
我首先想到的就是暴力求解,分别用A串中的每一个子串去匹配B串中的子串,只要匹配上了就说明这是一个公共的子串,然后记录下来此子串作为目前碰到的最长子串,继续匹配更长的子串,如果存在更长的公共子串就更新我们记录的最长子串,直到所有可能都已经验证过,这样我们就使用暴力算法求解出了该问题的解。
暴力算法时间分析
由于暴力算法是利用子串来进行匹配求解的,显然要求解A串和B串的每一对子串的配对,那么就要求解numA∗numBnum_A*num_BnumA∗numB其中numAnum_AnumA是A中的子串个数,numBnum_BnumB是B中子串个数,要求A中的子串个数可以这样考虑,A中的一个字符要么在A的子串里要么不在A的子串里,第一个字符要么出现在其子串中,要么不出现在其子串中,第二个字符同样的道理一直到第n个字符,由此A的子串有2∗2∗2∗......∗2∗2(n个2相乘)=2n2*2*2*......*2*2(n个2相乘)=2^n2∗2∗2∗......∗2∗2(n个2相乘)=2n
同理B串有2m2^m2m个子串,因为要求解2m+n2^{m+n}2m+n由此可以判定暴力算法求解的时间复杂度为O(2n2^n2n)。
虽然可以在这种算法的基础上进行优化,但是优化过后仍然是指数级的时间复杂度。
这指数级的时间复杂度似乎有点太高了吧!我无法接受。
动态规划算法
最长公共子序列问题具有最优子结构性质。
假设A串与B串的最长公共子序列为C=C0C1C2......CkC=C_0C_1C_2......C_kC=C0C1C2......Ck那么我们可以作以下推理:
- 如果An−1==Bm−1A_{n-1} == B_{m-1}An−1==Bm−1那么Ck==An−1==Bm−1C_k == A_{n-1} == B_{m-1}Ck==An−1==Bm−1同时C余=C0C1C2......Ck−1C_余 = C_0C_1C_2......C_{k-1}C余=C0C1C2......Ck−1是A=A0A1A2......An−2A = A_0A_1A_2......A_{n-2}A=A0A1A2......An−2
B=B0B1B2......Bm−2B=B_0B_1B_2......B_{m-2}B=B0B1B2......Bm−2的最长公共子序列。这很好理解对吧! - 如果An−1≠Bm−1A_{n-1}\neq B_{m-1}An−1=Bm−1且Ck≠An−1C_k \neq A_{n-1}Ck=An−1那么C=C0C1C2......CkC=C_0C_1C_2......C_kC=C0C1C2......Ck是A=A0A1A2......An−2A = A_0A_1A_2......A_{n-2}A=A0A1A2......An−2
B=B0B1B2......Bm−1B=B_0B_1B_2......B_{m-1}B=B0B1B2......Bm−1的公共子序列 - 如果An−1≠Bm−1A_{n-1}\neq B_{m-1}An−1=Bm−1且Ck≠Bm−1C_k \neq B_{m-1}Ck=Bm−1那么C=C0C1C2......CkC=C_0C_1C_2......C_kC=C0C1C2......Ck是A=A0A1A2......An−1A = A_0A_1A_2......A_{n-1}A=A0A1A2......An−1
B=B0B1B2......Bm−2B=B_0B_1B_2......B_{m-2}B=B0B1B2......Bm−2的公共子序列
这说明当前串的最长公共子序列包含了这两个序列前缀的最长公共子序列,这说明此问题具有最优子结构。
确定递归结构
当其中有一个串的长度为0的时候则递归求解的时候这种情况最长公共子序列的长度就为0,当An−1==Bm−1A_{n-1} == B_{m-1}An−1==Bm−1时,就是A=A0A1A2......An−2A = A_0A_1A_2......A_{n-2}A=A0A1A2......An−2B=B0B1B2......Bm−2B=B_0B_1B_2......B_{m-2}B=B0B1B2......Bm−2这两个最长公子序列长度加1,当An−1≠Bm−1A_{n-1} \neq B_{m-1}An−1=Bm−1则子序列的长度为A=A0A1A2......An−1A = A_0A_1A_2......A_{n-1}A=A0A1A2......An−1B=B0B1B2......Bm−2B=B_0B_1B_2......B_{m-2}B=B0B1B2......Bm−2的最长公共子序列长度和A=A0A1A2......An−2A = A_0A_1A_2......A_{n-2}A=A0A1A2......An−2B=B0B1B2......Bm−1B=B_0B_1B_2......B_{m-1}B=B0B1B2......Bm−1的最长公共子序列长度中的最大值
即
ans[i][j]={0i=0或j=0ans[i−1][j−1]+1An−1==Bm−1max(ans[i][j−1],ans[i−1][j])An−1≠Bm−1
ans[i][j] = \begin{cases}
0 &i=0或j=0 \\
ans[i-1][j-1] + 1 &A_{n-1} == B_{m-1} \\
max(ans[i][j-1], ans[i-1][j]) & A_{n-1} \neq B_{m-1}
\end{cases}
ans[i][j]=⎩⎨⎧0ans[i−1][j−1]+1max(ans[i][j−1],ans[i−1][j])i=0或j=0An−1==Bm−1An−1=Bm−1
这样我们就能求出整个字符串对应的矩阵列表,其中i表示字符串A的长度,j表示求解B串的长度ans[i][j]表示串A在为前i个字符组成的串的情况下B串为前j个字符组成的串的情况下最长公共子串的长度。
我们求出来的是一个长度矩阵,求解此矩阵只需要O(m*n)的时间复杂度,
然后根据此矩阵我们可以进一步确定整个最长公共子串是什么。这一步只需要O(m+n)时间复杂度,因此整个程序的时间复杂度为O(mn)
同时可以再使用一个矩阵记录一个子问题由哪个子问题得到,这样回溯就只需要根据此矩阵进行回溯即可。
由此可以写出下述代码
int LCLLength(int m, int n, char *x, char *y, int **c, int **b){
int i, j;
for(int i=1; i<m; i++){
c[i][0] = 0; // 其中一个子串长度为0
}
for(i=1; i<=n; i++){
c[0][i] = 0 // 其中一个子串长度为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] = 1;//;记录发生了第一种情况,这样回溯的时候查找A中第i个字符等于B中第j个字符
}else if(c[i-1][j] >= c[i][j-1]){
c[i][j] = c[i-1][j];
b[i][j] = 2;
// 说明A中此处的第i个字符不属于公共子串
}
else{
c[i][j] = c[i][j-1];
b[i][j] = 3;// 说明此处B串中第j个字符不属于公共字符串
}
}
}
}
在回溯的过程中,可以得到要的字符串。
void LCS(int i, int j, char *x, int **b){
if(i==0||j==0){
return;
}
if(b[i][j] == 1){
// 说明A中此i处属于公共子序列
LCS(i-1,j-1, x,b);
printf("%c", x[i]); // 使用B串就使用j下标
}else if (b[i][j] == 2){
LCS(i-1,j,x,b);
}else{
LCS(i,j-1, x,b);
}
}
这样我们就解决了整个最长公共子序列问题。
完整可运行代码如下:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int LCLLength(int m, int n, char *x, char *y, int **c, int **b){
int i, j;
for(i=1; i<m; i++){
c[i][0] = 0; // 其中一个子串长度为0
}
for(i=1; i<=n; i++){
c[0][i] = 0;// 其中一个子串长度为0
}
for(i=1; i<=m; i++){
for(j=1; j<=n; j++){
if(x[i-1] == y[j-1]){
c[i][j] = c[i-1][j-1]+1;
b[i][j] = 1;//;记录发生了第一种情况,这样回溯的时候查找A中第i个字符等于B中第j个字符
}else if(c[i-1][j] >= c[i][j-1]){
c[i][j] = c[i-1][j];
b[i][j] = 2;
// 说明A中此处的第i个字符不属于公共子串
}
else{
c[i][j] = c[i][j-1];
b[i][j] = 3;// 说明此处B串中第j个字符不属于公共字符串
}
}
}
}
void LCS(int i, int j, char *x, int **b){
if(i==0||j==0){
return;
}
if(b[i][j] == 1){
// 说明A中此i处属于公共子序列
LCS(i-1,j-1, x,b);
printf("%c", x[i-1]); // 使用B串就使用j下标
}else if (b[i][j] == 2){
LCS(i-1,j,x,b);
}else{
LCS(i,j-1, x,b);
}
}
int main(void) {
char x[] = "ab";
char y[] = "abc";
int **a = (int **)malloc(sizeof(int*)*(strlen(x)+1));
for(int i=0; i<strlen(x)+1; i++) {
a[i] = (int *)malloc(sizeof(int)*(strlen(y)+1));
}
int **b = (int **)malloc(sizeof(int*)*(strlen(x)+1));
for(int i=0; i<strlen(x)+1; i++) {
b[i] = (int *)malloc(sizeof(int)*(strlen(y)+1));
}
for(int i=0; i<strlen(x)+1; i++) {
for(int j=0; j<strlen(y)+1; j++) {
printf("%4d", b[i][j]);
}
printf("\n");
}
LCLLength(strlen(x), strlen(y), x,y,a,b);
printf("\n");
for(int i=0; i<strlen(x)+1; i++) {
for(int j=0; j<strlen(y)+1; j++) {
printf("%4d", b[i][j]);
}
printf("\n");
}
LCS(strlen(x), strlen(y), x, b);
return 0;
}
其实我们不使用b标记矩阵也能进行回溯,只不过需要进行判断而已。
参考书籍:计算机算法设计与分析第五版(王晓东编著)