KMP算法详解

191 阅读7分钟

前言

KMP算法用于解决字符串匹配问题,现有两个字符串 S 和 P,请求出字符串 P 在字符串 S 中首次出现的位置,通常 S 被称为文本串,P 被称为模式串. 我们规定字符串的下标从 0 开始,文本串 S 的长度为 n,模式串 P 的长度为 m. 在下面这个例子中,模式串 P 在 S 中首次出现的位置是 3.

暴力匹配

解决这个问题最直接的方法就是用模式串 P 的开头依次与文本串 S 中的每一个下标对齐,然后判断 S 和 P 的后续字符是否完全相等. 在最差的情况下,算法的复杂度是 O(nm).

暴力匹配的代码如下,我们根据代码来观察具体的匹配过程.

int match(string s,string p){
    int n=s.length();
    int m=p.length();
    int i=0,j=0;
    while(i<n && j<m){
        if(s[i]==p[j]) { ++i;++j; }
        else { i=i-j+1;j=0; }//当前字符不匹配就把p向右平移1位
    }
    return j<m?-1:i-j;//匹配失败返回-1
}

(1)i=0,j=0,首先比较 S[0] 和 P[0] 发现它们不相等,然后令 i=i-j+1,j=0,此时 i=1,相当于把模式串 P 向右平移一位.

(2)i=1,j=0,此时比较 P[1] 和 P[0] 发现它们相等,然后令 i+=1,j+=1,此时 i=2,j=1,继续匹配后续字符.

(3)i=2,j=1,此时比较 S[2] 和 P[0] 发现它们相等,不断重复第(2)步的过程,直到 i=6,j=5 时发现失配.

(4)令 i=i-j+1,j=0,此时 i=2,相当于把字符串 P 向右平移一位.

(5)后续过程与之前类似,不再一一列举,最终在 i=4 时匹配成功.

暴力匹配算法的缺点主要在于失配时只能将模式串 P 向右平移1位,导致了很多不必要的计算. 在上面的匹配过程的第(3)步,此时的i=6,j=5,我们发现 S[i] 与 P[j] 失配后将 i=i-j+1=2,j=0,然后再去比较 S[2] 和 P[0],这里其实就出现了冗余计算,因为我们可以根据之前的匹配情况推断出 S[2] 和 P[0] 一定不相等,也就是将 P 向后平移1位之后肯定还会失配,原因如下:

我们在进行到第(3)步时就已经知道 S[1]=P[0],S[2]=P[1],而 P[0] 与 P[1] 是不相等的,根据这3个信息就能知道 S[2] 和 P[0] 也一定不相等,因此就没有必要在失配后只把 P 向后平移1位,完全可以直接向后平移2位再去进行后续匹配.

那么这就需要我们根据模式串 P 的一些特点来优化暴力匹配的过程,在失配时尽可能将模式串 P 向后移动更多位,这也是KMP算法的主要思想.

KMP算法

KMP算法专门针对模式串 P,建立了一个 next 数组用来统计模式串 P 的所有前缀字符串中,相同前缀和后缀的最大长度.

前缀和后缀

依旧使用之前的例子,模式串 P=ABCABD,那么 P 的前缀一共有5个,分别是 A,AB,ABC,ABCA,ABCAB,ABCAB. P 的后缀一共有 5个,分别是 D,BD,ABD,CABD,BCABD.

也就是说,前缀可以是任意一个包含原字符串第一个字符的子串,后缀可以是任意一个包含原字符串最后一个字符的子串,但前缀和后缀都不能是原字符串本身.

next数组的含义

这里先说明 next 数组的含义,在3.4中介绍 next 数组的具体求解过程. 模式串 P 的 next 数组如下所示.

next[i] 表示的含义是 P[0~i-1] 这个子串的最大相同前缀后缀长度,特殊规定 next[0]=-1 便于代码编写.

next[1] 表示 P[0]='A' 的最大相同前缀后缀长度,next[1]=0.

next[2] 表示 P[0~1]='AB' 的最大相同前缀后缀长度,next[2]=0.

next[3] 表示 P[0~2]='ABC' 的最大相同前缀后缀长度,next[3]=0.

next[4] 表示 P[0~3]='ABCA' 的最大相同前缀后缀长度,next[3]=1,有相同的前缀和后缀 'A'.

next[5] 表示 P[0~4]='ABCAB' 的最大相同前缀后缀长度,next[4]=2,有相同的前缀和后缀 'AB'.

KMP算法流程

使用KMP算法进行匹配的代码如下,和暴力匹配十分相似. 假设当前文本串 S 匹配到了下标 i ,模式串P匹配到了下标 j,如果 j=-1 或者 S[i]=P[j],则继续匹配后续字符(j=-1 是由于规定了 next[0]=-1 会导致这样的情况,其实仔细观察代码,j=-1 就相当于暴力匹配,将 P 向右平移1位). 否则在失配时,直接令 j=next[j],i不变,这一操作不再让模式串 P 只向后移动 1 位,而是移动了 j-next[j] 位.

int match(string s,string p){
    int n=s.length();
    int m=p.length();
    int i=0,j=0;
    while(i<n && j<m){
        if(j==-1 || s[i]==p[j]) { ++i;++j }
        else j=nxt[j];
    }
    return j<m?-1:i-j;//匹配失败返回-1
}

仍然使用之前的例子,我们根据代码来观察具体的匹配过程.

(1)i=0,j=0,首先比较 S[0] 和 P[0] 发现它们不相等,令 j=next[0]=-1.

(2)此时由于 j=-1,++i,++j,随后 i=1,j=0,相当于 P 向右平移一位.

(3)依次匹配后续的几个字符,直到 i=6,j=5 时发生失配,令 j=next[5]=2.

(4)此时 i=6,j=2,相当于 P 直接向右移动了 4 位!

(5)匹配成功.

next数组的求解

next 数组是通过递推得到的,也就是说,在计算 next[i] 之前,next[0],next[1],...,next[i-1] 都已经被求出,并且可以根据它们来计算 next[i] 的值,具体过程如下.

我们要求的是 next[i],也就是 P[0~i-1] 的最大公共前缀后缀长度.

此时我们已经知道了 next[i-1] 也就是 P[0~i-2] 的最大公共前缀后缀长度,如果 P[next[i-1]]=P[i-1],那么 next[i]=next[i-1]+1,也就是在之前的基础上+1,下面的图片会更加直观.

如果 P[next[i-1]]!=P[i-1],如何继续求解?我们可以令 k=next[i-1],再去查看 next[k] 的值,也就是去查看 next[next[i-1]] 的值,如果我们发现 P[next[k]]=P[i-1],那么 next[i]=next[k]+1,下面的图片会更加直观.

如果 P[next[k]]!=P[i-1],又该如何求解,我们可以继续查看 next[next[k]] 的值,也就是查看 next[next[next[i-1]]] 的值,再去比较对应位置上的字符与 P[i-1] 是否相等. 就这样不断地找下去,如果最终没有找到相等字符,next[i]=0.

下面是求解 next 数组的代码.

int nxt[maxn];

void getnext(string p){
    int m=p.length();
    nxt[0]=-1;
    int i=0,k=-1;
    while(i+1<m){//每次循环求next[i+1]的值,每次计算前,k=next[i]
        if(k==-1 || p[i]==p[k]){
            ++i;
            ++k;
            nxt[i]=k;
        }
        else k=nxt[k];
    }
}

总结

KMP算法的整体时间复杂度是 O(n+m),求解 next 数组的复杂度为 O(m) ,文本串与模式串匹配的复杂度为 O(n). (并不会严格证明......),整体代码如下.

int nxt[maxn];

void getnext(string p){
    int m=p.length();
    nxt[0]=-1;
    int i=0,k=-1;
    while(i+1<m){//每次循环求next[i+1]的值,每次计算前,k=next[i]
        if(k==-1 || p[i]==p[k]){
            ++i;
            ++k;
            nxt[i]=k;
        }
        else k=nxt[k];
    }
}

int match(string s,string p){
    int n=s.length();
    int m=p.length();
    int i=0,j=0;
    while(i<n && j<m){
        if(j==-1 || s[i]==p[j]) { ++i;++j }
        else j=nxt[j];
    }
    return j<m?-1:i-j;//匹配失败返回-1
}