KMP字符串匹配

以下内容参考了这个文章:http://www.inf.fh-flensburg.de/lang/algorithmen/pattern/kmpen.htm

这两天重新看KMP,发现问题还蛮多的。以前知道KMP怎么用,复杂度如何,但是写起来总是做不到bug-free。把这两天重新看的,写的关于KMP的东西,拿出来总结一下。

首先介绍下KMP:KMP的全名是Knuth-Morris-Pratt algorithm,好吧,其实就是三个作者的名字加起来。它是用来做什么的呢?字符串匹配。下面是摘下来的一段介绍:

“The algorithm of Knuth, Morris and Pratt [KMP 77] makes use of the information gained by previous symbol comparisons. It never re-compares a text symbol that has matched a pattern symbol. As a result, the complexity of the searching phase of the Knuth-Morris-Pratt algorithm is in O(n).

However, a preprocessing of the pattern is necessary in order to analyze its structure. The preprocessing phase has a complexity ofO(m). Since m<=n, the overall complexity of the Knuth-Morris-Pratt algorithm is in O(n)”

  对于暴力的做法,我们枚举母串的所有字符作为匹配的开始位置,对模式串进行匹配,假设母串长度n,模式串长度m,时间复杂度是O(nm)的。在这个暴力的过程中,我们每次匹配都“从零开始”,忽略了前面的匹配结果给我们带来的信息,KMP算法巧妙的利用了前面的匹配结果,最大限度的避免重复的匹配,使得算法在最坏情况复杂度为O(n+m)。下面具体讲讲KMP算法。

KMP的第一步是利用O(m)时间,对模式串进行一个预处理。这一步完全是对于模式串的,跟母串没有关系。前面提到,暴力做法慢的原因在于,当我已经知道模式串的前k个字符与母串匹配而第k+1个字符不匹配时,我们在母串中选用下一个字符作为匹配开始节点,对模式串从头开始匹配。但是在前面一次失败匹配中,我们已经知道有k个字符可以匹配,如果这k个字符符合某些特定条件,我们是不是不需要重新开始匹配,而只需要把模式串的匹配位置往前回滚几个字符呢?答案当然是肯定的,下面先给出一个例子说明:

在上面的例子中,'d'出现了不匹配,而前面五个字符都是匹配的,按照暴力的做法,现在应该把模式串往后推一个字符,重新开始匹配。KMP算法注意到,对于模式串"abcabd",现在'd'跟母串'5'的位置出现了不匹配,而“abcab“是匹配成功的,那我们完全可以把模式串往后”推“不止一个字符,我们把它往后推三个字符,就像例子描述的,这样,母串'5'的位置跟模式串的'c'对在一起了,而前面的"ab"仍然是匹配成功的,也就是说,用暴力做法,模式串应该往后移动一个字符,重新匹配,但KMP缺移动了多个字符,而且也不需要重头匹配了。


为什么我们发现可以移动三个字符呢?因为对于匹配成功的串”abcab“最长的”前缀等于后缀“的串是”ab“(这里不把字符串本身当做自己的前后缀,不然最长的”前缀等于后缀“一定是串本身)。我假设大家都知道什么是前缀和后缀了(如果不知道,google一下就有了)。想象一下,既然对于现在成功匹配的串长度是len1,如果我知道最长的”前缀等于后缀“的长度为len2,那么我就可以保证把模式串往后推len1-len2个字符之后,模式串还是可以成功匹配到母串的当前位置的(在这个例子中,len1是5,len2是2,母串的当前位置是5)。为什么能保证到?因为前缀等于后缀。把模式串往后推了在匹配,就相当于拿前缀跟后缀匹配,所以,你懂的。

基本上kmp就是这个思路,我们知道当前匹配成功的字符串长度,又知道这个匹配成功的字符串的最长的”前缀等于后缀“的长度,我们就可以知道,我们应该把模式串后移多少个字符去匹配。要找到这个最长的”前缀等于后缀“的长度,KMP也告诉你了,可以用O(m)的复杂度来实现,一般来说,把这个结果存在一个叫next的数组里面。代码如下:

vector<int> GetNext(char* p){
        vector<int> next;
        next.push_back(-1);
        for(int i=0;p[i];i++){ //求next[i+1]
            int tmp = next[i]; 
            while(tmp!=-1&&p[i]!=p[tmp]) //找出一个最长的”前缀等于后缀“的长度
              tmp = next[tmp];   
            next.push_back(tmp+1);//如果p[i]==p[tmp],说明最长的长度是tmp+1
        }
        return next;
    }

为了更直观的看看next存了什么,用上面给的example,当模式串是”abcabd“时,对应的next为:


模式串: a    b    c    a    b    d

next:        -1   0    0    0    1    2    0    


next指针告诉了我们,当模式串的第i个位置出现mismatch时,模式串应该跳回到那个位置(i=next[i])。也就是说,模式串后移了i-next[i]位。next[i]保存的是串p[0:i-1]最长的”前缀等于后缀“的长度。next[0] = -1可以很好的作为一个标志位,说明没有前后缀符合要求,简化编程难度。


拿到next数组之后,我们就需要利用next数组进行匹配,由于next数组告诉了我们当出现mismatch时要做的事情,我们只需要当match时,两个串往后匹配,出现mismatch时,模式串的指针返回到next对应的位置即可。当模式串成功匹配到结束符时,说明找到了一个符合要求的子串。code:

char* Search(char* s,char* p,const vector<int>& next){
        int pos = 0;
        while(*s){
            if(pos==-1||*s==p[pos]){//pos==-1或者匹配时,两个指针后移
                s++;
                pos++;
            }else{    //否则模式串从pos走回到next[pos]
                pos = next[pos];
            }
            if(-1!=pos&&!p[pos]) return s-pos; //匹配成功
        }
        return NULL;
    } 

这个就是KMP的大体框架了,网上也有不少的代码,写法不一定相同,但是思路应该是差不多的。

最后就是证明一下为什么这个算法复杂度是O(n+m)的了(在GetNext中我们明明有两个循环啊~~):

一. 首先看GetNext为什么是O(m)复杂度的:

首先,next[i+1]<=next[i]+1。这个用反证法很容易就证明到了,如果next[i+1]>next[i]+1,那么我取一个长度为next[i+1]-1的前缀,这个前缀自然可以作为串[0,i-1]的后缀。那么,next[i]就等于next[i+1]-1,这样就矛盾了。

其次,对于每次while循环里面做的tmp=next[tmp],tmp的值至少减少1。这个从next本身的定义就可以推出,因为next[i]保存的是串p[0:i-1]最长的”前缀等于后缀“的长度,并且我说了,这里的前后缀不能等于串本身,所以串的前后缀的长度自然小于串本身了,所以next[tmp] < tmp。

    最后,next[i]的值总是>=-1。这个结论比较容易得出了。

        那么,程序的next从-1开始,每一次的for循环,next最多加1,也就是说next的最大值不超过m-1。另外,如果总的运行while循环的次数不能超过m,否则,next的值将会小于-1(这里得看到,每次tmp的值都初始化为next[i])。

所以,GetNext的while循环次数最多是m次,所以复杂度还是O(m)。


二. Search的复杂度是O(n)的:

   首先看到,跳出循环的条件是*s等于0,那么,如果每次循环都s++的话,循环最多运行n次。这里的问题在于,当进入else的时候,并没有执行s++,那么,如果else运行了很多次,那么复杂度就可能不是O(n)的,我们需要证明的是,整个while循环运行过程中,else不会被进入超过n次。证明思路跟上面差不多,由于上面已经说了pos=next[pos]使得pos至少减1,而pos不会小于-1(因为等于-1时,if的条件就成立了),然后,每次进入if时,pos只会加1,这就说明,整个循环中,进入else的次数不会多于进入if的次数+1(因为pos初始化为0)。这样,while循环最多执行2n次,复杂度是O(n)。


整个算法到这里就差不多了,贴一个code,是leetcode上面的题,标准的KMP题目:


Implement strStr()


参考代码:

class Solution {
public:
    vector<int> GetNext(char* p){
        vector<int> next;
        next.push_back(-1);
        for(int i=0;p[i];i++){
            int tmp = next[i];
            while(tmp!=-1&&p[i]!=p[tmp])
              tmp = next[tmp];
            next.push_back(tmp+1);
        }
        return next;
    }
    char* Search(char* s,char* p,const vector<int>& next){
        int pos = 0;
        while(*s){
            if(pos==-1||*s==p[pos]){
                s++;
                pos++;
            }else{
                pos = next[pos];
            }
            if(-1!=pos&&!p[pos]) return s-pos;
        }
        return NULL;
    } 
    char *strStr(char *haystack, char *needle) {
        if(!*needle) return haystack;
        vector<int> next = GetNext(needle);
        return Search(haystack,needle,next);
    }
};


另外要提一下,KMP可以支持wildcard,也就是说如果母串和模式串中含有类似'.'这样,代表可以匹配任何字符的通配符,KMP也可以处理,复杂度不会有变化。其实思路很简单,我们在判断字符是否相等时,用的是*s==*p,如果有通配符,我们只需要把这个等价条件改成*s==*p||*s=='.'||*p=='.'即可。为了通用一点,可以传入一个cmp函数来处理比较逻辑,不改变代码其他部分框架:

    bool compare(char c1,char c2){
	return c1=='.'||c2=='.'||c1==c2;
    }
    vector<int> GetNext(char* p,bool (*cmp)(char,char)){
        vector<int> next;
        next.push_back(-1);
        for(int i=0;p[i];i++){
            int tmp = next[i];
            while(tmp!=-1&&cmp(p[i],p[tmp]))
              tmp = next[tmp];
            next.push_back(tmp+1);
        }
        return next;
    }
    char* Search(char* s,char* p,const vector<int>& next,bool (*cmp)(char,char)){
        int pos = 0;
        while(*s){
            if(pos==-1||cmp(*s,p[pos])){
                s++;
                pos++;
            }else{
                pos = next[pos];
            }
            if(!p[pos]) return s-pos;
        }
        return NULL;
    } 
    char *strStr(char *haystack, char *needle) {
        if(!*needle) return haystack;
        vector<int> next = GetNext(needle,compare);
        return Search(haystack,needle,next,compare);
    }


  • 3
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值