引言
简单匹配模式算法的效率不高,原因在于匹配过程中有回溯。
示例:从左到右一个个匹配,如果这个过程中有某个字符不匹配,就跳回去,将模式串向右移动一位。
我们只需要比较i指针指向的字符和j指针指向的字符是否一致。如果一致就都向后移动,如果不一致,如下图:
A和E不相等,那就把i指针移回第1位(假设下标从0开始),j移动到模式串的第0位,然后又重新开始这个步骤:
思想
在S[i]!=P[j]
达到失配点,主串S要回到原来的i+1
的位置(回溯),模式串P要回到第一个字符的位置,然后继续比较。每次到达失配点时,串S和串P的指针i,j都要回溯,因此效率比较低。
程序
1 | package baoli; |
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] + 1
(j+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 | package kmp; |