上一篇博客介绍了串匹配问题的定义和蛮力(Brute Force)算法的实现,并且通过分析得出,在最好的情况下,执行一次蛮力算法的时间,是文本串T长度n和模式串P长度m的乘积,即它的最坏时间复杂度为O(nm)。不难想象,当文本串很长时,运行蛮力算法的时间成本相当高。那么,有没有办法能优化串匹配算法,来降低它的时间复杂度呢?今天介绍的KMP算法就是流传最广的串匹配优化算法。

KMP算法

KMP 算法是 D.E.Knuth、J,H,Morris 和 V.R.Pratt 三位大牛共同提出的,因此称之为 Knuth-Morria-Pratt 算法,简称为 KMP 算法。KMP算法是针对蛮力算法的一种改进。要想弄懂KMP算法,我们就要弄清楚,蛮力算法有何不足之处。首先我们先设想一种蛮力算的最坏情况,然后进行分析。不难发现,蛮力算法最坏的情况,就是每一轮匹配到最后一个字母才失配,例如下图这种情况。

 

 

使用蛮力算法,会逐个比较文本串和模式串,如果匹配,则继续比较。

 

 

在前六次比较中,两者都是匹配的$(T[j] = P[i])$,直到最后一次比较才失配,这个时候指向文本串的指针i会回退,重新开始下一轮比较。

从中我们可以发现,蛮力算法的时间主要浪费在指针的回退上。如图可见,在第一次匹配的时候,指向文本串的指针j就已经移动到了第七位,但一旦发生失配,它就会必须移动到第二位,重新开始另一次匹配的过程。因此,想要降低串匹配的时间复杂度,我们就要尽量减少j指针的回退,甚至让它不要回退。

文本串指针回退以后的情况分析

再分析文本串指针的回退情况之前,我们先补充几个关于串的知识

1. 子串

字符串中任一连续的片段,称作其子串(substring)。具体地,对于任意的$0 \leqslant  i \leqslant  i +k < n$,由字符串S中起始于位置i的连续k个字符组成的子串记作:

 

$$
"a_{i}a_{i+1}...a_{i+k-1}"=S\left[ i,i+k \right]
$$

 

 

2. 前缀和后缀

起始于位置$0$、长度为$k$的子串称为前缀(prefix),而终止于位置$n - 1$、长度为$k$的子串称为后缀(suffix),分别记作:

$$
prefix\left( S,k \right) =S\text{[0,}k\text{)}\\
suffix\left( S,k \right) =S\text{[}n-k,n\text{)}
$$

然后我们分析文本串的指针回退以后的情况,首先,选择新的子串t

不难发现,新选取子串的长度为5的前缀,在前一轮匹配中,和模式串是匹配成功的,即$t[0,5) = P[1,6)$

这个前缀在这一轮匹配中,会和模式串的前缀,即$P[0,5)$进行匹配

因为模式串P的前六个字符都是S,所以有:

$$ t[0,5) = P[0,5) = P[1,6) $$

因此子串t的这部分前缀没有再次匹配的必要,可以直接从第六个字符进行比较,而第六个字符就是上一次匹配失配的位置,我们只需要将模式串的指针指向第六个字符,就可以开始这一轮比较。

从上面这个例子我们可以看出,文本串指针的位置其实和上一轮匹配过程中,匹配成功的子串的前后缀有关

假设在上一轮匹配中,$T[j] \ne P[i]$ ,在此之前,有$P[0,i) = T[j-i,j)$

那么在下一次匹配中,需要用到子串T[j-i,j]长度为k的后缀T[j-k,j)去和模式串P长度为k的前缀P[0,k)匹配。

如果能匹配上,则有

$$ P[0,k) = T[j-k,j) $$

又因为P[0,i) = T[j-i,j),必有

$$ T[j-k,j) = P[i-k,i) $$

所以

$$ P[0,k) = T[j-k,j) = P[i-k,i) $$

这意味着,匹配成功的必要条件是模式串在i之前的子串P[0,i)一定要有相等的前缀和后缀。否则的话匹配一定不成功。这些子串的长度k,组成集合N

$$
N = \{k | P[0,k) = P[i-k,i)\}
$$

文本串指针真的需要回退吗?

进一步分析了文本串指针回退后的操作,结合模式串的前缀和后缀,可以发现,其实文本串的指针根本没有回退的必要,因为回退无非有两种情况

1. 前面局部匹配的子串中,不存在相等的前缀和后缀。按照上面的流程可以发现,如果不存在这样的前缀和后缀,匹配不可能成功。

2. 前面局部匹配的子串中,存在匹配的前缀和后缀。这种情况说明新选定的子串和模式串前面是匹配上的,那这部分就不需要重新比较了。

 

因此,下一次比较从匹配的前缀的后一个字符,即P[k]处开始

 

 

更一般的情况

按照上面的流程,我们再来分析一个更一般的情况。模式串为ABCAABC,文本串为ABCAEABCD。

模式串的Next数组为

如图,第一次匹配中,文本串和模式串前四个字符匹配,在第五个字符处失配

 

匹配成功的子串为ABCA,ABCA的真前缀为{A,AB,ABC},真后缀为{A,CA,BCA}。

ABCA的真前缀和真后缀中只有一对相等,长度为1,所以要从模式串的第二个字符处比较

next数组

在确定了文本串的指针不用回退以后,那需要做的,就是判断模式串指针的位置。从上面的分析可以看出,P长度为k的前缀P[0,k)不需要比较,这一轮的比较从P[k]开始,为了不移除任何的可能,k取N中的最大值。即 k = max(N)。我们将P在不同的位置失配得到的k用一个数组记录下来,这个数据就是KMP算法中最重要的next数组。next数组可以用来确定模式串指针的位置。

构建next数组的代码如下

 1 int*buildnext(char*P)
 2 {
 3     int i=0;
 4     size_t m = strlen(P);
 5     int*next = new int[m];
 6     next[0] = -1;
 7     int t = next[0];
 8     while(j<m-1)
 9     {
10         if(t<0||P[j]==P[t])
11         {
12             j++,t++;
13             N[j] = t;
14         }
15         else
16             t = N[t];
17     }
18     return next
19 }

得到next数组之后,我们可以得到最终的代码,它只要在BF算法的基础上结合next数组,就可以获得。

 1 int KMP(char*T,char*P)
 2 {
 3     int*next = buildNext(P);
 4     int i=0,j=0;
 5     int m = strlen(P),n = strlen(T);
 6     while(j<m&&i<n)
 7     {
 8         if(0>j||T[i]==P[j])
 9         {
10             i++;
11             j++;
12         }
13         else
14             j = next[j];
15     }
16     delete [] next;
17     return i-j;
18 }

时间复杂度

因为文本串的指针不需要回退,所以KMP算法最多只需要执行n+m次,它的时间复杂度为O(n+m)