字符串匹配KMP算法

新年第一篇博客也是第一次写关于算法的博客。这两天帮同学看《算法与数据结构》试题,其中涉及到字符串匹配KMP算法,借机重新温习整理了一下,也算有了新的体会与感悟,希望能够讲得清楚。

从字符串匹配讲起

我们都说KMP算法是一种高效的字符串匹配算法,所以首先先定义下字符串匹配问题:给定一个文本串T(也叫主串S),和一个模式串P,现在要找出S中与P匹配的子串,如果存在就返回匹配位置,否则返回-1。
字符串匹配KMP算法

暴力匹配

对于这个问题最简单最直接的想法是:从主串起始位置开始,将模式串与主串逐个匹配,如果匹配,就比较模式串下一个位置和主串下一个位置,如果不匹配,将模式串向右移动一位,然后从头开始匹配。对应上图,其匹配过程如下:
字符串匹配KMP算法
定义指针iijj分别指向主串当前位置和模式串当前位置。如上图,初始时iijj都指向开头,i=0i=0j=0j=0。有S[0]==P[0]S[0]==P[0],所以将iijj都后移一位,继续比较。直至i=3i=3j=3j=3,此时S[3]!=P[3]S[3] !=P[3]
字符串匹配KMP算法
所以将模式串向右移动一位,重新匹配,如下图:
字符串匹配KMP算法
对两种情况进行总结,有:

  1. 如果当前字符匹配成功(即S[i]==P[j]S[i] == P[j]),则i++i++j++j++,继续匹配下一个字符;
  2. 如果当前字符匹配失败(即S[i]!=P[j]S[i] != P[j]),则i=i(j1)i = i-(j-1)j=0j = 0,即ii回溯,jj重置为0,重新开始匹配。

代码描述如下:

int ViolentMatch(char * s, char* p)
{
	int i = 0;
	int j = 0;     // 初始化

    while (i < strlen(s) && j < strlen(p))
    {
        if (s[i] == p[j])      // 当前位置匹配成功
        {
            i++;
            j++
        } 
        else      // 当前位置匹配失败
        {
            i = i - j + 1;      // i回溯
            j = 0;      // j重置
        }
    }
    if (j == strlen(p))     // 匹配成功
        return i -j;
    else
        return -1;
}

在上面的方法中,每次匹配失败ii都要回溯,必然导致匹配效率较低,因此这种方法也被称为暴力匹配。那有没有一种算法,能够让ii不用回溯,只需要移动模板串呢?

高效方法的探索

下面以模板串“ABCDABD”为例进行分析
字符串匹配KMP算法
如上图所示,假设文本串某位置开始与模板串“ABCDAB”匹配成功,但模板串最后一个字符D与文本串当前位置字符C不匹配,此时iijj分别指向图上位置,特别地,ii指示了当前匹配到文本串的位置,jj反映了当前匹配的字符串数目。

因为不匹配,按照暴力匹配的方式,模板串右移一位,将首先是模板串的前j1j-1位与文本串当前不匹配位置前的j1j-1位进行比较,即P0P1Pj2P_0P_1\dots P_{j-2}Sij+1Sij+2Si1S_{i-j+1}S_{i-j+2}\dots S_{i-1}进行比较。因为jj之前的位置都匹配,有P0P1Pj1=SijSij+1Si1P_0P_1\dots P_{j-1}=S_{i-j}S_{i-j+1}\dots S_{i-1},所以,可以右移一位可以看作是P0Pj2P_0\dots P_{j-2}P1Pj1P_{1}\dots P_{j-1}。同理,右移两位将是P0Pj3P_0\dots P_{j-3}P2Pj1P_{2}\dots P_{j-1}。抽象一下,右移nn位,就是P0Pjn1P_0\dots P_{j-n-1}PnPj1P_{n}\dots P_{j-1}进行比较,即模板串前jj个字符中长度为jnj-n的前缀与后缀进行比较。
字符串匹配KMP算法
再分析一下,当模板串右移1,2,3位时,P0Pjn1!=PnPj1,n=1,2,3P_0\dots P_{j-n-1} != P_{n}\dots P_{j-1},(n=1,2,3)显然成立。因为模板串前6(j=6)(j=6)位“ABCDAB”中不存在长度为5,4,3的相同前后缀,所以这三次右移是完全不必要的操作,注定会导致失败。而“ABCDAB”中存在长度位2的相同前后缀“AB”,所以右移4(6n=2)(6-n=2)位时,有P0P1==P4P5P_0P_1 == P_4P_5,这样就能保证ii不动,jj移动到第3位(对应下标2),然后继续比较。

至此,我们能够总结,当匹配失败时,我们只需找出模式串前jj个字符中的最长前后缀(假设其长度为kk),然后将jj设置为kk,继续比较S[i]S[i]P[j]P[j]
字符串匹配KMP算法
以上正是KMP算法的核心,其充分利用已经部分匹配这个有效信息,保持指针ii不回溯,通过修改jj的位置,让模式串尽量地移动到有效位置。

最后多说一句,kk包含两重含义:

  1. 当前不匹配位置之前的字符串中,最长前后缀的长度;
  2. 当出现不匹配时,模板串指针jj要移动的下一个位置。

KMP算法

因为在模板串P的每一个位置都有可能发生不匹配,也就是说我们要计算每一个位置对应的kk,在KMP算法中,用数组next对其进行保存,next[j]=knext[j] =k,表示当S[i]!=P[j]S[i] != P[j]时,指针jj下一步指向下标kk 假设我们已经求出next数组,我们可以对暴力匹配进行改进,代码如下:

int kmpSearch(char* s, char* p)
{
	int i = 0;
	int j = 0;     // 初始化

    while (i < strlen(s) && j < strlen(p))
    {
        if (j == -1 || s[i] == p[j])      // 当前位置匹配成功
        {
            i++;
            j++
        } 
        else      // 当前位置匹配失败
        {
            j = next[j]    // i不回溯,j移动到next[j]位置,相当于模板串右移j - next[j]
        }
    }
    if (j == strlen(p))     // 匹配成功
        return i -j;
    else
        return -1;
}

求解next数组

再明确一遍,next[j]next[j]的值(也就是kk)表示当S[i]!=P[j]S[i] != P[j]时,指针jj下一步指向下标kk 下面一步步分析怎么求解next数组。

1、当j=0j=0
字符串匹配KMP算法
如上图,此时jj已经是模板串的最左边位置,前面不存在任何字符,这时候应该将指针ii后移一位,并且指针jj指向模板串串首不变,然后接着比较。因此将jj设置为-1(因为在if条件中,i++i++表示将ii右移一位,j++=1+1=0j++ = -1+1=0保持jj指向串首不变),所以next[0]=1next[0]=-1

2、当j=1j=1
字符串匹配KMP算法
如上图,此时指针jj前面只有一个位置,所以指针jj一定是移动到0位置(相当于模板串右移一位)。

3、当Pk==PjP_k==P_j时,求next[j+1]

字符串匹配KMP算法
next[j+1]next[j+1]是字符串P0PjP_0\dots P_j中的最长前后缀,因为我们已经求出next[j]=knext[j]=k,即在P0Pj1P_0\dots P_{j-1}中已经有P0Pk1==PjkPj1P_0\dots P_{k-1} == P_{j-k}\dots P_{j-1},且其是能找到的最长前后缀,此时又有Pk==PjP_k==P_j,我们可以直接得出P0Pk==PjkPjP_0\dots P_k == P_{j-k}\dots P_j,并且没有比这更长的前后缀,所以next[j+1]=k+1next[j+1]= k+1

4、当Pk!=PjP_k!=P_j时,求next[j+1]
字符串匹配KMP算法
Pk!=PjP_k!=P_j时,导致P0Pk!=PjkPjP_0\dots P_k != P_{j-k}\dots P_j,即P0PjP_0\dots P_j中没有长度为k+1k+1的相同前后缀,那只能去寻找更短一点的相同前后缀,且是在P0Pk1P_0\dots P_{k-1}中寻找。
字符串匹配KMP算法
如上图(来自july的博客),同样因为已经求出next[j]=knext[j]=k,即在P0Pj1P_0\dots P_{j-1}中已经有长度为kk的相同前后缀(如上图大括号标示区间)。假设我们在P0Pk1P_0\dots P_{k-1}中找到一个位置xx,满足P0Px==PjxPjP_0\dots P_x ==P_{j-x}\dots P_j,除去PxP_xPjP_j,即P0Px1==PjxPj1P_0\dots P_{x-1} ==P_{j-x}\dots P_{j-1}。而PjxPj1P_{j-x}\dots P_{j-1}next[j]next[j]对应的一个后缀串,一定有PjxPj1==PkxPk1P_{j-x}\dots P_{j-1}==P_{k-x}\dots P_{k-1},因此可以得到P0Px1==PkxPk1P_0\dots P_{x-1} ==P_{k-x}\dots P_{k-1},这个等式是不是有点似曾相识,对,这正是kk位置对应的前后缀。受此启发,我们找到kk位置对应的最长前后缀,即next[k],并且有Pnext[k]==PjP_{next[k]}==P_j,那么P0Pnext[k]P_0\dots P_{next[k]}就是我们要找的最长前后缀。如果Pnext[k]!=PjP_{next[k]}!=P_j,那么继续迭代,比较p[next[next[k]]] 与pj,即不断的递归k = next[k],直到要么找到长度更短的相同前缀后缀,要么没有长度更短的相同前缀后缀。

综上,可以得到求解next数组的代码

void GetNext(char* p, int next[])
{
    int k = -1;
    int j = 0;
    next[0] = -1;
    while (j < strlen(p) - 1)
    {
        if (k == -1 || p[j] == p[k])
        {
            k++;
            j++;
            next[j] = k;
        }
        else
        {
            k = next[k];
        }
    }
}

至此,我们从暴力匹配开始,逐步了解了KMP的原理,以及next数组的求解。关于next数组的优化,next数组与有限状态自动机的转换等一些其他字符串匹配算法大家可以自行了解。

参考文献

https://www.cnblogs.com/yjiyjige/p/3263858.html
https://www.cnblogs.com/ZuoAndFutureGirl/p/9028287.html