字符串的模式匹配:KMP算法

时间:2022-03-03 06:12:35

  KMP算法是一种改进的字符串匹配算法,由D.E.Knuth,J.H.Morris和V.R.Pratt同时发现,因此人们称它为克努特——莫里斯——普拉特操作(简称KMP算法)。KMP算法的关键是利用匹配失败后的信息,尽量减少模式串与主串的匹配次数以达到快速匹配的目的。具体实现就是实现一个next()函数,函数本身包含了模式串的局部匹配信息。其实KMP算法与BF算法的区别就在于KMP算法巧妙的消除了指针i的回溯问题,只需确定下次匹配j的位置即可,使得问题的复杂度由O(mn)下降到O(m+n)。

与BF算法的比较

  普通的匹配算法BF中,目标串和模式串的索引值都需要重新回到起点再次进行匹配,所以该算法的时间复杂度较高,达到O(m*n)。KMP算法的核心思想是寻找模式串本身的特征,在此基础上达到目标串不回溯,模式串有规律回溯的目的,以减少回溯比较的次数,降低时间复杂度,达到O(m+n)。但是这是建立在提高空间复杂度的基础上的。
  BF算法:时间复杂度O(m*n);空间复杂度O(1)
  KMP算法:时间复杂度O(m+n);空间复杂度O(n)

算法思想

  KMP算法的核心是寻找模式串本身的规律。在该算法中表现为反映该规律的next数组。next数组的作用是在每次失配时,模式串根据next数组对应位置上的值回溯模式串索引的位置。next数组的求法也是KMP算法的精华所在。
  
  next数组有如下定义:

(1) next[j] = -1: j = 0
(2) next[j] = max(k): 0 < k < j 且 P[0,k-1]=P[j-k, j-1];
(3) next[j] = 0 其他

  该定义的意义就是next数组的首位均为-1。在其他位置上,该位置之前的子串存在与该位置之后的同等长度的子串,我们称之为前后缀(如在a b c a b中,前缀和后缀均为”ab”。)。当没有任何相同的前后缀的情况下,next值为0。否则next值为k。k可以理解为子串的长度。
  
  再举个例子来看一下设么是前缀和后缀:

字符串:”test”
前缀为: t, te, tes
后缀为:est, st, t

  next值举例:

P  a   b  a   b  a  
j   0   1  2   3   4
next -1   0  0   1  2

   我们来分析一下next值的产生过程:

“a”的前缀和后缀都为空集,共有元素的长度为0;
“ab”的前缀为[a],后缀为[b],共有元素的长度为0;
“aba”的前缀为[a, ab],后缀为[ba, a],共有元素的长度0;
“abab”的前缀为[a, ab, aba],后缀为[bab, ba, b],共有元素的长度为0;
“ababa”的前缀为[a, ab, aba, abab],后缀为[baba, aba, ba, a],共有元素为”a”,长度为1;

   由next数组的定义可知,我们可以递推出如下结论:

(1) next[0] = -1
假设next[j]=k, 即P[0, k-1]==P[j-k, j-1]
(2) 若P[j] == P[k],则有P[0, k]==P[j-k, j],很显然,next[j+1]=next[j]+1=k+1;
(3) 若P[j] != P[k],则可以把其看做模式匹配的问题,即匹配失败的时候,k值如何移动,显然k=next[k]。

  那么具体的实现可以是(JAVA):

    static void getNext(char[] p){
int[] next = new int[p.length];
next[0] = -1;

int j = 0;
int k = - 1;
while(j < p.length - 1){
// 匹配的情况下,p[j]==p[k]
if (k == -1 || p[j] == p[k]) {
j++;
k++;
next[j] = k;
} else{
//p[j]!=p[k]
k = next[k];
}
}
}

  或者可以使用直接求解(java):

    static void getNext(char[] p) {
int i, j, temp;
int[] next = new int[p.length];
for (i = 0; i < p.length; i++) {
if (i == 0) {
next[i] = -1; // next[0]=-1
} else if (i == 1) {
next[i] = 0; // next[1]=0
} else {
temp = i - 1;
for (j = temp; j > 0; j--) {
if (equals(p, i, j)) {
next[i] = j; // 找到最大的k值
break;
}
}
if (j == 0)
next[i] = 0;
}
}
}

// 判断p[0...j-1]与p[i-j...i-1]是否相等
static boolean equals(char[] p, int i, int j) {
int k = 0;
int s = i - j;
for (; k <= j - 1 && s <= i - 1; k++, s++) {
if (p[k] != p[s])
return false;
}
return true;
}

  KMP算法的思想就是:我们的原则还是从左向右匹配, 在匹配过程称,若发生不匹配的情况,如果next[j]>=0,则目标串的指针i不变,将模式串的指针j移动到next[j]的位置继续进行匹配;若next[j]=-1,则将i右移1位,并将j置0,继续进行比较。
  
  KMP的匹配过程:

搜索词:      abcdabd
匹配值(next):0000120

1、字符串和模式串(搜索词)对齐
bbc abcaab abcdabcdabdec
abcdabd

2、字符串的第一个字符为b与搜索词的第一个字符a不匹配。搜索词后移一位
bbc abcaab abcdabcdabdec
abcdabd

3、因为b与a不匹配,搜索词再往后移。
bbc abcaab abcdabcdabdec
abcdabd

4、就这样,直到字符串有一个字符,与搜索词的第一个字符相同为止。
bbc abcaab abcdabcdabdec
abcdabd

5、接着比较字符串和搜索词的下一个字符,还是相同, 直到字符串有一个字符,与搜索词对应的字符不相同为止。
即搜索词的d与字符串的空格不符。这时,最自然的反应是,将搜索词整个后移一位,再从头逐个比较。
这样做虽然可行,但是效率很差,因为你要把"搜索位置"移到已经比较过的位置,重比一遍。
bbc abcaab abcdabcdabdec
abcdabd

6、一个基本事实是,当空格与d不匹配时,你其实知道前面六个字符是"abcdab"
KMP算法的想法是,设法利用这个已知信息,不要把"搜索位置"移回已经比较过的位置,继续把它向后移,这样就提高了效率。
查表可知,最后一个匹配字符b对应的"部分匹配值"2,因此按照下面的公式算出向后移动的位数:
移动位数 = 已匹配的字符数 - 对应的部分匹配值, 即 6 - 2 等于4,所以将搜索词向后移动4位。
bbc abcaab abcdabcdabdec
abcdabd

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

8、因为空格与a不匹配,继续后移一位。
bbc abcaab abcdabcdabdec
abcdabd

9、逐位比较,直到发现c与d不匹配。于是,移动位数 = 6 - 2,继续将搜索词向后移动4位。
bbc abcaab abcdabcdabdec
abcdabd

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

  代码实现如下(java):

    static int KMPMatch(char[] T, char[] p) {
int i = 0;
int j = 0;
int next[] = getNext(p);## 标题 ##
while (i < T.length) {
if (j == -1 || T[i] == p[j]) {
i++;
j++;
} else {
j = next[j]; // 消除了指针i的回溯
}
if (j == p.length)
return i - p.length;
}
return -1;
}