完全搞懂KMP算法

来源:互联网 发布:网络球机控制线接线图 编辑:程序博客网 时间:2024/05/20 09:47

在串的模式匹配算法中,KMP算法称得上是经典算法了。简约而不简单,是对KMP算法最恰当的评价。该算法是由D.E.Knuth,J.H.Morris和V.R.Pratt三人同时发现,因此人们称它为KMP算法。废话不多说,现在开始进入正题。

首先声明两点:

【1】字符串 采用定长顺序存储结构,主串用S表示,模式串用T来表示。用n来表示主串S的有效字符长度,用m来表示子串T中有效字符的长度。

【2】数组下标从0开始,但要注意,数组S[0]和T[0] 中存放的是串的长度。也就是说,字符串从S[1]和T[1]开始。(与严蔚敏老师的书保持一致)

主串S:     ababcababa 用 i 指示主串中正待比较的字符
模式串T:  ababa          用 j 指示子串中正待比较的字符 

目标:对于给定的主串S=‘ababcababa’ 和模式串T='ababa',判断串T 是否在 串S中,如果在,返回起始位置。

想要实现该目标,有很多方法,本文只介绍两种,即BF算法和KMP算法。


Brute Force(BF或蛮力搜索) 算法

首先,让我们完全从直觉出发,来认识一下最简单的字符串模式匹配算法——BF算法(又叫朴素的串匹配算法)。

BF算法是最笨的算法,也是一看就懂的算法。该算法在有些情况下时间复杂度为O(n+m),但是,在最坏的情况下,时间复杂度会飙升到O(n*m)。但是,BF算法也是理解KMP算法的基础。该算法的主要思想是:从主串S的第一个字符起,和模式串T中的第一个字符比较,若相等,则继续逐个比较后续的每个字符;否则就从主串的下一个字符起再重新和模式串T比较。

再次强调一遍,i 指向主串,j 指向子串。

算法的C语言代码表示如下:

int Index(const char* S, const char* T){//注意,S[0]和T[0]中存放的是字符串的长度int i = 1, j = 1;while(i <= S[0] && j <= T[0]){if(S[i] == T[j]) { ++i;++j; }else { i = i - j + 2;j = 1; }}if(j > T[0])return i - T[0];elsereturn -1;}

下图所示的为BF算法的一种最坏的情况,其中主串S为'aaaaaaaaaaaaaaaaaab',子串T为'aaaaab'。


我们可以看到,当一个一个字符比较,遇到不相同的字符时,BF算法的做法是让子串向右移动一位,然后重新子串头部开始比较。

为了更清楚地看到BF算法的缺陷,我们再来看一个BF算法的例子。


从该例子中可以看出,每次遇到不等的字符时,主串的“指针”i就要回退,而且子串每次也只是向右移动一位,这两点导致BF算法效率低至O(n*m),令人发指。


KMP算法

能不能换种方法,使得每次遇到不匹配的字符时,i呆在原地,不回退呢?同时,能不能想办法让子串每次向右移动的时候,在保证正确的情况下,子串尽量多 右移几位呢?

答案是 可以(Both)。这也正是KMP的做法。

我们在介绍KMP算法之前,先回头再研究一下BF算法,看一下该算法中遗漏掉了什么有用的信息。

如下图所示,当匹配到字符a处时,显然字符a与字符b不同,即串T匹配主串T失败。这时,子串T要一位一位右移,每移动一位,都要重新从头开始匹配。好,经历千辛万苦,终于,当下一次重新匹配到字符a处时(即B与C段完全相同),这时,先别着急着判断问号处的字符和字符a是否相同。

我们来观察一下这个状态有什么特殊之处。因为已经匹配到a处,所以之前的内容是完全相同的,即B与C段相同。之前,已经有C与A段相同了。哈哈,我们发现了,原来当右移重新匹配到上一次失败点的时候,必有A与B段相同。


B段显然是串T的一个前缀,而A段是失败点之前的串的一个后缀。为了不遗失可能正确的匹配结果,我们要再A与B第一次相同时就停止右移,即要保证A与B相同时,B的长度最大。

B是子串T的一个前缀,A是截止到b为止的子串T的一个后缀,且A与B完全相同。


用官方的术语来说,就是寻找匹配失败点之前的 subT的最长的前缀和后缀相等的串。

好了,上面讲了那么多,就是为了这一句话。只有理解了上面这一句话,你才会心悦诚服地 理解并记忆KMP。

KMP算法的整个流程就是: 

1. 将S串和T串从第一个字符开始匹配; 

2. 如果匹配成功,则subT即灰色部分增加;

3. 如果不成功,则T向后滑动,使滑动后的subT的前缀和滑动前的subT的后缀重合,再对问号处 进行匹配,如果还不成功,则再次滑动T,直到匹配成功或者T滑动到a处。

4. 如果到了a处,则从T串的起始位置进行匹配,跳至步骤1。 

从上面的步骤可以知道,匹配失败后,每次滑动到哪里,只与子串T本身有关,而与主串S没有任何关系。所以,我们可以在匹配主串之前,先对子串T进行预处理,得到每个失败点 应该向前滑行的位置,用一个next数组来存储。这样,以后每次匹配失败,只用O(1)的时间查询next数组,直接将j指向那里,再重新开始与主串失败点处进行比较就OK了。

====================================================================================

好了,理解了算法的精髓,就一马平川了。

我们假定,当比较到子串的第j个字符时,遇到了失败点。

此时:我们假设此时主串S中的失败点应该与子串T中的第k个字符继续比较,显然,k<j。



有了next数组,我们先假定next数组已经求好了,那么KMP算法就是小菜一碟了。

int KMP(const char* S, const char* T){//S[0]和T[0]中存放的是有效字符串的长度int i = 1, j = 1;while(i <= S[0] && j <= T[0]){if(0 == j || S[i] == T[j]) { ++i;++j; }//继续比较后继字符elsej = next[j];//匹配失败,模式串向右移动}if( j > T[0])return i - T[0];//匹配成功else return 0;}

可见,next数组的求法 才是KMP算法的关键,也是我们要讲的大头。下面,让我们开始进入next数组的求解环节吧。胜利就是眼前。

子串中第1个字符到第j-1个字符都匹配成功,第j个字符匹配失败,则要查询next[j]。

记next[j] = k,则k表示子串中第j个字符与主串匹配失败之后,应该将主串中的失败点与子串中的第k个字符相比较。

也就是说,在子串第j个字符之前,存在如下关系:


并且k是满足上述关系式的最大值。(k < j)

好的。下面我们用类似数学归纳法的方法,也就是递推(不是递归)来求next数组。

当next[j] = k时,我们来求next[j+1]。

存在两种情况:


第一种情况很简单,不多说了。


第二种情况,表明k也是失败点,则我们仍要继续取next[k],然后比较next[k]处的字符是否与Pj相等,如果相等,则next[j+1] = next[k] + 1,如果还是不等,则继续向前迭代。直至到子串的开头为止,即j==0为止。用C语言表述next数组的求法如下所示:

void get_next(const char*T, int next[]){//因为字符串有效值从下标1开始,所以我们的next数组也是从下标1开始next[1] = 0;next[2] = 1;int i = 2, j = next[i];while(i < T[0]){if(0 == j || T[i] == T[j]) { ++i;++j;next[i] = j; }else j = next[j];}}

我们可以看出,该函数的时间复杂度为O(m),m为子串T的长度。

不过,用该方法求得的next数组仍然有缺陷。我们举个例子就一目了然了。

主串S: 'aaabaaaab'

模式串T: ‘aaaab’

根据T可以得出next数组为

j12345模式串aaaabnext[j]01234再次强调一下,数组的第一个元素T[0]存放的是数组T的长度。真正的元素从T[1]开始存储。

我们看到,当i=4, j=4时,子串中T[4]a与主串的字符不匹配,则还要由next[j]的指示依次进行j=3, j=2, j=1这3次比较。但是,由于这四个字符也都相同,因此,假如j=4与主串i=4不匹配的话,就可以将子串一口气向右滑动4个字符,直接进行i=5, j=1的比较。

即我们再给next数组 递推赋值的时候,next[i] = j,还要再判断一下 T[i] 与 T[j] 是否相等。

因为正是由于子串T[i]与主串相比不符,失败的情况下才查找next[i],如果此时next[i]仍然是同一字符,那么比较肯定还是会失败的,所以如果T[i] == T[j],则next[i] = next[j].

修正后的next数组求法C语言描述如下所示:

void get_next(const char*T, int next[]){//2nd Edition//因为字符串有效值从下标1开始,所以我们的next数组也是从下标1开始next[1] = 0;int i = 1, j = next[i];while (i < T[0]){if (0 == j || T[i] == T[j]){++i;++j;if (T[i] == T[j])next[i] = next[j];elsenext[i] = j;}else j = next[j];}}

修改之后的next数组如下:

j12345模式串Taaaabnext[j]01234修改后next[j]00004


至此为止,KMP算法全部搞定。
算法不容易理解,谨记之。




0 0
原创粉丝点击