【注】本文参考了数据结构和算法方面的书籍和网上资料。
字符串模式匹配有着广泛的应用,如求最大公共子串、最长回文字符串、L-Gap、数据压缩、DNA序列匹配等问题。所谓模式匹配就是在目标字符串中寻找字串的过程,要寻找的字串即为模式。
目前主流的模式匹配算法不外乎BF、KMP、BM等等。本小节主要讨论前两个算法。
BF(Bruce Force)算法可以说是模式匹配算法中最简单、最容易理解的一个。原理很简单。其基本思想是从主串的start位置开始与模式串进行匹配,如果相等,则继续比较后续字符,如果不相等则模式串回溯到开始位置,主串回溯到start+1位置,继续进行比较直至模式串的所有字符都已比较成功则匹配成功,或者主串所有的字符已经比较完毕,没有找到完全匹配的字串,则匹配失败。
该算法较为简单,代码如下:
//start 为从第start位置的字符开始比较,默认为0
int BF(char s[], char d[], int start = 0)
{
char *p = s + start; //记录开始比较的位置
int index = start - 1; //记录位置,以便成功时返回字串在主串的位置
char *q = d;
char *tmp;
while (*p != '/0')
{
tmp = p;
++index;
while (*q != '/0' && *tmp != '/0' && *tmp == *q)
{
++tmp;
++q;
}
if (*q == '/0') //匹配成功,返回结果
return index;
else //主串和模式串回溯
{
++p;
q = d;
}
}
return -1; //匹配失败
}
下面开始讨论比较麻烦的KMP算法。
KMP算法是有Knuth、Morris、Pratt提出的。目的是为了解决BF算法中不必要的回溯。下面着重讨论一下其原理。
假设主串为T,模式为P。BF算法的匹配过程如下:
首先P中的第一个字符与T中的第一个字符比较,
ToT1T2……Tn
P0P1P2……Pm,假设在T2和P2匹配时失败,则可以知道T0T1一定与P0P1相同。那么T可以等价的改为P0P1T2T3……Tn。则下次比较为:
P0P1T2……Tn
P0P1P2……Pm。可以看到这次比较时从P1和P0比较开始的。同样假设是在Tk和Pk-1比较时失败,则T1T2T3……TkTk+1……Tn可以等价的替换为P0P0P1P2……Pk-2Tk……Tn,下次匹配为:
P0P0P1P2……Pk-2Tk……Tn
P0P1P2……Pk……Pm。可以看到前面一大段字符基本上是P在和自己匹配,而如果可以事先获得P中字符之间的某种关系,则这些中很多不必要的匹配都可以省去了,可以极大的提高匹配的效率。例如T=babaaac,P=aac,在第二轮匹配时,T3=b与P2=a匹配失败,按照BF算法下次有应该是T3和P1开始匹配,但是我们看以看出P1和P2是相等的,既然P2和T3不相等,那么也就没有比较继续T3和P1的比较了,这样就避免了很多不必要的比较。而这种字符之间的关系就是大家非常熟悉的next数组。
有了next数组之后,如果Pj与Ti匹配失败后不是像BF算法那样P回溯到开始,T指针后移一位继续比较,而是利用P字符之间的关系,T指针不回溯,模式串P像右滑动一段距离,Pnext[j]继续和Ti匹配,这样就省去了很多不必要的匹配和回溯,提高了效率。而next数组中的值也就是Tj匹配失败是下一次与主串进行匹配的模式串的字符下标。此时模式匹配算法KMP的代码如下:
//s为主串,d为模式串,next为next数组,lens为s的长度,
//lend为d的长度
//start为s中开始比较的字符下标
int kmp(char *s, char *d, int *next, int lens, int lend, int start = 0)
{
char *p = s + start; //p指向T中开始匹配的位置
char *q = d; //q指向P的起始位置
int i = 0, j = 0;
while (i < lens && j < lend) //放置越界访问
{
// j=-1表示P已经回溯到起始位置但是还是不能与主串中的Ti
// 匹配成功,则T指针后移一位,继续匹配
if (j == -1 || *(p + i) == *(q + j))
{
++i;
++j;
}
else //匹配失败且P指针没有回溯到起始位置,
{ //则用next[j]继续与Ti进行匹配
j = next[j] ;
}
}
if (*(q + j) == '/0') //q指向P的结束位置,表示匹配成功
return i - j;
else //匹配失败
return -1;
}
这里与很多书上介绍的KMP算法不太一致,C语言中字符串的第一个位置并不是保存的字符串的长度,故这里对算法稍加改造,next数组与书中的next数组的求法整体相比小1,即next[0]等于-1而不是0,后面的值也较之小1。这样可以很方便的在C和类C的语言中使用。
这个算法本身并不难理解,难理解的就是next数组的意义和其求法,下面着重讨论这个问题。
同样假设主串为T,模式串为P,在Ti和Pj进行匹配时匹配失败。则可以得知:P0P1P2……Pj-1=Ti-jTi-j+1……Ti-11,
同时假设下次应该由Pk与Ti开始进行下一轮的匹配。则可以得出:
P0P1P2……Pk-1=Ti-kTi-k+1……Ti-12,而且可以肯定k<j;故P0P1P2……Pk-1为P0P1P2……Pj-1的一个字串,可以得出下面的结论:
P0P1P2……Pk-1=Pj-kPj-k+1……Pj-1
这也就是数据结构方面书籍给出的结论,上面这个式子在KMP算法中是很重要的,其意思就是P的这部分子串中前一部分和后一部分是相等的,这意味着如果在Pj匹配失败,则靠近Pj的这k-1个字符和P开始处的k-1个字符是相同的,既然能够匹配到Pj,那么靠近Pj的这k-1个字符是匹配成功的,而由于P开始处的K-1个字符与其相同,那么如果拿这些字符去与主串中的这些字符匹配肯定是能够匹配成功的,也就没有必要进行匹配了,可以直接从Pk开始匹配,这样既可以说是P向右滑动了一段距离避免了T指针的回溯,也可以简单的说是利用P字符按之间的关系避免了P与T之间不必要的回溯。
在理解了next数组的来历和用途后,下面面临的一个总要问题就是next数组的求法了。下面给出next数组的官方定义:
next[j]=0,当j=1时
next[j]=max,{ k|1<k<j,且’p1p2…pk-1’=’pj-k+1,pj-k+2…pj-1’}
next[j]=1,其他情况
但是上面提到为了便于在C语言中实现,对next数组的求法进行了修改,其值为标准next数组的值-1,故next[0] = -1;
next数组展现的是模式串P自身字符之间的一些规律,故其求法相当于自己对自己进行模式匹配。对于本例,next[0]=-1,next[1]=0基本上是固定的next[0]=-1表示已经回溯到开始,下次比较时从主串下一个字符开始继续比较,i=i+1,j=0。后面nex值的求法与KMP算法类似。
假设此时已经求的next[j]的值,下面说明如何求得next[j+1]。此时若Pk=Pi,则有p1…pk-1pk=pj-k+1…pj-1pj,则说明next[j+1]=k=next[j]+1。若pk≠pj,可把求next值问题看成是一个模式匹配问题,整个模式串既是主串,又是子串。继续比较Pj和Pnext[k],重复上面的步骤,若一直无法找到匹配的k,则next[j+1]=0(按照上述定义应该为1,这里next值都减一,便于处理)。
具体代码如下:
//s为模式串,len为其长度,d用来存储next值
void get_next(char *s, int len, int *d)
{
int i = 0, j;
j = d[0] = -1;
while (i < len - 1)
{
while (j > -1 && *(s + j) != *(s + i))
{
j = d[j];
}
++i;
++j;
d[i] = j;
}
}
至此KMP的问题基本上解决了,但是按照大部分书上的介绍,还有一个问题,kmp算法同一存在不必要的回溯。例如对于T=aaabaaaab,P=aaaa的模式匹配,在本文中next值为-1,0,1,2,当T3=b与P3=a比较失败后,根据next值,T3需要与P2比较,但是可以看出P2=P3,因此这个比较是没有必要的,可以直接与Pnext[3]比较,同时Pnext[3]与P3也相同,故据此回溯,应该直接与P0J进行比较。针对此问题,人们又提出了KMP算法的改进。即在上面求next[i]时,若p[i]=p[j],则next[i]=next[j]。
if(T[i] != T[j]) nextval[i] = j;
else nextval[i] = nextval[j];
以上描述了KMP算法,下面是BM和KMP的整体代码: