(转载)字符串匹配(KMP)

引言

简单匹配模式算法的效率不高,原因在于匹配过程中有回溯。
示例:从左到右一个个匹配,如果这个过程中有某个字符不匹配,就跳回去,将模式串向右移动一位。

我们只需要比较i指针指向的字符和j指针指向的字符是否一致。如果一致就都向后移动,如果不一致,如下图:

A和E不相等,那就把i指针移回第1位(假设下标从0开始),j移动到模式串的第0位,然后又重新开始这个步骤:

思想

S[i]!=P[j]达到失配点,主串S要回到原来的i+1的位置(回溯),模式串P要回到第一个字符的位置,然后继续比较。每次到达失配点时,串S和串P的指针i,j都要回溯,因此效率比较低。

程序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
package baoli;

public class A {

public static void main(String[] args) {
System.out.println(bf("ab1cs", "cs"));
}

/**
*
* 暴力破解法
* @param ts
* 主串
* @param ps
* 模式串
* @return 如果找到,返回在主串中第一个字符出现的下标,否则为-1
*/

public static int bf(String ts, String ps) {
char[] t = ts.toCharArray();
char[] p = ps.toCharArray();
int i = 0; // 主串的位置
int j = 0; // 模式串的位置
while (i < t.length && j < p.length) {
if (t[i] == p[j]) { // 当两个字符相同,就比较下一个
i++;
j++;
} else {
i = i - j + 1; // 一旦不匹配,i后退
j = 0; // j归0
}
}

if (j == p.length) {
return i - j;
} else {
return -1;
}
}
}

KMP算法到达失配点时,串S不需要回溯,串P也不一定要回溯到第1个字符的位置。
Next[]函数的时间复杂度为O(m),m是模式串的长度。则KMP算法的时间复杂度为O(m+n),其中n是主串的长度。
KMP 算法的主要特点是:

  • 需要对模式字符串做预处理。
  • 预处理阶段需要额外的O(m)空间和复杂度。
  • 匹配阶段与字符集的大小无关。
  • 匹配阶段至多执行2n - 1次字符比较。
  • 对模式中字符的比较顺序时从左到右。

    概念

    字符串匹配是计算机的基本任务之一。
    举例来说,有一个字符串”BBC ABCDAB ABCDABCDABDE”,我想知道,里面是否包含另一个字符串”ABCDABD”?

    许多算法可以完成这个任务,Knuth-Morris-Pratt算法(简称KMP)是最常用的之一。它以三个发明者命名,起头的那个K就是著名科学家Donald Knuth

    这种算法不太容易理解,网上有很多解释,但读起来都很费劲。直到读到Jake Boxer的文章,我才真正理解这种算法。下面,试图写一篇比较好懂的KMP算法解释。

    首先,字符串”BBC ABCDAB ABCDABCDABDE”的第一个字符与搜索词”ABCDABD”的第一个字符,进行比较。因为B与A不匹配,所以搜索词后移一位。

    因为B与A不匹配,搜索词再往后移。

    就这样,直到字符串有一个字符,与搜索词的第一个字符相同为止。

    接着比较字符串和搜索词的下一个字符,还是相同。

    直到字符串有一个字符,与搜索词对应的字符不相同为止。

    这时,最自然的反应是,将搜索词整个后移一位,再从头逐个比较。这样做虽然可行,但是效率很差,因为你要把”搜索位置”移到已经比较过的位置,重比一遍。

    一个基本事实是,当空格与D不匹配时,你其实知道前面六个字符是”ABCDAB”。KMP算法的想法是,设法利用这个已知信息,不要把”搜索位置”移回已经比较过的位置,继续把它向后移,这样就提高了效率。

    怎么做到这一点呢?可以针对搜索词,算出一张《部分匹配表》(Partial Match Table)。这张表是如何产生的,后面再介绍,这里只要会用就可以了。

    已知空格与D不匹配时,前面六个字符”ABCDAB”是匹配的。查表可知,最后一个匹配字符B对应的”部分匹配值”为2,因此按照下面的公式算出向后移动的位数:
    1
    移动位数 = 已匹配的字符数 - 对应的部分匹配值

因为 6 - 2 等于4,所以将搜索词向后移动4位。

因为空格与C不匹配,搜索词还要继续往后移。这时,已匹配的字符数为2(”AB”),对应的”部分匹配值”为0。所以,移动位数 = 2 - 0,结果为2,于是将搜索词向后移2位。

因为空格与A不匹配,继续后移一位。

逐位比较,直到发现C与D不匹配。于是,移动位数 = 6 - 2,继续将搜索词向后移动4位。

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

下面介绍《部分匹配表》是如何产生的。
首先,要了解两个概念:”前缀”和”后缀”。”前缀”指除了最后一个字符以外,一个字符串的全部头部组合;”后缀”指除了第一个字符以外,一个字符串的全部尾部组合。

“部分匹配值”就是”前缀”和”后缀”的最长的共有元素的长度。以”ABCDABD”为例,

1
2
3
4
5
6
7
- "A"的前缀和后缀都为空集,共有元素的长度为0。
- "AB"的前缀为[A],后缀为[B],共有元素的长度为0。
- "ABC"的前缀为[A, AB],后缀为[BC, C],共有元素的长度0。
- "ABCD"的前缀为[A, AB, ABC],后缀为[BCD, CD, D],共有元素的长度为0。
- "ABCDA"的前缀为[A, AB, ABC, ABCD],后缀为[BCDA, CDA, DA, A],共有元素为"A",长度为1。
- "ABCDAB"的前缀为[A, AB, ABC, ABCD, ABCDA],后缀为[BCDAB, CDAB, DAB, AB, B],共有元素为"AB",长度为2。
- "ABCDABD"的前缀为[A, AB, ABC, ABCD, ABCDA, ABCDAB],后缀为[BCDABD, CDABD, DABD, ABD, BD, D],共有元素的长度为0。


“部分匹配”的实质是,有时候,字符串头部和尾部会有重复。比如,”ABCDAB”之中有两个”AB”,那么它的”部分匹配值”就是2(”AB”的长度)。搜索词移动的时候,第一个”AB”向后移动4位(字符串长度-部分匹配值),就可以来到第二个”AB”的位置。

思想

部分匹配表

部分匹配表就是程序代码里的next[]next[]里存的是模式串(搜索词)P的每个字符的最大k值。k值是字符串匹配失败时,字符需要回到什么位置的值。(回溯的值)k值部分匹配表的值
既然是部分匹配表的数据,那next中的k值也满足“前缀”和“后缀”共有元素的长度:P[0 ~ k-1] == P[j-k ~ j-1](头部k长度的字符与尾部k长度的字符是相等的)
比如:ABCDAB的前缀为[A, AB, ABC, ABCD, ABCDA],后缀为[BCDAB, CDAB, DAB, AB, B],共有元素为”AB”,长度为2;既k的值是2。
如图:

因为:当T[i] != P[j]时,有T[i-j ~ i-1] == P[0 ~ j-1](表示:在失配点,之前的j个字符都是匹配的),由P[0 ~ k-1] == P[j-k ~ j-1](在k点失配,前缀和后缀的长度相等)
必然:T[i-k ~ i-1] == P[0 ~ k-1],下一趟匹配过程可以从T串的i、P串的k开始。
这里通过数学公式推导出来T[i-k ~ i-1] == P[0 ~ k-1],这就是模式串(搜索词)移动到k点,而不需要比较k之前的字符串了。

如何计算Next[]

如何计算next[]?因为在P的每一个位置都可能发生不匹配,也就是说我们要计算每一个位置j对应的k,所以用一个next[]来保存,next[j] = k,表示当T[i] != P[j]时,j指针的下一个位置。

k值定义


k值被定义为:字符串失配时,字符串相同的前、后缀子串长的最大值。
公式的含义:当匹配在S[i]P[j]失效时,j应该回溯的位置k(next[j])。next[j]>=0表示下一趟从S[i]P[k]P[next[j]])开始;next[j]=-1,下一趟从S[i]P[0]开始。

Next的核心思想

j=0,next[k]=-1
现在要始终记住一点,next[j]的值(也就是k)表示,当P[j] != T[i]时,j指针的下一步移动位置。先来看第一个:当j为0时,如果这时候不匹配,怎么办?

j=1,根据第3个公式,k=0。S串中的i不回溯,j回溯到k(next[1]=0),则又是不匹配;j=0,next[0]=-1,k=-1,相当于P的0位置之前有个-1,这个-1位置与S串的i肯定不会匹配(-1位置实际上是不存在),则S串和P串分别+1重新开始(如第3图所示)。next[0] = -1。这也是一种特殊情况。
j=0,next[k]=-1
对于next[1],请看灰色部分,在第二组中,因为W[1](图中的a)前面只有一个字符(b),所以只要W[1]不匹配,不管W[0]是不是蓝色,W总是会滑到开头的。所以next[1] == 0总是成立的,这是一种特殊情况。你可以顺着灰色条看上去(注意是灰色部分哦),图1中c对应W的位置1在图2是不是变成对应0了。
j指针一定是后移到0位置的。因为它前面也就只有这一个位置
next[j+1] == next[j] + 1


我们发现一个规律:
P[k] == P[j]时,有next[j+1] == next[j] + 1j+1的回溯点k1和k+1是一个点,因为P[k]=P[j])代码里就是这句:next[j] = k;
其实这个是可以证明的:因为在P[j]之前已经有P[0 ~ k-1] == p[j-k ~ j-1]。(next[j] == k)这时候现有P[k] == P[j],我们是不是可以得到P[0 ~ k-1] + P[k] == p[j-k ~ j-1] + P[j]
即:P[0 ~ k] == P[j-k ~ j],即next[j+1] == k + 1 == next[j] + 1
P[k] != P[j]

像这种情况,如果你从代码上看应该是这一句:k = next[k]

p[j]p[k]不匹配,这时j回溯到k位置,没有意义了,因为本身就不匹配了,那么在k位置再往前回溯,k=next[k]
注:另一种图解(图片挺好解释的,文字比较难理解)

对于长度为k的头部子字符串,他可能有图中蓝色部分的前缀和后缀。注意,如果图中头部的黄色表示的字符与下标j表示的字符相等,那么,下标j找到了他的最长前缀了头部黄色表示的字符的下标就是next[k]。因为next[k]表示的就是字符串长度为k的头部字符串的最长前缀和后缀。

优化

next[]函数可以通过改进之后,进一步提高效率。

显然,当我们上边的算法得到的next[]应该是[ -1,0,0,1 ],所以下一步我们应该是把j移动到第1个元素:

这一步是完全没有意义的。因为后面的B(j=3)已经不匹配了,那前面的B(j=1)也一定是不匹配的。
显然,发生问题的原因在于P[j] == P[next[j]]。加上一个判断条件就可以,当P[j]=P[k]的时候,需要跳过P[k],回溯到k的上一个回溯点next[k],即:next[j] = next[k];

计算

j 0 1 2 3 4 5 6 7
P a b c a b c a b
next[j]=k -1 0 0 0 1 2 3 4

模式串(abcabcab)
f(4)=next[4],应该在其前串(abca)找到k。abca的前缀:{a,ab,abc};后缀:{bca,ca,a}。前后缀相同的最大长度是1,即f(4)=next[4]的k值是1。

程序代码

Java版本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
package kmp;

public class A {

public static void main(String[] args) {
System.out.println(KMP("1221yu12", "yu"));
}

public static int KMP(String ts, String ps) {
char[] t = ts.toCharArray();
char[] p = ps.toCharArray();
// 主串的位置
int i = 0;
// 模式串的位置
int j = 0;
//j回溯的位置
int[] next = getNext(ps);
while (i < t.length && j < p.length) {
// 当j为-1时,要移动的是i,当然j也要归0
//2个字符串相匹配时,i++,j++
if (j == -1 || t[i] == p[j]) {
i++;
j++;
} else {
//2个字符串不匹配时候, i不需要回溯了, j回到指定位置
j = next[j];
}
}
//模式串完全匹配,返回S串前j的开始位置
if (j == p.length) {
return i - j;
} else {
return -1;
}
}

// 获取next数组(部分匹配表)
public static int[] getNext(String ps) {
//P字符数组
char[] p = ps.toCharArray();
int[] next = new int[p.length];//部分匹配表
//j=0,k=-1,因为j=0,左边没有数字,现在假设左边有个-1,j回到-1,对应S串中的i,相当于P串的j=j+1,相当于P串的j=j+,也相当于++j和++i
next[0] = -1;
//初始化P串的j
int j = 0;
//初始化k
int k = -1;
//循环匹配P串
while (j < p.length - 1) {
//P串的k前缀和后缀相同时
if (k == -1 || p[j] == p[k]) {
//
if (p[++j] == p[++k]) {
next[j] = next[k];
} else {//不相同,当两个字符相等时要跳过k,因为P[++j]不匹配S串,P[++k]也同样不匹配S串,这回溯到k的下一个点,重新匹配
next[j] = k;//不相同,j回溯到k位置,比如2个B的例子
}
} else {//p[j]和p[k]不匹配,这时j回溯到k位置,没有意义了,因为本身就不匹配了,那么在k位置再往前回溯,k=next[k]
k = next[k];
}
}
return next;
}
}

评论

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×