目录
朴素的模式匹配算法:
当我们要在一个字符串中找一个子字符串时,一般情况下首先想到的都是暴力求解法,就是朴素的模式匹配算法。
假设我们需要在主串“S”=abcabcd找子串"T"=abcd,通常情况下我们需要以下几个步骤:
(1)、主串S第一位开始匹配,若匹配成功,则后移一位继续比较,当匹配到第四次时,主串S中的a与子串T中的d不相同,匹配失败;
(2)、主串S的第二位开始匹配,b与a不相同,匹配失败;
(3)、主串S的第三位开始匹配,c与a不相同,匹配失败;
(4)、主串S的第四位开始匹配,S与T四个字母全匹配,匹配成功。
简单地说,就是将主串的每一个字符作为子串的开头,与子串的字符进行匹配。对主串做一个大循环,每个字符开头做与子串T匹配的小循环,直到匹配成功。
朴素的模式匹配算法代码展示:
//该函数返回子串T在主串S中的的位置(从第几个字符开始)
//若主串S中不存在子串T,则返回0;
int Index(char*S,char*T)
{
int lengthS=strlen(S);
int lengthT=strlen(T); //用于计算字符串的长度
int i=1; //记录主串S当前位置的下标值
int j=1; //记录子串T当前位置的下标值
while(i<=lengthS&&j<=lengthT){
if(S[i-1]==T[j-1]){ //若两个字符相等,则后移一位继续匹配
i++;
j++;
}else{
i=i-j+2; //若不相等,i退回到上轮匹配首位的下一位
j=1; //j退回到字符串T的首位
}
}
if(j>lengthT){
return i-lengthT;//返回子串T在主串S中的的位置(从第几个字符开始)
}else{
return 0;
}
}
下面我们来看看朴素的模式匹配算法的时间复杂度(假设主串的长度为n,子串的长度为m):
(1)当情况最好的时候(子串就存在与主串的开头,一开始就匹配成功)此时该算法的时间复杂度为O(1);
(2)平均情况:例如当我们需要在“abcdefgoogle”中找“google”时,该算法的时间复杂度是O(n+m);
(3)在最坏的情况下(即每次匹配不成功都发生在子串的最后一个字母,例如当主串为“aaaaaaaaaaaaaaaaaaaaaaaaaaab”,子串为“aaaaaaaaaaab”)此时该算法的时间复杂度为O((n-m+1)*m)。
在计算机中,模式匹配算法随处可见,如果向上面列举的最坏的那种情况,每次匹配失败后需要回溯的主串的下一位继续匹配,这种情况显然是非常糟糕的,计算机做了很多无用功,显得非常低效。
因为朴素的模式匹配算法非常低效,三位前辈:D.E.Knuth、J.H.Morris、V.R.Pratt发表了一个模式匹配算法----即“克努特—莫里斯—普拉特算法”简称(KMP算法),大大地避免了重复遍历的情况。
KMP算法:
(烤馍片算法)举个例子:
现有一个主串S="abcdabcdz"和一个子串T="abcdz",现在要在主串S中找子串T,按照朴素的模式匹配算法,执行步骤如下(这里我们用i记录主串S中当前匹配的位置,用j记录子串T中当前匹配的位置):
(1)、当i=5,j=5时匹配失败,下一步:让i回溯到i=2的位置,j回溯到j=1的位置进行下一轮的匹配;
(2)、i=2,j=1匹配失败,下一步:让i=3,j=1开始匹配;
(3)、i=3,j=1匹配失败,下一步:让i=4,j=1开始匹配;
(4)、i=4,j=1匹配失败,下一步:让i=5,j=1开始匹配;
(5)、i=5,j=1进行匹配。
在上述步骤中,如果我们仔细观察,可以发现子串“T”中abcdz中任意一个字符都不相等,也就是说:a不与自己后面的任意一个字符相等,那么对于上述步骤一来说,既然前四位字符都匹配成功,则可以得出子串的第一个字符“a”不与主串S中第二到四个字符相等,那么在上述步骤中,步骤二,三,四就显得多余。可以直接跳到步骤五:
如果我们知道子串T中的第一个字符与后面的字符均不相等,且子串T中第二位、第三位.....第n位与主串S串中的字符相匹配,则可以得出子串T的首位字符与主串S的第二到第n位都不相等,按照朴素的模式匹配算法的思路,第一步后面的步骤一直到第n步都显得多余。
当子串T中的字符中存在与字符串首位字符相等的字符时该怎么办?
现在我们假设主串S=“abcababca”,子串T=“abcabx”;(这里我们用i记录主串S中当前匹配的位置,用j记录子串T中当前匹配的位置)
(1)、当i=6,j=6时匹配失败,根据上一个例子的经验,我们可以直接跳到i=4,j=1进行匹配;
(2)、i=4,j=1时继续匹配,当i=6,j=3时匹配失败。
在第一步时,i=6时匹配失败,i回溯到i=4继续匹配到了第二步,i的值又回到了6;经过分析发现,在朴素的模式匹配算法中,主串i的值是通过不断回溯来实现的,其实这种回溯是可以省略的,我们让i不回溯,通过j值的回溯来实现KMP算法。
在KMP算法中i值是不回溯的,但是j的值是需要回溯的。前面在举例的时候,我们多次提到过子串T的首字符与自身后面的字符相比较,当子串T后面存在与首字符相等的字符和子串T后面的字符与首字符都不相等的两种情况,j值的变化就不相同
如图所示:(情况一):
(情况二):
也就是说:j值的大小取决于当前字符之前的字符串串的最前面与最后面的相似度(公共前后缀的最长长度)(只与子串有关,与主串无关);我们在需要查找字符前,先要对要查找的字符串做一个分析,计算出子串每一个的位置j值应该回溯到哪里,并把子串各个位置的j值变化定义为一个数组next存储起来,这样可以大大减少查找难度,提高了速度。
next数组值的推导
举个例子:
假设子串T=“ababaaaba”
(1)、当j=1时,其next值都为0,即next[1]=0;
(2)、当j=2时,next[2]=1;
(3)、当j=3时,next[3]=1;
(4)、当j=4时,该位置前面的串是“aba”,前缀字符“a”与后缀字符“a”相等,因此next[4]=2;
(5)、当j=5时,该位置前面的串是“abab”,前缀字符串“ab”与后缀字符串“ab”相等,因此next[5]=3;
(6)、当j=6时,该位置前面的串是“ababa”,前缀字符串“aba”与后缀字符串“aba”相等,因此next[6]=4;
(7)、当j=7时,该位置前面的串是“ababaa”,前缀字符“a”与后缀字符“a”相等,因此next[7]=2;
(8)、当j=8时,该位置前面的串是“ababaaa”,前缀字符“a”与后缀字符“a”相等,因此next[8]=2;
(9)、当j=9时,该位置前面的串是“ababaaab”,前缀字符“ab”与后缀字符“ab”相等,因此next[9]=3;
计算子串的next数组的代码实现:
//通过计算得到子串T的next数组
void Getnext(char*T,int *next)
{
int length=strlen(T);
int i=1;
int k=0;
next[1]=0;
while(i<length){
if(k==0||T[i]==T[k]){
i++;
k++;
next[i]=k;
}else{
k=next[k];
}
}
}
当我们得到next数组后,开始匹配前的准备工作已经做完了,下面将进入匹配环节:
//该函数返回子串T在主串S中的的位置(从第几个字符开始)
//若主串S中不存在子串T,则返回0;
int Indexkmp(char*S,char*T)
{
int i=1; //记录主串S当前位置的下标值
int j=1; //记录子串T当前位置的下标值
int lengthS=strlen(S);
int lengthT=strlen(T); //计算字符串的长度
int next[lengthT+1];
Getnext(T,next); //得到子串的next数组
while(i<=lengthS&&j<=lengthT){ //当i小于主串S的长度,j小于子串T的长度时循环继续
if(j==0||S[i-1]==T[j-1]){ //若两个字符相等或k=0时,后移一位继续匹配
i++;
j++;
}else{
j=next[j]; //j回溯到合适的位置,i不回溯
}
}
if(j>lengthT){
return i-lengthT; //返回子串T在主串S中的的位置(从第几个字符开始)
}else{
return 0; //若不存在则返回0
}
}
KMP算法的时间复杂度为O(m+n),相比于朴素的模式匹配算法的O((n-m+1)*m)来说要好很多。
KMP模式匹配算法的改进
当子串主串S=“aaaaaaacdfghj”,子串T=“aaaaaaab”时,根据KMP算法,子串T的next数组值依次为01234567;
(1)、第一轮匹配当i=8,j=8时,匹配失败,
(2)、j回溯到j=7继续匹配,匹配失败,
(3)、j回溯到j=6继续匹配,匹配失败,
..............
(8)、j回溯到j=1继续匹配,匹配失败
(9)、j回溯到j=0继续匹配。
在以上步骤中,步骤(2)到步骤(8)都是多余的判断,因为子串T第2、3、4、5、6、7位置的字符都与首位字符相等,因此为了提高计算效率,可以用首位字符的next值代替后面与首位字符相等的字符的next值。
因此,我们可以对next数组值的计算做一个改进,举例说明:
假设子串T=“ababaaaba”
该子串原来的next数组值依次为011234223
(1)、当j=1时,newnext[1]=0
(2)、当j=2时,第二位字符“b”的next值是1,第一位是“a”与第二位字符不相等,所以newnext[2]=next[2]=1;
(3)、当j=3时,第三位字符的next值为1,与第一位字符“a”相等,所以newnext[3]=newnext[1]=0;
(4)、当j=4时,第四位字符“b”的next值为2,与第二位字符“b”相等,所以newnext[4]=newnext[2]=1;
(5)、当j=5时,第五位字符“a”的next值为3,与第三位字符“a”相等,所以newnext[5]=newnext[3]=0;
(6)、当j=6时,第六位字符“a”的next值为4,与第四位字符“b”不相等,所以newnext[6]=next[6]=4;
(7)、当j=7时,第七位字符“a”的next值为2,与第二位字符“b”不相等,所以newnext[7]=next[7]=2;
(8)、当j=8时,第八位字符“b”的next值为2,与第二位字符“b”相等,所以newnext[8]=newnext[2]=1;
(9)、当j=9时,第九位字符“a”的next值为3,与第三位字符“a”相等,所以newnext[9]=newnext[3]=0;
newnext数组值的计算代码如下:
//改进后的KMP算法的next数组值的计算
void Getnewnext(char*T,int *newnext)
{
int i=1;
int k=0;
int length=strlen(T);
newnext[1]=0;
while(i<length){
if(k==0||T[i]==T[k]){ //T[i]表示后缀的单个字符,T[k]表示前缀的单个字符
i++;
k++;
if(T[i]!=T[k]){ //若当前字符与前缀字符不同,
newnext[i]=k; //当前的k为newnext在i位置的值
}else{
newnext[i]=newnext[k];//如果与前缀字符相同,则将前缀字符的newnext值赋给newnext在i位置的值
}
}else{
k=newnext[k]; //若字符不同,则k值回溯
}
}
}