现在的位置: 首页 > 算法 > 正文

【模式匹配】之 —— KMP算法详解及证明

2016年05月27日 算法 ⁄ 共 13902字 ⁄ 字号 暂无评论

  1. 一    RevisionsHistory 1
    1. 一       Revisions History
    2. 二       前言
    3. 三       关于算法学习
    4. 四       KMP算法始末
      1. KMP算法是用来干什么的
      2. KMP算法是怎样产生的从暴力搜索算法讲起
      3. KMP算法的思想
      4. KMP算法的代码实现
      5. KMP算法改进
      6. 使用KMP算法在目标字符串中查找所有匹配的位置
      7. 使用Z-BOX算法计算next数组
    5. 五       总结  

本文所述KMP算法源码可在这里下载:

一、       Revisions History

 

Name

Date

Reason for change

Revision

超然

2013.03.19

First version

1.0

超然

2013.04.15

Added z box algorithm to compute next array

1.0.1

 

     


 

二、       前言

免责申明:

本kmp算法介绍及深入分析、逻辑证明、代码过程优化文章,为了全面演示本人愚钝的大脑思考过程,写的又臭又长。读者在阅读过程中必然会耗费大量的脑细胞,影响愉快的心情,所以阅读之前请三思而行,对于阅读本文造成的任何严重及不严重的后果本人概不负责:)

三、       关于算法学习

刘未鹏在他的《知其所以然》系列文章中提到了算法的学习,提出了很多具有代表性的问题,比较典型的有:学习算法为什么这么难?该怎样去学习算法?

算法的学习为什么这么难?

1 算法本身就是一个比较难的课门。

2 讲授算法的书、老师没有从大家更容易理解的角度去讲解。

该怎样去学习算法?

1 如果说要学习的算法是未知的东西,那么我们必然要从已知处出发,一步一个台阶,层层推进,严格推导出该算法。可惜的是,很多老师,很多书籍、文章做不到这一点。

2 在每一步的推导过程中,我们要证明这一步是正确的,这一步是最优的。其他假设的分支都是不正确、不合适的。

3 一个算法的推导过程就像一颗树一样,根节点是已知条件,其中的一个叶子节点是我们需要的算法。在推导过程中,我们不仅要演示能够得到证明的这条路径,我们还应该尽最大可能地说明其他分支是不正确的分支,或者不是最优分支。

我们应该由浅入深地思考以下问题:

某算法是用来干什么的?

某算法是怎样产生的?

某算法的本质是什么?

如何严格证明(一步一步推导)该算法的正确性?

该算法是不是最优的?是否存在更好的算法?

最后才是:

写代码实现该算法!!!

四、       KMP算法始末

1.       KMP算法是用来干什么的?

该算法是用来在目标字符串中查找模式串的。

 

2.       KMP算法是怎样产生的?——从暴力搜索算法讲起

说到查找字符串,我们首先想到的就是暴力搜索算法,从目标字符串的第1位开始进行比较,如果目标串的每一个字符都和模式串匹配,那么我们就找到了一个匹配的位置。如果在这一次比较的过程中发现某一位不匹配,那么我们就从目标字符串的第2为开始进行比较……依次进行下去,直到找到匹配的目标字符串的位置或者目标字符串到达了末尾。

写代码如下:

void ForceSearch(char *pMain,char* pPattern)
{
    int i = 0;
    int j = 0;
    int k = 0;
    for ( ; '\0' != pMain[k];i++, j = 0,k = i)
    {
        while (pMain[k] !='\0' && pPattern[j] != '\0' &&
            pMain[k++] ==pPattern[j++]);
        if (pPattern[j] =='\0')
        {
            printf("match ok at %d\r\n",i);
        }
        if ('\0' ==pMain[k])
        {
            break;
        }
    }
}

用图表表示暴力搜索算法如下:

目标串:abcabcad

模式串:abcad

下标计数

0 1 2 3 4 5 6 7

 

目标串

a b c a b c a d

 

模式串

a b c a d

第1次比较

匹配情况(o表示匹配,x表示不匹配)

o o o o x

 

 

  a b c a d

第2次比较

 

  x

 

 

    a b c a d

第3次比较

 

    x

 

 

      a b c a d

第4次比较

 

      o o o o o

 

由此表我们可以知道暴力搜索的时间复杂度大概为:O(目标串长度*模式串的长度)

 

那么暴力搜索算法有没有改进的空间呢?

答案是肯定的!

仔细分析上表,我们会发现在第一次匹配的时候,目标串和模式串在下标为4的时候发生了不匹配(目标串中是b,模式串中是d)。

因为此时我们已经知道了目标串的前4个字符和模式串中的前4个字符是匹配的(注意:接下来的分析均以此为出发点),所以,其实不用看目标串,只看模式串,我们也能够“预知”接下来的第2次和第3次比较是肯定会失败的。

因为第2次比较是把模式串往右移一位,模式串的前3位(abc)要和目标串中下标从1开始的3位(bca)进行比较,目标串中下标从1开始的3位和模式串中下标为1开始的3个字符(bca)是匹配的。因为bca不匹配abc,所以第2次比较不匹配。

再看第3次比较,将模式串往右移动2位,模式串的前2位(ab)要和目标串中下标从2开始的2位(ca)进行比较,目标串中下标从2开始的2位(ca)和模式串中下标从2开始的2位(ca)是匹配的。因为ca不匹配ab,所以第3次比较也不匹配。

而且我们可以知道在第4次比较中,第0个字符是匹配的。因为在第4次比较中,模式串的第0位要和目标串的第3位进行比较,目标串的第3位和模式串的第3位是相同的,所以只要看模式串的第0位和第3位是否相同,我们就能知道第0个字符的匹配情况,在这里模式串的第0位和第3位是相同的。

 

3.       KMP算法的思想

首先为了简化说明,统一称谓,我们给出下列定义:

字符串的前缀:从主串下标0开始的子串称为主串的前缀。

 

字符串的后缀:从主串下标大于0的位置到结尾的子串称为主串的后缀。

(前缀和后缀都是主字符串的一部分,而不能是全部)

 

两个字符串的匹配:两个字符串中每一位都一一对应的相同,我们称这两个字符串匹配。显然匹配的两个字符串等长。

 

对于一个给定的字符串,我们可以搜索它的所有前缀、后缀匹配的序列。对于前缀和后缀匹配的部分我们称之为首尾重复。所有匹配的前缀、后缀序列称为所有重复序列

例如对于字符串aabaa。我们可以列出它的所有前缀和后缀:

主串

ababa

所有前缀

(长度从小到大)

a

ab

aba

abab

所有后缀

(长度从小到大)

a

ba

 aba

baba



所以对于字符串ababa来说:它的首尾重复有a(第一个字符和最后一个字符都是a),aba(前3个字符和最后3个字符都是aba)。如果我们用重复的长度来组成一个序列的话,那么ababa的所有重复序列为{1,3}。

 

归纳一下上面的分析过程,可以得到更一般的结论:

如果目标串和模式串在第m位(模式串的第m位)发生了不匹配,只需要分析模式串的前m位,我们就可以知道,在接下来的后1次比较中,前面长度为m-1的字符是否和目标串匹配。

(如果模式串的前m位中,长度为m-1的前缀和后缀匹配,那么此次比较中前面长度为m-1的字符和目标串匹配,反之不匹配)

举例:

目标串为D1D2 D3 D4 D5 D6

模式串为A1A2 A3 A4 A5 A6

下标:    0  1    2    3   4    5

在下标为5的位置发生了不匹配(目标串中是D6,模式串中是A6),如果模式串中A1 A2A3 A4 (长度为4的前缀)匹配A2 A3 A4 A5(长度为4的后缀),那么接下来的一次匹配中(把模式串右移一位)模式串的前面4位就能和目标串匹配了。且前面4个字符的比较可以直接跳过,直接比较模式串的A5和目标串的D6

如果模式串中A1 A2 A3A4 不匹配A2 A3 A4A5,那么我们可以知道接下来的一次匹配肯定是会失败的。我们可以跳过这次比较。继续看模式串(A1 A2 A3 A4 A5)的长度为3的前缀是否匹配长度为3的后缀。

重复这一步骤,我们可以一直检查到长度为1的前缀和后缀是否匹配。

 

将这些能够匹配的前缀、后缀对记录下来,我们就知道在发生不匹配时,我们的模式串该右移到什么位置来和目标串继续进行匹配(以例子中的情况来说,如果长度为4的前缀、后缀对匹配,那么模式串应该右移1位再继续比较;如果长度为3的前缀、后缀对匹配,那么模式串应该右移2位再继续比较),而且继续匹配时,指向目标串的指针是不需要回溯的(继续从发生不匹配的位置开始进行比较)。

所以,我们可以将模式串的每一位对应的前缀、后缀对(可能会有不止一对)先计算保存起来,在匹配过程中,在哪一位发生了不匹配,我们查表就可以知道应该将模式串右移到什么位置来继续进行比较。这就是KMP算法的基本思想

 

接下来的工作转化为:求模式串中每一位对应的所有重复序列。

 

我们先使用肉眼查看的方式统计模式串的每一位对应的所有重复序列:

模式串:aabaabaa

第1位对应的所有重复序列:{0}

第2位对应的所有重复序列:{1}(在字符串aa中长度为1的前缀、后缀对匹配)

第3位对应的所有重复序列:{0}

第4位对应的所有重复序列:{1}(在字符串aaba中长度为1的前缀、后缀对匹配)

第5位对应的所有重复序列:{1,2}(在字符串aabaa中长度为2的前缀、后缀对匹配)

第6位对应的所有重复序列:{3}(在字符串aabaab中长度为3的前缀、后缀对匹配)

第7位对应的所有重复序列:{1,4}(在字符串aabaaba中长度为1和4的前缀、后缀对匹配)

第8位对应的所有重复序列:{1,2,5}(在字符串aabaabaa中长度为1,2,5的前缀、后缀对匹配)

 

现在假设模式串aabaabaa在和目标串匹配过程中,前面7位都是匹配的,第8位发生了不匹配,我们查第7位对应的重复序列表,取得的最大数是4,那么接下来的比较应该从目标串的第8位和模式串的第5位开始。(之所以选择最大数4是因为这样模式串移动的位置最小,如果我们一上来就选择1,那么模式串移动的位置将比4大,可能会遗漏前面能够匹配的位置)

 

接下来目标串的第8位和模式串的第5位比较可能有两种情况:

1 匹配。

2 不匹配。

我们先讨论不匹配的情况,此时很显然我们看表里面比4小的数是1,所以我们应该拿目标串的第8位和模式串的第2位进行比较。

我们在看匹配的情况,如果目标串的第8位和模式串的第2位匹配了(注意:这是前提),而在后面的某一个位置M处(模式串的位置)发生了不匹配,那么我们应该查模式串第M位对应的重复序列表来决定下一次的比较位置了,而可以忽略这里第7位对应的重复序列表中其余的序列。(在这里也就是表现在可以不管重复序列1,可以不用去比较目标串的第8位和模式串的第2位了)。

 

为什么可以省略掉其余重复序列的比较?我们给出证明

 

已知:

模式串的第A位对应的重复序列表为:{n,m}(n<m)

结论:

如果模式串在和目标串的比较过程中在第A位之后的1位发生了不匹配,此时将模式串第m位后的1位来和目标串进行匹配,如果在比较k个字符后发生了不匹配,此时我们可以查看m+k位对应的重复序列表,而不需要管第A位对应的重复序列表的下一个重复序列n。

 

假如m+k组成的模式字符串(的一部分)在位置B处发生了不匹配,我们只需要考虑m+k字符串对应的重复序列表,而不需要再考虑n+1位在目标串的位置A处是否能够匹配的上。

分析如下:

n+k长度的模式串和目标串比较有两种结果:

1 匹配。

2 不匹配。

如果不匹配,那么我们自然可以跳过n在位置A的比较。

如果能匹配,从“第1行”和“第2行”可知模式串开头n+k长度的子串是开头m+k长度的子串的后缀,而这两个字符串又都是从模式串开头起始的,所以短串(n+k子串)是长串(m+k子串)的前缀。因为n+k子串既是m+k子串的前缀,也是m+k子串的后缀,所以n+k是m+k子串的一个重复序列。所以在考虑位置B对应的m+k子串的所有重复序列时,如果前面位置A的n前缀是一个能够匹配的位置时,那么n前缀也会以n+k的形式包含在m+k子串的所有重复序列中。这样我们可以放心的只管m+k子串的所有重复序列,而不用在位置B不匹配时,又回溯目标串从位置A处开始和n前缀的后一个字符进行比较。

KMP算法就是这样避免了回溯目标串的指针。如果目标串是在文件中,我们只需要顺序地一个字符一个字符读取就可以了。

 

前面讲到了需要计算出模式串中的每一位对应的所有重复序列,这样才能在匹配过程中一旦发现不匹配就可以通过查表来确定接下来该把模式串往后移动到什么位置再继续和目标串的失配字符继续比较。但是模式串的每一位对应的重复序列的个数是不一样的,以上面的模式串:aabaabaa为例,第7位的重复序列有2个,第8位的重复序列有3个。我们能不能将目前这种不定长二维重复序列表进行简化呢?

答案是肯定的!

我们来看以下命题:

已知:

一个字符串的所有重复序列为{m1,m2,…mN-1,mN}(m1< m2< … < mN-1 < mN)。

结论:

该字符串的长度为mN的前缀串的所有重复序列为{ m1,m2,…mN-1}。

 


因为:

1.        “前 mN-1个字符”和“后mN-1个字符”匹配;

2.        “后mN-1个字符”其实就是“后mN个字符”的后缀;

3.        “后mN个字符”和“前 mN个字符”匹配。

所以:

mN-1是“前 mN个字符”组成的字符串的一个重复序列。

同理可得:m1,m2,…mN-1都是“前 mN个字符”组成的字符串的重复序列

接下来使用反证法证明这些重复序列是所有的重复序列。

假设在“前 mN个字符”组成的字符串中存在另外一个重复序列mk,那么在“前 mN个字符”组成的字符串中,前面mk个字符匹配最后mk个字符。而在主串中前面mN个字符匹配最后mN个字符,所以在主串中前面mk个字符匹配最后mk个字符。所以mk也是主串的一个重复序列。这与已知条件:{m1,m2,…mN-1,mN}是字符串的所有重复序列矛盾。

假设不成立,故命题得证。

 

递归运用以上命题,我们就可以知道:

如果:

一个字符串的所有重复序列为{m1,m2,…mN-1,mN}(m1< m2< … < mN-1 < mN)。

那么:

该字符串的长度为mN的前缀串的所有重复序列为{m1,m2,…mN-1}。

该字符串的长度为mN-1的前缀串的所有重复序列为{m1,m2,…mN-2}。

………………

该字符串的长度为m2的前缀串的所有重复序列为{m1 }。

该字符串的长度为m1的前缀串的所有重复序列为{0}(该前缀没有可匹配的重复序列)。

这样我们就可以优化模式串中每一位对应的重复序列表的存放方式,将二维表变成一维线性表,仍假设一个字符串的所有重复序列为{m1,m2,…mN-1,mN},我们可以这样组织一维线性表:

位置索引(1为起始下标)

该位置对应的最长重复序列

1

……

……

……

m1

0

……

……

m2

m1

……

……

mn-1

m n-2

……

……

mn

mn-1

……

……

字符串长度

mn

在此一维线性表中,只要我们知道了模式串对应的最大的重复序列(mn),我们就可以用这个最大的重复序列做索引,顺藤摸瓜,依次找到该模式串的所有重复序列。

 

回顾前面我们通过肉眼判断出来的模式串每一位的重复序列表

模式串:aabaabaa

第1位对应的所有重复序列:{0}

第2位对应的所有重复序列:{1}(在字符串aa中长度为1的前缀、后缀对匹配)

第3位对应的所有重复序列:{0}

第4位对应的所有重复序列:{1}(在字符串aaba中长度为1的前缀、后缀对匹配)

第5位对应的所有重复序列:{1,2}(在字符串aabaa中长度为2的前缀、后缀对匹配)

第6位对应的所有重复序列:{3}(在字符串aabaab中长度为3的前缀、后缀对匹配)

第7位对应的所有重复序列:{1,4}(在字符串aabaaba中长度为1和4的前缀、后缀对匹配)

第8位对应的所有重复序列:{1,2,5}(在字符串aabaabaa中长度为1,2,5的前缀、后缀对匹配)

我们可以通过上面的方法将结果改变成按照一维线性表的方式存放:

位置索引(1为起始下标)

该位置对应的最长重复序列

所有重复序列

1

0

无重复序列

2

1

下标1=0

3

0

无重复序列

4

1

下标1=0

5

2

下标2=1 下标1=0

6

3

下标3=0

7

4

下标4=1

8

5

下标5=2 下标2=1 下标1=0

 

下文要用到的next数组

 

该表也很直观地验证了我们上面的命题的正确性。

4.       KMP算法的代码实现

通过以上分析我们可以看出,使用KMP算法关键是要获得模式串中每一位对应的最长重复序列。我们把得到的数值保存在一个名为next的数组中,next数组的长度和模式串的长度一样。

假设我们知道next[A]的值为m(最长重复序列),那么next[A+1]的值为多少呢?

 

 

分为两种情况:

如果模式串的p[A+1] == p[m+1],那么next[A+1] = m+1。

如果模式串的p[A+1] != p[m+1],那么我们应该找下一个次长的重复序列来测试,位置A处的下一个次长重复序列,通过上面的“命题”,我们知道就是next[m]。所以,我们接下来的工作将转为测试p[A+1]是否==p[next[m]+1]。这样就形成了一个递归,递归的结束是要么找到p[A+1]==p[n+1](n是位置A的一个重复序列),要么所有的重复序列之后的一个字符都不能匹配上,此时next[A+1] = 0。

回顾上面模式串:aabaabaa的next数组构造方式,位置3的next[3]计算过程为:

判断p[3]是否==p[next[2]+1],p[3]!=p[2],所以继续判断p[3]是否==p[next[next[2]]+1]

P[3]!=p[1],1已经是最小长度了,所以next[3]=0。

位置7的next[7]计算过程为:

判断p[7]是否==p[next[6]+1],p[7]==p[4],所以next[7]=next[6]+1=4。

我们将以上过程用代码实现(注意:代码中的下标从0开始):

void GetNext(char *p,int nLen, int *next)
{
    next[0] = 0;
    for (inti = 1; i < nLen; i++)
    {
        int tmp = next[i-1];
FLAG1:
        if (p[tmp] ==p[i])
        {
            next[i] =tmp + 1;
        }
        else if (0 == tmp)
        {
            next[i] = 0;
        }
        else
        {
            tmp = next[tmp-1];
            goto FLAG1;
        }
    }
}

以上原始代码是严格按照我们前面的分析写出来的,用以上代码求模式串“aabaabaa”,得到的数值和前面我们肉眼观察的值一致。

特别需要注意的是代码里面是以0为起始下标的,而前面表格中是以1为起始下标的。

 

我们在实际使用kmp算法时,是在发现不匹配的位置(假设下标为A处)时,应该去找前面从下标0到下标A-1组成的字符串的最长重复序列,然后将模式串进行适当的移动。所以上面例子中的模式串“aabaabaa”的next数组应该做一点调整:

位置索引i(0为起始下标)

对应的字母

Next[i]

0

a

-1

1

a

0

2

b

1

3

a

0

4

a

1

5

b

2

6

a

3

7

a

4

(注:next[0] = -1,有双重意义,一是为了简化代码分支;二是因为实际匹配中,如果在第0位发生了不匹配,目标串从数学意义上就应该是和模式串的-1进行比较,也就是目标串的指针应该往后挪1位和模式串的第0位进行比较。)

 

相应的求next数组的代码修改如下:

void GetNext2(char *p,int nLen, int *next)
{
    next[0] = -1;
    next[1] = 0;
    for (inti = 2; i < nLen; i++)
    {
        int tmp = next[i-1];
FLAG1:
        if (-1 == tmp || p[tmp] ==p[i-1])
        {
            next[i] =tmp + 1;
        }
        else
        {
            tmp = next[tmp];
            goto FLAG1;
        }
    }
}

为了验证代码的正确性,我写了一段暴力搜索的代码来求一样next数组:

void Force(char *p,int i, int *pNext)
{
    for (intlen = i-1; len >= 0; len--)
    {
        if (memcmp(p, &p[i-len],len) == 0)
        {
            pNext[i] =len;
            break;
        }
    }
}
void ForceNext(char *p,int nLen, int *pNext)
{
    pNext[0] = -1;
    int i = 1;
    for (; i < nLen; i++)
    {
        Force(p,i, pNext);
    }
}

可以写测试代码来验证这两种方法得到的结果是否一致。

 

接下来我们对GetNext2函数继续调整,这段代码使用了goto语句,从形式上来说很不美观,也不够结构化。另外我们先指定的next[1]=0也是多余的,后面的for循环可以保证next[1]=0,只需要将for的起始i改为1。

void GetNext3(char *p,int nLen, int *next)
{
    next[0] = -1;
    int tmp = -1;
    for (inti = 1; i < nLen;)
    {
        if (-1 == tmp || p[tmp] ==p[i-1])
        {
            next[i] =tmp + 1;
            tmp = next[i];
            i++;
        }
        else
        {
            tmp = next[tmp];
        }
    }
}

上述代码可以更精简一点:

void GetNext3(char *p,int nLen, int *next)
{
    int tmp = next[0] = -1;
    for (inti = 1; i < nLen; )
    {
        if (-1 == tmp || p[tmp] ==p[i-1])
        {
            next[i++] = ++tmp;
        }
        else
        {
            tmp = next[tmp];
        }
    }
}

这段代码已经和网上很多地方的代码类似了,我们可以用这段代码求出next数组,并真正使用到kmp算法中。

接下来实现kmp算法的代码:

char* KmpMatch(char *pDest,char* pPattern)
{
    char *pFind =NULL;
    int nLen = strlen(pPattern);
    int *pNext =new int[nLen];
    int i = 0;
    int j = 0;
    if (!pNext)
    {
        return NULL;
    }
    GetNext3(pPattern,nLen, pNext);
    while (pDest[i] !='\0' && pPattern[j] != '\0')
    {
        if (pDest[i] ==pPattern[j])
        {
            i++;
            j++;
        }
        else
        {
            j = pNext[j];
            if (j == -1)
            {
                j = 0;
                i++;
            }
        }
    }
    if (j ==nLen)
    {
        pFind = &pDest[i-j];
    }
 
    if (pNext)
    {
        delete pNext;
        pNext = NULL;
    }
    return pFind;
}

该代码中while循环里面的if/else分支可以进行一些合并:

while (pDest[i] !='\0' && j < nLen)
{
    if (j == -1 ||pDest[i] ==pPattern[j])
    {
        i++;
        j++;
    }
    else
    {
        j = pNext[j];
    }
}

至此整个KMP算法就讲的差不多了。

接下来我们要讲一个KMP算法的特例,以及对KMP算法的改进。

 

5.       KMP算法改进

对于特例模式串aaaaa,它的next数组我们用上面的代码可以计算出:

next[5] = {-1,0,1,2,3}。

我们看一下根据这个next数组进行匹配的情况,假设目标串为aaaacxxxx。

如下图:

下标计数(从0开始)

0 1 2 3 4 5 6 7 8

 

目标串

a a a a c x x x x

 

模式串

a a a a a

第1次比较,第4位不匹配

(o匹配,x不匹配)

o o o o x

next[4]=3接下来从模式串的第3位开始比较

 

  a a a a a

第2次比较,第3位不匹配

 

  o o o x

next[3]=2接下来从模式串的第2位开始比较

 

    a a a a a

第3次比较,第2位不匹配

 

    o o x

next[2]=1

 

      a a a a a

第4次比较,第1位不匹配

 

      o x

 

 

        a a a a a

第5次比较,第0位不匹配

 

        x

 

分析一下这个过程,我们会发现,第1次比较在第4位失配,也就是目标串在第4位不是a,而我们的模式串里面的每一位都是a,那么只要是比较的时候会比较到目标串的第4位则匹配必然是会失败的。也就是说我们接下来第2,3,4,5次比较其实早已注定是无用功。

根据之前的next数组计算方法得到next[4]=3,也就是说如果在第4位失配的话,应该用模式串的第3位来继续比较,但是在这里,模式串的第4位和第3位都是a,如果第4位和目标串不匹配,那么第3位和目标串肯定也是不匹配的。

 

所以我们应该改进next数组的计算方法,在找到失配位置之前的最长重复子串时,还要保证最长重复子串接下来的一个字符不等于失配位置的模式串字符。这一点最早是D.E.Knuth(TAOCP的作者)发现的,其实KMP算法原本是MP算法(J.H.Morris和V.R.Pratt),D.E.Knuth改进MP算法之后,将自己的名字加在前面,成为KMP算法。

 

具体到模式串aaaaa来说,我们在计算next[4]时,不仅要看到模式串的前面4个字符aaaa的最大重复子串为3,还要看下标为3的字符是否和和下标为4的字符是否相等,如果相等,那么3就是不合格的最大重复子串(必然导致匹配失败)。我们对GetNext3函数进行改进:

void GetNext4(char *p,int nLen, int *next)
{
    int tmp = next[0] = -1;
    for (inti = 1; i < nLen; )
    {
        if (-1 == tmp || p[tmp] ==p[i-1])
        {
            if (p[i] != p[tmp+1])
            {
                next[i++] = ++tmp;
            }
            else
            {
                next[i++] = next[++tmp];
            }
        }
        else
        {
            tmp = next[tmp];
        }
    }
}

对于这个新的条件,我们依然可以改进我们的暴力搜索next数组代码来进行验证:

void Force2(char *p,int i, int *pNext)
{
    pNext[i] = -1;
    for (intlen = i-1; len >= 0; len--)
    {
        if (memcmp(p, &p[i-len],len) == 0 && p[i] != p[len])
        {
            pNext[i] =len;
            break;
        }
    }
}
void ForceNext2(char *p,int nLen, int *pNext)
{
    pNext[0] = -1;
    int i = 1;
    for (; i < nLen; i++)
    {
        Force2(p,i, pNext);
    }
}

改进后的next数组对KMP算法的主体匹配部分不影响,代码保持不变。

 

对于函数GetNext4,if分支的前一部分代码next[i++] = ++tmp;比较容易理解。

            if (p[i] != p[tmp+1])
            {
                next[i++] = ++tmp;
            }
            else
            {
                next[i++] = next[++tmp];
            }

对于else部分的代码next[i++]= next[++tmp];我们仍然是心里不踏实。这样写对吗?实验出来的正确性是否只是简单情况下的巧合?是否有其他特殊情况是我们没有考虑到的?

为了让大家放心,我们来尝试证明。

首先,我们明确改进后的next数组有两个前提条件

条件1: next数组中的数值是前面部分子串的最长重复序列,如果next[i]=tmp,则模式串中从下标0到i-1的子串中,前面tmp位和最后tmp位是一样的(重复序列)。

条件2:如果next[i] = tmp,则模式串中的p[tmp] != p[i]。

 

现在,已知模式串p的next数组中next[i-1]=tmp,问next[i] = 多少?

画图如下:

 

 

如果p[tmp] == p[i-1]且p[tmp+1] != p[i],则按照代码中的流程

next[i++] = ++tmp;

这和改进之前的代码一样,我就不再解释了。

如果p[tmp] == p[i-1]且p[tmp+1] == p[i](违背了条件2),我们分析一下这时候该怎么得到next[i]?

因为:p[tmp] == p[i-1]

所以:tmp+1是next[i]的最长重复序列,只是因为p[tmp+1] == p[i](违背了条件2),所以我们不能写next[i] = tmp+1。

既然tmp+1不满足,那么我们就应该考虑next[i]的次长重复序列,而根据前面的命题,次长的重复序列就是next[tmp+1]

next[tmp+1] = n(如上图紫色部分所示),next[tmp+1] =n满足条件1和条件2。

根据条件2得:p[tmp+1] != p[n]。

而已知条件中:p[tmp+1] ==p[i]。所以p[n] != p[i](满足条件2)。

n是next[i]的次长重复序列(最长重复序列tmp+1已经不满足条件2),且n满足条件2。

所以next[i] = n = next[tmp+1]

else分支中的代码得证。 

6.       使用KMP算法在目标字符串中查找所有匹配的位置

我们上面演示的代码只是在目标字符串中查找到第一个匹配的位置后就返回了,如果要想匹配所有的位置,我们需要将模式串最后的’\0’结尾字符也考虑进去(对于查找二进制串的要做相应的1位扩充)。构造的next数组元素要多一个,在做KMP查找的时候找到第一个匹配处并不退出循环,而是继续查找下去。

相应代码如下(注意:本文中演示的KMP算法代码不能直接用于二进制串搜索):

void KmpLoopMatch(char *pDest,char* pPattern)
{
    char    *pFind      =NULL;
    int     nLen        = strlen(pPattern);
    int     i           = 0;
    int     j           = 0;
    int     nFind       = 0;
    int     *pNext      =new int[nLen+1];
    if (!pNext)
    {
        return;
    }
    printf("Find \"%s\" in \"%s\":\r\n",pPattern, pDest);
    GetNext3(pPattern,nLen+1, pNext);
    while (pDest[i] !='\0' && j <= nLen)
    {
        if (j == -1 ||pDest[i] ==pPattern[j])
        {
            i++;
            j++;
            if (j ==nLen)
            {
                printf("Match at: %d\r\n",i-j);
                nFind++;
            }
        }
        else
        {
            j = pNext[j];
        }
    }
 
    if (pNext)
    {
        delete pNext;
        pNext = NULL;
    }
    printf("All find %d position(s).\r\n",nFind);
}

7.       使用Z-BOX算法计算next数组

http://blog.csdn.net/sun2043430/article/details/8784853一文中,我们讲述了Z-BOX算法。我们可以根据一个字符串的Z-BOX数值,计算出该字符串的next数组,而且是改进之后的next数组。

针对字符串p的Zi(p)值,其含义是从位置i处有多长的子串和p的前缀匹配。例如对于字符串aabaaab,Z3(p)的值(下标从0开始)= 2。因为p[3,4] =p[0,1] = “aa”。并且p[5] !=p[3]。所以我们可以知道这个字符串的next数组在下标5位置的next[5] = 2。根据这一思路我们可以完成Z-BOX值到next数组值的转化,需要注意的是,可能有不止一个Z-BOX值指向了同一个位置的next值,例如abaxabac,Z4(p) = 3,Z6(p) = 1。根据Z4(p) =
3,我们可得next[4+3] = 3;根据Z6(p) = 1,我们可得next[6+1] = 1。这样next[7]就算出了2个不同的值,但很显然我们需要的是较大的数值,也就是3。根据这一分析,我们在做Z-BOX数值到next数值的转换时,需要从后往前处理,这样大的数值,可以覆盖小的数值,同时对于那些没有计算到的空位,我们需要将其填写为0或者-1,如果该位和字符串开头的字符不同则填0,如果相同则填-1。对应的转换代码如下:

void ZBox2KMPNextArray(const char *p, intzbox[], int nLen, int next[])
{
    next[0] =  -1;
    for (inti = 0; i <=nLen; i++ )
    {
        if (p[i] ==p[0])
        {
            next[i] = -1;
        }
        else
        {
            next[i] = 0;
        }
    }
    for (inti = nLen-1;i > 0; i--)
    {
        int tmp = zbox[i];
        if (0 != tmp)
        {
            next[i+tmp] =tmp;
        }
    }
}

 

这个使用Z-BOX值计算next数组的方法易于理解,写代码也比较容易。但使用Z-BOX数值来计算next数组需要额外的空间来保存Z-BOX数组中间值。在算法的时间复杂度方面,Z-BOX数组的计算时间是线性的,从Z-BOX值转换到next数组的时间也是线性的,整体上的时间复杂度为线性时间。

对于不熟悉Z-BOX算法的读者,请参阅上面给出的链接。使用Z-BOX值计算next数组的好处是从思路上完全避免了递推计算中各种数学证明,直观易懂。

五、       总结

至此,KMP算法的整体思路、推导过程和代码实现都讲完了。整个文章一路写下来,基本上是一个学习、思考、从质疑到确定的过程,按照我自己对KMP的理解,对我认为不容易理解,不讲解清楚、不证明其正确性不能令自己信服的地方花费了很多功夫进行文字说明、图表演示、逻辑证明。虽然篇幅庞大,但恐仍然有不及之处,另外篇幅冗长可能给读者的阅读和理解也带来一定的干扰性。再此表示抱歉,并恳请各位读者不吝赐教!

刘未鹏在其《知其所以然》系列文章中,就陈述了目前算法教学、书籍中普遍存在的弊端,即只给结论,不给思考过程,只授人以鱼,不授人以渔的状况。他认为算法、数学的教学需要讲解出整个思维的过程,如何从已知一步一步递进到结论。我的这篇文章本着这样的想法,力求从简单的、易懂的已知部分,一步一步到达终点,但中间有些地方,实在是因为本人能力有限,而不得不从结论部分来倒推、揣摩其原理,当然这也是学习过程中普遍采用的一种方法。

最后,我在尝试写这篇kmp算法文章时,参阅了大量网上的文章,如july_v,matrix67,以及以下这篇未署名的文章:http://uriah.diandian.com/post/2012-07-23/40029493444。其他的文章和作者我就不一一列出了,再次一并表示感谢,并特别感谢刘未鹏及其启发思维的好文章!

本文所述KMP算法源码可在这里下载: