模式匹配和KMP算法详解

模式匹配和KMP算法

模式匹配

  • 字串的定位操作通常称作串的 模式匹配

  • 最基本的模式匹配算法就是从主串(S)的第一个字符开始,逐字符与模式串(T)进行匹配。这种匹配方式简单但费时,下面给出这种匹配算法的简单实现

    int patternMatch(char *S,char *T,int pos) {
        //返回字串在主串第pos个字符之后的位置,若不存在,则返回0
        int lenS = strlen(S);//储存字符串S和T的长度
        int lenT = strlen(T);
        if (pos<1 || pos>lenS) return 0;
        int i,j,loc=0;
        for( i= pos - 1;i<lenS - lenT;i++){
            for( j = 0;j<lenT;j++){
                if(S[i + j] == T[j]) continue;
                else break;
            }//for
            if (j == lenT) {
                loc = i + 1;
                break;
            }//if
        }//for
        return  loc; 
    }//patternMatch
    
  • 根据模式匹配算法的两重循环可知,模式匹配算法的时间复杂度为 O(n∗m)O(n*m)O(nm)

    其中,n和m为主串和模式串的长度。
    上面的时间复杂度是模式匹配算法的最坏情况,在这种情况下,主串除了首尾的几个字符,几乎每个字符都有可能与模式串的匹配m次,这在很多情况下效率是极低的。
    那么,我们有没有可能让主串的每个字符基本只和模式串匹配一次呢?
    这就得提到KMP算法了

KMP算法 (The Knuth-Morris-Pratt Algorithm)

  • KMP算法是上面模式匹配算法的改进版,其优势在于可以在 O(n+m)O(n+m)O(n+m) 的时间数量级上完成模式匹配的操作。

    我们可以发现上面的算法在每一次匹配时,无论上一次匹配了几个字符,i都是逐一递增的,再叠加上j的注意递增,整个算法的时间复杂度就被大大提高了。就比如下面这个例子:
    模式匹配示例

    首先我们要明确,在模式匹配中主串S的字符对于我们来说就像一个个黑盒,我们是拿T中的一个个字符去和S串比对。

    从这个角度出发,我们可以发现在这个示例中,模式串的最后一个字符与主串不匹配,且S[9]~S[11]是什么字符我们是不知道的。

    那么根据一开始的模式匹配算法,我们会将iS[0]移动到S[1]然后重新开始与T逐个匹配,但这种效率是极低的,因为我们发现一开始ab就不匹配了,直到i移动到3时才重新匹配上。

    可是通过第一轮匹配,我们已经知道S0~7T就是匹配的,而且如果我们把T的字符当作已知,我们可以发现S[1]S[2]T[0]不匹配,同时,我们惊奇地发现,S[3]~S[7]T[0]~T[4]是相同的(为什么不是到S[8]T[5]为止呢?因为此时,我们假设S未知,T已知,当两个字符匹配时我们能知道S串的字符就是相对应的T的字符,但如果不匹配,我们只能知道两个字符不相同,而无法确定S相应的字符是什么),也就是说,我们可以把T串直接向右平移三个字符然后让T[5]去和S[8]配对就行了,在实际的算法中,我们直接将j回溯到T[5]然后接着配对,这样就可以避免主串的过多的无用的重复匹配,那么我们要怎么知道j要回溯到之前的那个位置呢?

    不难发现,j可回溯的部分已经和S配对上了,也就是说这部分ST是完全相同的,也就是说我们可以将这部分的S直接看作相应部分的T那么先让我们一步步来试试。我们将每一个j所回溯到的位置设为kT中每一个字符所对应的K就组成了一个数组,我们不妨称为next。并且我们设next[0](也就是示例的a对应的k)为-1.因为每当j回溯到首字符时我们就知道,在这个部分,T没有任何一个子串是能和主串对应的,在这种情况下我们就要从主串的下一个字符开始重新匹配。于是,我们可以得到下图所示的next数组:

    next示例

    根据图示和刚刚的分析,我们可以得到每个j所对一个的k需要满足的要求是:

    1. T[0]~T[k-1]要和T[j-k]~T[j-1]对应相等

    2. 不存在更大的k'使得T[0]~T[k' - 1]T[j-k']~T[j-1]对应相等(因为如果存在这样的k',那么显然选择k'会使得我们后续需要匹配的字符数更少)

    应用到算法中,我们可以用数学归纳法的思想来理解。

    首先,假设对于T[j-1],有next[j] = k-1 使得上述条件成立(当然j,k>0),那么对于T[j],我们有以下两种情况:

    1. T[j-1] == T[k-1]

      此时依照假设内容,T[0]~T[k-2]已经是T[j-1]满足要求的最长串,且T[j-1]还能和T[k-1]配对,那么T[0]~T[k-1]一定就是T[j]满足要求的最长串
      因此在这种情况下,我们可以得到:

      next[j] = k;
      

      证明图示如下:
      图解要求1

      这是对要求1的图解
    2. T[j-1] != T[k-1]

      这种情况就有些令人头大了,吗?来,我们慢慢分析。如果想要next[j]某个k,依据第一种情况,我们会先判断T[j-1]是否等于T[k-1],现在这个条件显然不成立,但是,对于T[k-1]我们还可能有一个next[k-1],欸,我们不妨假设为k'-1,那我们就很自然地设想如果这个k'-1对应的T[k'-1]会等于T[j-1],那么k'会不会就是我们想要得到的k呢?

      对于这个设想,要求1肯定是满足的,那么会满足要求2吗?答案是显然的。如果一定要证明,那我们可以采用反证法。

      假设存在某个p (k'<p<k)满足所有要求,那么它就是j所对应的next[j]的值,于是我们可以推出T[0]~T[p-1]就会与T[j-p]~T[j-1]对应相等,由此我们还可以得出T[0]~T[p-1]还会等于T[(k-1)-p]~T[k-2]

      由于k'是小于p的,因为k'T[k-1]所对应的next的值,这也就意味着不存在比k'还长的串能够满足要求1.但根据我们刚刚的分析,对于p要求1显然是满足的。矛盾产生,因此k'就是我们想要得到的k

      由此,如果k'还不成立,那么我们就继续往前回溯,直到相应next值为-1(也就是回到了首字符,表明没有可以匹配的子模式串),那就从主串的下一个字符开始重新匹配。
      证明图示如下:

      图解要求2

      这是对要求二的图解

    至此,我们已经将求next数组的证明过程理清楚了,那么具体应该如何实现呢?程序应该怎么写呢?其实不过就是将我们刚刚的证明过程一步步翻译成代码而已。下面我们来看具体实现。

求next函数值的算法

```c
void get_next(char *T,int next[]){
    next[0] = -1;//将第一个字符的next值设为-1
    int k = -1,j = 0;//这里的k就是每个T[j]所对应的next的值
    while(j<strlen(T) - 1){
        if (k == -1 || T[j] == T[k]) {//这里if的条件就对应我们所分析的
                                    // T[j-1] == T[k-1]的情况
                                    //k==0是初始条件
                                    //为了让循环能从第一个字符开始进行
            j++;                    //根据分析,满足条件时T[j]的next值即为k
            k++;                    //这三行代码即实现这一过程
            next[j] = k ;
        }//if
        else k = next[k];           //这行代码就对应了上面T[j-1] != T[k-1]的情况
                                    //整个思路是不是就非常清晰了?
    }//while
}//get_next
```

KMP算法主体

有了next数组之后,我们就可以开始实现整体的KMP算法了,只要在最开始的模式匹配算法的基础上稍加改进就行
下面是具体实现:

int KMP(char *S,char *T,int next[]){
    //用KMP算法返回成功匹配串的首字符的位置,不成功则返回-1
    int i = 0,j = 0;
    int lenS = strlen(S) - 1,lenT = strlen(T) - 1;//注意字符串末尾的0 
    while(i < lenS && j < lenT){
        if(j == -1 || S[i] == T[j]) {
            //当成功匹配,或者j回溯到首字符时,继续匹配下一字符
            i++; 
            j++;
            }//if
        else {
            j = next[j];//否则j回溯
        }//else
    }//while
    if (j >=lenT) return i - lenT + 1;//如果j大于T的长度,说明完全匹配
                                      //此时返回首字符的位置
    else return j;
}//KMP

现在再看这个算法是不是就显得特别容易特别简短了?

O(m+n)O(m+n)O(m+n)?

看到这里,你可能会有疑问,为什么说KMP算法的时间复杂度是O(m+n)O(m+n)O(m+n)?对于KMP算法来说,i确实是不用回溯了,但j不是还要回溯吗?j每次回溯多少怎么确定呢?接下来我们来讨论这个问题。

对于最坏的情况来说,就是j每次都回溯一个字符,直到回溯到首字符为止,那让我们再看一眼KMP算法。在每单轮匹配中,我们假设i往前移动了m个字符,那么此时模式串也将成功匹配m个字符,那么在最坏的情况下,j就是最多回溯m个字符。

由于i是不回溯的,因此i的时间复杂度显然是O(n)O(n)O(n),那么对于j来说最多也就是回溯n次,这就是j在KMP算法中的最坏情况,对于j的回溯来说,复杂度也是O(n)O(n)O(n),现在,让我们回到算法的循环中,我们可以看到,在每一轮循环中,要么ij同时前进,要么j回溯,这两种情况并没有嵌套关系,因此KMP算法的整体复杂度就是 O(n)+O(n)=O(n)O(n) + O(n) = O(n)O(n)+O(n)=O(n) ,不要忘了,在进行KMP算法之前,我们还需要求得next数组,对于这个函数来说,求复杂度就很简单啦,因为我们是对每个模式串的每个字符求值,不存在什么回溯啦乱七八糟的,因此复杂度就是很简单的O(m)O(m)O(m)于是,整体加起来,完整的KMP算法的复杂度就是O(n+m)O(n+m)O(n+m)

让我们开看个具体的例子:
KMP算法示例

这个例子很好地展现了最坏的情况。依照KMP算法,一开始,ij一起从0移动到4发现S[4]T[4]不匹配,此时,j会按照next数组一个个往前移,然而最多也只会移动四个字符回到T[0]然后从S[5]开始重新匹配,这就很好地印证了我们求得的时间复杂度。

BUT!

作为一个聪明的人,你会发现这个现在的匹配方式在这种情况下挺蠢的,明明模式串全是a,最后一个不匹配那显然前面所有的都不匹配啊,因此,我们还可以在现在算法的基础上继续改进next函数。

复杂度还能降低吗?

依据上面的例子,我们很自然地发现,在发现T[j]不匹配的时候,如果T[j]T[next[j]]相等,那就没有匹配的必要了,显然不匹配。

在这种情况下我们就得继续回溯,直到发现next不与T[j]相等或直到为-1那么就继续往下匹配就行了,应用到具体的算法中,我们可以将next函数改进为:

void nextval(char *T,int nextval[]){
    int j = 0,k = 0;
    nextval[0] = -1;
    while(j<strlen(T)){
        if (k == 0 || T[j] == T[k]){
            j++;
            k++;                        //到目前为止一切照旧
            if(T[j] != T[k]) nextval[j] = k;
                //此时,我们要多判断一下T[j]是否等于T[k],
                // 如果不相等,那么一切照旧,
                // 当S和T不匹配的时候,
                // 自然移动到k处继续尝试
            else nextval[j] = nextval[k];
                //如果相等,那就直接等于nextval[k]
                //并且,由于i是逐渐增加的,
                //在算法中我们其实不用考虑j到底和前面的几个k相等
                //因为找到第一个相等的值的时候nextval[j]自然就变成了nextval[k]
                //如果之后还有和T[j]相等的T[j'],
                //那么当nextval[j']=nextval[j]时,
                //其值自然也就直接等于nextval[k],而不用回溯两次
        }//if
        else k = nextval[k];//此步依然照旧
    }//while
}//nextval

到这里,我们的KMP算法总算是完美结束了AAAAAAAA!

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值