欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

KMP匹配算法源码

程序员文章站 2022-06-06 22:39:24
...

        模式匹配问题,即字符串的匹配,int index(String t,String p),求p在t中第一次出现的位置。如何高效地匹配?先来讨论朴素的回溯的模式匹配。例如,设t=“abbaba”,p=“aba”,t的长度为6(plen=6),t的长度为3(tlen=3)。匹配过程如下:

KMP匹配算法源码

        算法如下:

int index(string p, string t)
{
    int i = 0, j = 0;           //i,j分为p,t串当前字符的下标
    while(i<p->n && j<t->n){    //反复比较
        if(p->c[i] == t->c[j]){
            i++; j++;           //继续匹配下一个字符
        }
        else{
            j = j - i + 1;      //p,t串的i,j回溯,即重新开始下一次匹配
            i = 0;
        }
    }
    if(i >= p->n)
        return(j - i +1);       //匹配成功,返回p中第一个字符在t中的序号
    else
        return 0;               //匹配失败
}

       这种算法效率不高,在最坏的情况下,每趟比较都在最后出现不等,最多比较 tlen-plen+1 趟,总比较次数为 plen×(tlen-plen+1),由于在一般情况下plen<<tlen,所以算法的运行时间为O(plen× tlen)。

        对于上图的匹配过程,由(a)可知:p0=t0,p1=t1,p2!=t2,

由p0!=P1可知p0!=t1,将p右移一位后(b)的比较一定不等;

由p0==p2可知p0!=t2,将p再右移一位后(c)的比较一定不等;

        因此,由(a)便可直接将p右移3位跳到(d),从p0和t3开始作比较,很快可以匹配成功。这样的匹配过程对字符串 t 就消除了回溯。

        这种快速的算法也就是,在右移若干位后,立即用p串中的一个新的字符Pk(k<i)和tj(甚至t(j+1))继续作比较。Knuth等人发现这个k值不仅存在,而且仅依赖于p串本身,与t串无关,因此可以在与任何串匹配前计算出k值。

        假设k值已经存在,存放在数组next中,next[ i ] 表示与pi对应的k值。意义就是,一旦匹配过程中发现pi!=tj,可用p中以next[ i ]为下标的字符Pnext[ i ] 与tj作比较。特别地,当p中任何字符都不能和tj比较时(此时next[ i ]==0),直接从t(j+1)和p0作比较。将上面的算法部分改变:

    while(i<p->n && j<t->n){    
        if(i == -1){
            i++; j++;            //i变为0,即t(j+1)和p0作比较
        }
        else if(p->c[i] == t->[j]){
            i++; j++;
        }
        else
            i=next[i];
    }
可以合并,
 while(i<p->n && j<t->n){    
        if(i == -1 || p->c[i] == t->[j]){
            i++; j++;            //i变为0,即t(j+1)和p0作比较
        }
        else
            i=next[i];
    }
        分析其时间复杂度,由于 j 只增不减,j++最多执行n次,从而相邻的 i++最多也不超多n次;循环中唯一能使 i 减少的语句是 i=next[ i ],而使i=-1,那么下步必定会执行{ i++; j++;},所以 i=next[ i ]语句的执行次数不会超过i++的执行次数加一。因此,该算法的事件代价为O(n)。

        那么怎样计算next数组呢?

        我们发现,在p与任意目标t匹配时,若pi≠tj,则意味着pi之前的字符已与t中对应的字符相等。因此,如下图(a)和(b)是等价的。

KMP匹配算法源码

然后,将p右移i-k位,希望用pk与tj继续比较,这意味着tj以前的比较工作都已全部相等,相当于p0…p(i-1)的一个前缀p0…p(k-1)与它的一个后缀p(i-k)…p(i-1)长度相等。如图,

KMP匹配算法源码

因此,在p0…p(i-1)中,求出其相同的而且是最长的前缀和后缀,设长度为k(0≤k≤i-1),这个值仅仅依赖于p本身,与任何t无关。

①当右移量为i-k时,相同的前后缀恰好对齐,pk与tj对齐。用不着再去比较这一对相同的前后缀;

②当右移量小于i-k时,也用不着去比较,因为根据k的意义,长度大于k的前后缀必定不相等;

③当右移量大于i-k时,又可能错过匹配成功的机会。

      通过上面的分析,可以知道求next数组的关键,就是求出p0…p(i-1)中最大的相同的前后缀的长度。而求出这个长度,也就是判断p0…p(i-2)是否等于p1…p(i-1),如果不等,则继续判断p0…p(i-3)是否等于p2…p(i-1)等等。显然,这实际上还是模式匹配问题。

      作为一种特殊情况,当i=0时,令next[0]=-1,因为p0与tj比较不等,只能用p0与t(j+1)去比较。另外,如果p0…p(i-1)的最大相同的前后缀长度为k,那么p0…pi的最大相同的前后缀长度就为k+1。加上有next[0]=-1的基础,就可以依次计算出next[1],next[2]…next[n-1]。在计算next[i+1]时,充分利用已经求出的next[0],next[1]…next[i]的值,使得计算next[i+1]的过程有点类似于前面的无回溯的模式匹配问题。算法如下:

void makeNext(PSeqString p, int *next)
{
    int i = 0, k = -1;           //初始化
    next[0]=-1;
    while(i < p->n - 1){         //计算next[i+1]
        while(k >= 0 && p->c[i] != p->c[k])
            k=next[k];
        i++; k++;
        next[i] = k;
    }
}
算法改进,试想,按上面算法求出k后,若pk=pi,则当发现pi≠tj时,用pk和tj比较,也必然不相等,应该再右移,用Pnext[i]与tj比较。既然预先知道这种情况,理应预先避免走这个弯路,使得next[ i ]的值尽可能变得更小,以便在匹配时更多地右移。如此,当发现pi≠tj时,则右移i+1位,用p0与t(j+1)开始比较。即将 next[i] = k 改为:
if(p->c[i] == p->c[k])
        next[i] = next[k];
    else
        next[i] = k;

        算法分析,从表面上看,有两层循环,每重循环最多执行m次,最大的运行时间可能达到O(m^2),但仔细分析,因为每执行一次最外层循环,i 严格增加1,所以最外层循环正好执行 m-1 次;另外k值从-1开始,执行 k+m-1 次(i++;  k++;),并且只有在这一语句中k被增值。在内层循环中,语句k=next[k] 至少使d减少1,所以整个算法中,这个语句执行次数不可能超过m-1次(否则k将小于-1,不可能),所以最内层循环的执行次数最大为 m-1 。

        因此,该算法的时间代价为O(m),与无回溯的模式匹配模式合在一起,即用长度为m的模式串p与长度为n的目标串t进行匹配,所需的时间为O(m+n),算法如下:

int index(PSeqString p, PSeqString t, int *next)
{
    int i = 0, j = 0;           //i,j分为p,t串当前字符的下标
    
    while(i<p->n && j<t->n){    
        if(i == -1 || p->c[i] == t->[j]){
            i++; j++;            //i变为0,即t(j+1)和p0作比较
        }
        else
            i=next[i];
    }
    
    if(i >= p->n)
        return(j - i + 1);       //匹配成功,返回p中第一个字符在t中的序号
    else
        return 0;               //匹配失败
}

void makeNext(PSeqString p, int *next)
{
    int i = 0, k = -1;           //初始化
    next[0]=-1;
    while(i < p->n - 1){         //计算next[i+1]
        while(k >= 0 && p->c[i] != p->c[k])
            k=next[k];
        i++;
        k++;
        if(p->c[i] == p->c[k])
            next[i] = next[k];
        else
            next[i] = k;
    }
}

        当n>>m时,该算法的优越性更显然,特别是当一个模式p被反复使用时,只要花一次O(m)的时间代价计算next数组,以后的每次匹配只要用O(n)的时间;但是,当n和m相差不大时,并且只匹配一次就能成功时,朴素的回溯的算法更快。

        下面是一个例子的完整代码(部分变量储存方式已改变):

#include <iostream>

using namespace std;

int index(string p, string t, int lenP, int lenT, int *next)
{
    int i = 0, j = 0;           //i,j分为p,t串当前字符的下标

    while(i<lenP && j<lenT){
        if(i == -1 || p[i] == t[j]){
            i++; j++;            //i变为0,即t(j+1)和p0作比较
        }
        else
            i=next[i];
    }

    if(i >= lenP)
        return(j - i + 1);       //匹配成功,返回p中第一个字符在t中的序号
    else
        return 0;               //匹配失败
}

void makeNext(string p, int lenP, int *next)
{
    int i = 0, k = -1;           //初始化
    next[0]=-1;
    while(i < lenP - 1){         //计算next[i+1]
        while(k >= 0 && p[i] != p[k])
            k=next[k];
        i++;
        k++;
        if(p[i] == p[k])
            next[i] = next[k];
        else
            next[i] = k;
    }
}
int main()
{
    string p, t;
    cout<<"Input P string:\n";
    cin>>p;
    cout<<"Input t string:\n";
    cin>>t;
    int len = p.length();
    int next[len];
    makeNext(p, len, next);
    cout<<"The begin position is "
        <<index(p, t, len, t.length(), next);
}

结果样例:

Input P string:
ANOTONY
Input t string:
abcdefANOTONYghijANTONYsfj
The begin position is 7
模式p在目标t中第一次出现的位置是第7个字符。