KMP算法解析

字符串匹配,一个老生常谈的问题。常见的问题是在源字符串S中查找模式字符串P,若存在,则返回P第一次出现的位置。
抛开效率不谈,任何问题都有一个暴力解法,当我们无法容忍暴力解法所带来的低效时,伟大的科学家们便开始研究如何提高效率,各种高效、令人称奇的算法便诞生了。
本文参考JULY的博客


字符串查找-暴力解法

存在字符串S和P,要求在S中查找P,若存在则返回P在S中第一次出现的位置,若不存在返回-1。

对于这种两个字符串匹配的问题,第一反应是用两个指针i和j,在源字符串S和模式字符串P中遍历,从字符串S的起始开始进行匹配,遍历S和P,如不完全匹配,则i后移一位,j从头再来,如此反复,直到遇到字符串结尾。

如下图:在S =“ABCDABCEABCX” 中查找 P =“ABCE”的字符串,暴力解法的第一步是用两个指针i和j在S和P中遍历:

步骤1:

这里写图片描述

第一步匹配过程中,当匹配到最后一个字符 ‘E’的时候,失配,此时i = 3,j = 3;

步骤2:

这里写图片描述

当第一次失配之后,我们将i指针后移一位(相当于模式字符串P整体右移一位),再来一次匹配,很显然第一次就失配了。继续右移:

步骤3:

这里写图片描述

继续失配,
如此反复…
直到 i==4 的时候,才开始匹配:

这里写图片描述

很显然,暴力解法中,每次 i 指针在源字符串中 如果失配了之后,便会回溯回上一次匹配的下一个位置。这一点很关键,这是其低效的最大原因。
很容易写出暴力解法的代码:

int violentSearch(char *s, char *p)
{
    if (s == NULL || p == NULL)return -1;
    int i = 0, j = 0;
    while (i < strlen(s) && j < strlen(p))
    {
        if (s[i] == p[j])//若匹配成功,则i和j后移
        {
            i++;
            j++;
        }
        else//失配
        {
            i = i - j + 1;//这是关键,i要回溯到上一次开始匹配的下一个位置
            j = 0;
        }
    }
    if (j == strlen(p))return i - j;//返回i的位置
    return -1;
}

如前面所说,源字符串指针i的回溯是造成低效的最大原因,能不能让i不回溯?答案当然是可以。

KMP算法

仔细观察暴力解法步骤1,在第一次匹配直到失配开始,我们已经直到源字符串S的0-3位是”ABCD”,因为’D’与模式串的’E’失配,所以匹配失败,之后我们将i回溯到’B’处,重新开始匹配。
重点来了·········································
源字符串中前缀”ABCD”在第一次遍历完成之后,我们已经知道这几个字符跟模式串的匹配关系了,能否利用这个已知的信息呢?观察模式串P,P=”ABCE”,发现其第一个字符’A’与其后面的”BCE”均不等,而在步骤1中,匹配直到i=3,j=3处才发现’D’和’E’不等,也就是说前面的3个字符”ABC”已经完成匹配,即有:

S[0]==P[0],S[1]==P[1],S[2]==P[2], S[3]!=P[3]
P[0]!=P[1],P[0]!=P[2],P[1]!=P[3].
->可以得出
P[0]!=S[1],P[0]!=S[2]
//但是由于S[3]!=P[3],P[0]!=P[3]并不能得出P[0]和S[3]的关系

所以步骤2和3就可以省略了。之所以步骤4不能省略是因为S[3]!=P[3],P[0]!=P[3]并不能得出P[0]和S[3]的关系。
那么,如果模式串P后面有跟首字符’A’相等的字符该怎么办?

假设源字符串S=”ABCABAABCBXA”, 模式串P=”ABCABX”,
步骤1:
这里写图片描述

第一步匹配中,前5个字符完全相等,到第6个字符不等。根据前面的经验,P串的首字符’A’与后面的第二、第三个’B’,’C’均不等,不需要再次判断,因为第4个字符’A’和首字符’A’相等,第5个字符和首字符后面的’B’相等,即

S[3] == P[3],S[4] == P[4];
P[0] == P[3],P[1] == P[4];
->
可直接得出P[0] == S[3], P[1] == S[4].

由上面的推导,即

这里写图片描述

上图中的A,B两个无需再进行比较,直接进行步骤3:

这里写图片描述

如此往复,继续比较下去。

通过上面两个例子,可以发现一个与之前暴力解法明显的区别:每次失配之后,源字符串指针i不再回溯,而是将模式串的指针j移动到一个合适的位置,并且这个j值的变化取决于模式串P的首字符与其尾字符的比较关系,相等和不等的情况下,j值的移动是不同的。我们称这种前后字符串为前缀子串和后缀子串。精简一点概括就是,j的移动位置取决于模式字符串的前缀和后缀的相似度。(与源字符串没有任何关系)。
不难想象,如果我们能够得出每次失配之后的j值的取值,那么便能将暴力解法的代码优化如下:

int KMPSearch(char *s, char *p)
{
    if (s == NULL || p == NULL)return -1;
    int i = 0, j = 0;
    while (i < strlen(s) && j < strlen(p))
    {
        if (j == -1||s[i] == p[j])//j==-1用来处理j回到起始位置时,保证i能够++
        {
            i++;
            j++;
        }
        else
        {
            j = next[j];//失配之后i不再回溯,j找到合适的位置
        }
    }
    if (j == strlen(p))return i - j;//返回i的位置
    return -1;
}

上述代码中的next[j]表示失配之后j的位置数组,称为next数组,它取决于失配字符前面的字符串的前缀和后缀的相似程度。OK,这句话没看明白没关系,介绍一下何为前缀、后缀的相似程度。


字符串前缀后缀

字符串的前缀,表示除最后一个字符外,其所有的头部子串。如ABCD的前缀就是三个:ABC,AB,A;
字符串的后缀,表示除第一个字符外,其所有的尾部子串。如ABCD的后缀就是三个:BCD,CD,D。

有了这个概念,字符串的前缀后缀的相似程度就好理解了。所谓字符串前缀、后缀的相似程度就是一个字符串的所有前缀子串和后缀子串中,最长相同两个子串的长度。如模式串P:”ABCABX”,其各个子串的前缀后缀如下表所示:

这里写图片描述

为了方便,本文后面称该表为部分匹配表。

这里写图片描述

前面示例2的步骤1,当匹配到第5个字符时失配,

这里写图片描述

此时我们根据其前面已经匹配的字符的相似程度将j移动到了 j == 2 处:

这里写图片描述

这里的j == 2就是我们上面求出的部分匹配表中子串ABCAB的相似度,

接下来继续比较:
源字符串中’A’ 与 模式串的 ‘C’失配,查表得 ‘C’之前的已匹配子串 ‘AB’的部分匹配值为0,所以j的下个位置回到0:

这里写图片描述

继续比较,第二个失配,前面已经匹配的子串’A’的部分匹配值为0,所以j继续回到0:

这里写图片描述

直到完成匹配。

注意到,我们一直强调,在失配字符之前已经匹配的子串的部分匹配值,为什么是失配字符之前呢?因为如前面描述的那样:

S[0]==P[0],S[1]==P[1],S[2]==P[2], S[3]!=P[3]
P[0]!=P[1],P[0]!=P[2],P[1]!=P[3].
->可以得出
P[0]!=S[1],P[0]!=S[2]
//但是由于S[3]!=P[3],P[0]!=P[3]并不能得出P[0]和S[3]的关系 即P[0]和S[3]的比较是不可以避免的。

很容易得出我们需要的next数组—–将上面得出的最大匹配表整体右移一格即可(为j=0赋个初值,设为-1)

这里写图片描述

代码中如何求出这个next数组呢?


递推法求next数组

首先,明确一下next数组的含义,next[j]表示模式串的第j个字符前面子串的最大前缀后缀相似度。假设next[j]=k.

这里写图片描述

表示P[0]…P[K-1] == P[j-k]…P[j-1],在该例中就是P[0]P[1] == P[3]P[4].
记住next数组的含义对后面求解next数组很关键。

若一直next[j]=k,求next[j+1]?

若p[k] == p[j],则next[j + 1 ] = next [j] + 1 = k + 1;
若p[k ] ≠ p[j],如果此时p[ next[k] ] == p[j ],则next[ j + 1 ] = next[k] + 1,否则继续递归前缀索引k = next[k],而后重复此过程。 相当于在字符p[j+1]之前不存在长度为k+1的前缀”p0 p1, …, pk-1 pk”跟后缀“pj-k pj-k+1, …, pj-1 pj”相等,那么是否可能存在另一个值t+1 < k+1,使得长度更小的前缀 “p0 p1, …, pt-1 pt” 等于长度更小的后缀 “pj-t pj-t+1, …, pj-1 pj” 呢?如果存在,那么这个t+1 便是next[ j+1]的值,此相当于利用已经求得的next 数组(next [0, …, k, …, j])进行P串前缀跟P串后缀的匹配。

当P[k] == P[j]时,很显然,next[j+1]=k+1;
当P[k]!= P[j]时,说明模式串的前缀后缀中P[0]…P[k] != P[j-k]…P[j](因为P[k]已经不等于P[j]了嘛),那么只能在这前缀P[0]…P[k] 和后缀 P[j-k]…P[j]中寻找更短的前缀后缀了是不是?如何理解前面所说的要递归的索引k=next[k]呢? 还记得前面强调的next数组的含义吗?next[j]表示第j个字符之前的子串的最大前缀后缀相似度。

发布了24 篇原创文章 · 获赞 15 · 访问量 1万+

猜你喜欢

转载自blog.csdn.net/ck1n9/article/details/77652474