字符串匹配的KMP算法之初学整合

时间:2021-08-31 10:21:56

首先声明:这篇博客来自于我初学KMP算法时对于大多数博客的筛选和整合。文章最下面给出了原文的出处。

KMP算法是拿来处理字符串匹配的。换句话说,给你两个字符串,你需要回答,B串是否是A串的子串(A串是否包含B串)。比如,字符串A="I'm Rain",字符串B="Rain",我们就说B是A的子串。你可以委婉地问你的MM:“假如你要向你喜欢的人表白的话,我的名字是你的告白语中的子串吗?”

首先给出阮一峰博客的图文详解(阮老师还是一如既往的强大)

原文链接http://www.ruanyifeng.com/blog/2013/05/Knuth%E2%80%93Morris%E2%80%93Pratt_algorithm.html

1.

首先,字符串"BBC ABCDAB ABCDABCDABDE"的第一个字符与搜索词"ABCDABD"的第一个字符,进行比较。因为B与A不匹配,所以搜索词后移一位。

2.

因为B与A不匹配,搜索词再往后移。

3.

就这样,直到字符串有一个字符,与搜索词的第一个字符相同为止。

4.

接着比较字符串和搜索词的下一个字符,还是相同。

5.

直到字符串有一个字符,与搜索词对应的字符不相同为止。

6.

这时,最自然的反应是,将搜索词整个后移一位,再从头逐个比较。这样做虽然可行,但是效率很差,因为你要把"搜索位置"移到已经比较过的位置,重比一遍。

7.

一个基本事实是,当空格与D不匹配时,你其实知道前面六个字符是"ABCDAB"。KMP算法的想法是,设法利用这个已知信息,不要把"搜索位置"移回已经比较过的位置,继续把它向后移,这样就提高了效率。

8.

怎么做到这一点呢?可以针对搜索词,算出一张《部分匹配表》(Partial Match Table)。这张表是如何产生的,后面再介绍,这里只要会用就可以了。

9.

已知空格与D不匹配时,前面六个字符"ABCDAB"是匹配的。查表可知,最后一个匹配字符B对应的"部分匹配值"为2,因此按照下面的公式算出向后移动的位数:

  移动位数 = 已匹配的字符数 - 对应的部分匹配值

因为 6 - 2 等于4,所以将搜索词向后移动4位。

10.

因为空格与C不匹配,搜索词还要继续往后移。这时,已匹配的字符数为2("AB"),对应的"部分匹配值"为0。所以,移动位数 = 2 - 0,结果为 2,于是将搜索词向后移2位。

11.

因为空格与A不匹配,继续后移一位。

12.

逐位比较,直到发现C与D不匹配。于是,移动位数 = 6 - 2,继续将搜索词向后移动4位。

13.

逐位比较,直到搜索词的最后一位,发现完全匹配,于是搜索完成。如果还要继续搜索(即找出全部匹配),移动位数 = 7 - 0,再将搜索词向后移动7位,这里就不再重复了。

14.

下面介绍《部分匹配表》是如何产生的。

首先,要了解两个概念:"前缀"和"后缀"。 "前缀"指除了最后一个字符以外,一个字符串的全部头部组合;"后缀"指除了第一个字符以外,一个字符串的全部尾部组合。

15.

"部分匹配值"就是"前缀"和"后缀"的最长的共有元素的长度。以"ABCDABD"为例,

  - "A"的前缀和后缀都为空集,共有元素的长度为0;

  - "AB"的前缀为[A],后缀为[B],共有元素的长度为0;

  - "ABC"的前缀为[A, AB],后缀为[BC, C],共有元素的长度0;

  - "ABCD"的前缀为[A, AB, ABC],后缀为[BCD, CD, D],共有元素的长度为0;

  - "ABCDA"的前缀为[A, AB, ABC, ABCD],后缀为[BCDA, CDA, DA, A],共有元素为"A",长度为1;

  - "ABCDAB"的前缀为[A, AB, ABC, ABCD, ABCDA],后缀为[BCDAB, CDAB, DAB, AB, B],共有元素为"AB",长度为2;

  - "ABCDABD"的前缀为[A, AB, ABC, ABCD, ABCDA, ABCDAB],后缀为[BCDABD, CDABD, DABD, ABD, BD, D],共有元素的长度为0。

16.

"部分匹配"的实质是,有时候,字符串头部和尾部会有重复。比如,"ABCDAB"之中有两个"AB",那么它的"部分匹配值"就是2("AB"的长度)。搜索词移动的时候,第一个"AB"向后移动4位(字符串长度-部分匹配值),就可以来到第二个"AB"的位置。

kmp算法的理解与实现

一 kmp算法为什么比传统的字符串匹配算法快

假设文本T = y1y2y3....yn, 模式 P = p1p2p3...pm, 传统的匹配算法把位移为0,1,...n-m时的文本依次跟P比较,每次比较最多花费O(m)的时间,算法的复杂度为O((n-m+1)*m)。这种算法没有利用匹配过的信息,每次都从头开始比较,速度很慢。而kmp算法充分利用了之前的匹配信息,从而避免一些明显不合法的位移。加快匹配过程。来看一个例子:

 

#########000xxxx000######                       文本T

|<---- s ---->|000xxxx000~~~                              模式P

 

假设位移为s时,T和P匹配了红色部分的字符,即匹配到了模式P的前10个字符,如果按照传统的匹配方法,下一步就是从位移s+1开始比较,而kmp算法则直接从位移s+7开始比较,而且断定:位移s+7对应的串和模式P的前3个字符是相同的,可

以不用比较,直接从第4个字符开始比较,这种跳跃式的匹配是不是比传统匹配方法快很多,如下图所示:

 

#########000xxxx000######                       文本T

|<-------- s+7-------->| 000xxxx000~~~               模式P

 

 

 

那么kmp是如何实现这种跳跃的呢?注意到红色部分的字符,即模式P的前10个字符,有一个特点:它的开始3个字符和末尾

3个字符是一样的,又已知文本T也存在红色部分的字符,我们把位移移动 10-3 = 7个位置,让模式P的开始3个字符对准文本

T红色部分的末尾3个字符,那么它们的前3个字符必然可以匹配。

 

二 构造前缀数组

 

上面的例子是文本T和模式P匹配了前面10个字符的情况下发生的,而且我们观察到模式P的前缀P10中,它的开始3个字符和末尾3个字符是一样的。如果对于模式P的所有前缀P1,P2...Pm,都能求出它们首尾有多少个字符是一样的,当然相同的字

符数越多越好,那么就可以按照上面的方法,进行跳跃式的匹配。

 

 

定义:

Pi表示模式P的前i个字符组成的前缀, next[i] = j表示Pi中的开始j个字符和末尾j个字符是一样的,而且对于前缀Pi来说,这样

的j是最大值。next[i] = j的另外一个定义是:有一个含有j个字符的串,它既是Pi的真前缀,又是Pi的真后缀

 

规定:

next[1] = next[0] = 0

 

 

next[i]就是前缀数组,下面通过1个例子来看如何构造前缀数组。

例子1:cacca有5个前缀,求出其对应的next数组。

前缀2为ca,显然首尾没有相同的字符,next[2] = 0

前缀3为cac,显然首尾有共同的字符c,故next[3] = 1

前缀4为cacc,首尾有共同的字符c,故next[4] = 1

前缀5为cacca,首尾有共同的字符ca,故next[5] = 2

 

 

 

如果仔细观察,可以发现构造next[i]的时候,可以利用next[i-1]的结果。假设模式已求得next[10] = 3,如下图所示:

 

 

000#xxx000         前缀P10

000                        末尾3个字符

 

根据前缀函数的定义:next[10] = 3意味着末尾3个字符和P10的前3个字符是一样的

为求next[11],可以直接比较第4个字符和第11个字符,如下图所示:蓝色和绿色的#号所示,如果它们相等,则

next[11] = next[10]+1 = 4,这是因为next[10] = 3保证了前缀P11和末尾4个字符的前3个字符是一样的.

 

000#xxx000#       前缀P11

000#                      末尾4个字符

 

所以只需验证第4个字符和第11个字符。但如果这两个字符不想等呢?那就继续迭代,利用next[next[10] = next[3]的值来求

next[11]。

代码如下:
void compute_prefix(int *next, char *p)  
{  
    int     i, n, k;  
  
    n = strlen(p);  
    next[1] = next[0] = 0;  
    k = 0;      /* 第i次迭代开始之前,k表示next[i-1]的值 */    
    for (i = 2; i <= n; i++) {  
        for (; k != 0 && p[k] != p[i-1]; k = next[k]);  
        if (p[k] == p[i-1]) k++;  
        next[i] = k;  
    }  
}  
其实最难理解的部分就是next数组的迭代过程:
首先要明确的是字符串数组的下标从0开始,而next数组的下标则是从1开始的
举一个从上面得到启发的一个栗子:
字符串数组下标0 1 2 3 4 5 6 7 8 9 10
待匹配的字符串b o b # x x x b o b o(字符串中没有空格,只是为了举例的上下对应)
k变化的记录     0 0 1 0 0 0 0 1 2 3 2
next数组的下标1 2 3 4 5 6 7 8 9 10 11
如果手动根据compute_prefix函数计算下最后一个'o'是怎么得到2的就可以知道稍微理解下迭代的过程了。

最后给出一道与KMP有关联的题目的代码,poj3461 ,求一个模式串在字符串中的出现次数。
#include<iostream>
#include<cstring>
#include<cstdio>
using namespace std;
void compute_prefix(int *next, char *p)
{
    int n, k;
    n = strlen(p);
    next[0] = next[1] = 0;
    k = 0;
    for(int i = 2; i <= n; i++)
    {
        while(k && p[k] != p[i-1])
            k = next[k];
        if(p[k] == p[i-1])
            k++;
        next[i] = k;
    }
}
int kmp_match(char *text, char *p, int *next)
{
    int ans = 0, j = 0;
    int text_len = strlen(text);
    int p_len = strlen(p);
    for(int i = 0; i < text_len; i++)
    {
        while(j && text[i] != p[j])
            j = next[j];
        if(text[i] == p[j])
            j++;
        if(j == p_len)
            ans++;
    }
    return ans;
}
int main()
{
    int Case, ans;
    char W[10005], T[1000005];
    int next[10005];
    cin >> Case;
    while(Case--)
    {
        scanf("%s", W);
        scanf("%s", T);
        compute_prefix(next, W);
        ans = kmp_match(T, W, next);
        cout << ans << endl;
    }
    return 0;
}

最后给出学习过程中的几篇博客:
http://www.matrix67.com/blog/archives/115