KMP算法是一种改进的字符串匹配算法,由D.E.Knuth,J.H.Morris和V.R.Pratt提出的,因此人们称它为克努特—莫里斯—普拉特操作(简称KMP算法)
首先介绍一下模式匹配
模式匹配有两个字符串S和T,设S是主串,T为模式串,在主串S中查找与模式T相匹配的子串,若匹配成功,确定相匹配的子串中的第一个字符在主串中出现的位置。
首先是暴力匹配算法也就是BF算法
BF算法
BF算法非常简单,就是一个一个比过去。
简单来说就是定义两个指针i与j。i指针指向S,j指针指向T
然后分以下情况讨论
情况一:如果s[i]==t[j]的时候,此时i+=1,j+=1
情况二:如果s[i]!=t[j]的时候,此时i=i-j+1,j=0
如果此时j==lent,则返回i-j的位置
也就是说,相等的时候i和j指针均往有移动,如果不相等(称之为失配)的情况,则i指针回溯到之前开始比对的位置的下一个位置,j指针回溯到0。
对应的Python代码如下
S=input()
T=input()
def find(S,T):
i=0
j=0
lens=len(S)
lent=len(T)
while i<lens and j<lent:
if S[i]==T[j]:
i+=1
j+=1
else:
i=i-j+1
j=0
if j==lent:
return i-j
print(find(S,T))
KMP算法
很明显,暴力法有一个致命的弱点,就是时间复杂度太高。时间复杂度为O(mn),m为模式串T的长度,n为主串S的长度
那么,如何能够优化呢,这就是KMP算法了。KMP算法的时间复杂度是O(m+n),m为模式串T的长度,n为主串S的长度。
那么具体它是怎么做的呢?实际上KMP算法看起来复杂,本质还是通过BF算法来做的,只是它减少了比较次数,核心是通过利用之前得到的结果。
首先和BF算法一样,还是两个指针i和j分别指向主串S和模式串T,如果S[i]==T[j]的时候,那么i+=1,j+=1这里是不变的。
但是如果不相等的话,那么实际上KMP算法i指针是不动的,而是移动j指针。
假设有一个函数f,它能够告诉我们j指针移动到哪里,那么这样实际上这样就能减少比较次数了。
根据上述的思想我们能够写出KMP算法的基本代码
def KMP(S,T):
i=0
j=0
lens=len(S)
lent=len(T)
while i<lens and j<lent:
if S[i]==T[j]:
i+=1
j+=1
else:
j=f[j]
if j==lent:
return i-j
虽然上述代码存在着一定的问题,但是我们确实可以写出这样的代码。值得注意的是,上面说的是函数f,这里是数组,KMP算法当中这个函数实际上就是数组。
这个数组实际上有一个更为标准的说法就做next数组,下面的问题就是如果得出这个next数组
next数组与最长公共前后缀
首先我们来看一个例子
假设上面 两个字符串,在某次执行的时候绿色部分代表了已经匹配的,而在黑色和红色部分代码了失配的部分。
如果按照BF算法,那么一定是i回溯到i=i-j+1,j回溯到j=0,但是KMP算法上文说了,不让i回溯,就让j指针回溯,实际上j回溯这一做法,可以理解为移动了T模式串
如上图来说,此时下一次比较的将会是S[i]和T[j']
那么我们实际上可以认为S[i-j:i-1]这一部分和T[0:j-1]这一部分是比较过的,这就是上文说的减少了比较次数。也就是相当于移动了T模式串。
那么这个移动是怎么移动的呢?或者从程序设计角度来说,这个j指针到底要回溯到哪个位置呢?
上面的这个模式串比较简单,我们用下面这个例子来介绍
假设此时黄色部分相当于匹配了的,而红色部分和下面的绿色部分是不匹配的,那么根据KMP的思想,我们应当将j指针进行回溯。
假设这两个字符串为
ABABABASWB
ABABAS
此时我们对j指针进行回溯
j指针应该回溯到下标3的位置,至于为什么后面会说。
这样的过程等价于移动了模式串T,然后我们惊讶的发现j指针只是简单的回溯了一下,竟然匹配了。这虽然也有一定的偶然因素,但是我们发现这种方式确实可以降低时间复杂度。
下面我们来谈一下原因。
注意:为了方便说明,虽然Python中的字符串切片是右开的,但是这里统一认为是全闭区间的
还是失配的情况,但是根据这个情况我们可以得到一个非常有价值的线索。
也就是说当前主串S[0:i-1]和T[0:j-1]是完全相同的。那么我们可以利用这一条件。
首先观察模式串
这里取失配地方的前的子串
我们发现T[0:2]和T[2:4]是完全相同的,而上文可知S[0:i-1]和T[0:j-1]是完全相同的。也就是说
T[2:4]==S[2:4],而我们知道T[0:2]又是和T[2:4]是相同的,那么我们根据等式的传递原理,我们知道S[2:4]和T[0:2]是相等的,那么既然相等,我们还需要比较嘛,当然不需要了,因此我们把j指针移到第3个位置,这样T[0:2]和S[2:4]这一部分就不需要比较啦。
显然,根据上文我们也知道这个模式串如果前后相同的部分越长自然越好。跳过的越多。
这个相同的部分我们取一个名字叫做公共前后缀。自然要找最长的,也就是最长公共前后缀。
首先
这里要明确前缀和后缀的概念
前缀就是说以第一个字符开始的,不包括最后一个字符的子串
后缀就是说以最后一个字符的结尾的,不包括第一个字符的子串
eg:
hello字符串的前缀有
h,he,hel,hell
后缀有
o lo llo ello
注意了,后缀是以最后一个字符结尾的,不说是以最后一个字符开头的,我刚开始学的时候就是把回文和后缀搞错了。
上文的例子ABABA的最长公共前后缀的长度是3
我们可以通过手算得出
前缀有A、AB、ABA、ABAB,后缀有A、BA、ABA、BABA,显然最长的公共前后缀就是ABA,长度是3。
到此为止,你应该对最长公共前后缀的定义和其强大作用有一定的了解了。
那么,但是还没有完,反而最痛苦的来了,怎么计算这个最长的公共前后缀。这里就要用到Next数组了。
首先能不能用之前的方法。首先一个循环从1开始到n-1,然后依次判断。
T=input()
Max=0
n=len(T)
for lens in range(1,n-1):
if T[0:lens]==T[n-lens:n]:
Max=max(lens,Max)
print(Max)
当然是可以的,但是你看一下时间复杂度外层循环是n,而比较也是n,所以是,当然了如果使用字符串哈希可以使得比较的时间复杂度降为O(1),但是字符串哈希本身要计算哈希数组,因此也比较麻烦,这里提高一个简单的,类似于动态规划中的当前状态可以由前一个状态来推出。也就是递推法求字符串的最长公共前后缀。
讲到这里可以引出next数组的概念了。
定义next[i]为模式串T[0:i-1]子串的最长公共前后缀。注意是i-1哦,不包括i这个字符。
为什么要这么定义,实际上是为了方便使用,因为当j处失配的时候,j回溯的位置实际上是由j前的字符串的最长公共前后缀决定的。
因此,一般会对next数组和最长公共前后缀有一个变形。
也就是说
是如图上的关系,如果要求子串S[0:i]的最长公共前后缀需要访问next[i+1]数组。
那么next[0]为啥要赋值为-1呢,这实际上是为了KMP算法方便操作。什么意思,这个后面会介绍。
下面我们介绍一下next数组递推法求出整个字符串的最长公共前后缀的方法
第一步定义两个指针i和j,首先i指针的初值为0,含义是指向后缀的最后一个字符,j指针初值为-1,代表了指向前缀的最后一个字符(准确来说是与后缀相等的前缀的最后一个字符,不是说最长的那个前缀的字符)。
刚开始的时候由于j=-1要进行特判。
if j==-1:
Next[1]=0
上述代码的意思是j==-1是代表初次的时候,初次的时候显然是求next[1],也就是T[0]这个子串的最长公共前后缀,此时显然一个字符的最长公共前后缀一定是0
然后这里要让j指针和i指针都要右移。这样就可以使用i指针和j指针的含义了。
下一次求next[2],next[2]代表了T[0:1]这个子串的最长公共前后缀。
还是上面的例子就是
AB
此时j指针指向的是0,i指针指向的是1,但是T[i]!=T[j]的,因此不能对next[2]直接赋值,要进行一定的处理。这个处理就是对于这个j,要让
j=Next[j]
由于现在j的值是0,因此next[0]=-1,因此j==-1,又是开始这个条件,但是这里不能再是next[1]=0了,而应该是next[2]=0,也就是说实际上上面这个条件的正确写法应该是
if j==-1:
i+=1
j+=1
Next[i]=j
这段代码的意思很简单,也就是说j==-1有2层含义,第一层含义是刚开始next[1]=0,也就是一个字符的最长公共前后缀肯定是0,第二层含义就是j==-1代表了没有最长的公共前后缀,那么此时next[i+1]=0也是0,为什么i也要+1呢,因为next数组的含义啊,此时i指向的是后缀的最后一个字符,说白了后缀的最后一个字符就是该字符串的最后一个字符,那么根据next数组的含义,next[i+1]才是前i个字符的最长公共前缀,因此i也要+1。
然后我们继续对next[3]求解,也就是求T[0:2]这个字符串的最长公共前后缀,上文的例子就是ABA
此时j的值是0,i的值是2,显然T[i]==T[j],此时我们知道,这个j指向的是与后缀相同的最长的前缀的末尾即最后一个字符,根据字符串0开始的性质,长度我们很快得出是j+1,同样的因为next数组的含义此时i指向的是后缀的最后一个字符也是该字符串的最后一个字符,因此i也要+1
if T[i]==T[j]:
i+=1
j+=1
Next[i]=j
我们发现实际上上面两个代码执行的语句是相同的,因此我们可以合并。
if T[i]==T[j] or j==-1:
i+=1
j+=1
Next[i]=j
这样我们就完成了一部分了。
这段代码的意思就是说因为j指向的是当前字符串的前缀的最后一个字符,因此显然长度为j+1,而i指向的是当前字符串的后缀的最后一个字符,因此next数组的定义告诉我们i也要+1,那么为啥不是next[i+1]=j+1,这个也可以,但是结束后i和j还是要+1,也就是说i和j都要右移这是什么原因,很简单因为后面字符串会变长,那么其前缀和后缀自然也会变长,而且j指针指向的是与后缀相同的最长的前缀的末尾,只是当前来看它是最长的,可能再字符串变长后导致后缀的最后一个字符改变,也许j指针后面的这个字符和这个新后缀的最后一个字符相同,那么显然这个最长公共前后缀可以更长,所以j要+1,i也要+1。
目前我们处理相等的情况,那么不相等呢?为什么只需要让j=next[j]即可。这里很多资料都是说让j回溯,没有说明具体原因,这个也是苦恼我很久的问题。网上很多回答都是认为这也是一个KMP匹配过程,实际上确实是这样,但是我觉得这样说不太好理解,我们看下面这个例子。
假设这是一个字符串,我们要求它的最长公共前后缀,假设我们已经知道了T[0:7]的最长公共前后缀。
假设目前的最长公共前后缀是绿色部分和红色部分,长度为4,那么显然我们这次要比较的时候,i和j指向的位置是
此时T[j]!=T[i],也就是说这个时候这个最长的公共前后缀一定是比5小的,因为j此时指向的是这个前缀的最后一个字符,i指向的是后缀的最后一个字符,它们不相等,那么前面得出来的那四个字符不可能同时都是这个字符串的最长公共前缀的前面部分,也就是必须去掉这四个字符的后面几个,因为前缀一定包括了第0个字符,后缀一定要包括这个最后一个字符,那么,此时的做法用一句话概括就是退而求其次。 既然这个长度5不能满足,那么我们只能找别的,而且一定比5小。
假设我们找到了这个合适的最长公共前后缀的长度。
如图中的黑色部分,长度是3,那么我们此时要做的是i指针不能动,因为后缀一定包括了最后一个字符,那么只能动j指针。
此时我们需要让j指针移动到黑色部分的下一个字符。
这样如果此时j‘指向的字符和i指针指向的字符相等的话那么最长公共前后缀就是4了。那么这个最长合适的长度是怎么找到的?难道一定是当前最长前后缀长度-1吗?答案是否定的。
那么原因是为啥呢?
实际上也是利用了最长公共前后缀的性质,什么性质,相等啊
还是上面的例子,绿色部分和红色部分是相等的,因此 我们现在要找的是合适的且是最长的,那么红色部分后面部分是不是和绿色部分的后面部分是相同的,我们要找的便是从绿色部分中找出满足条件的最长的那一段,那么既然红色部分和绿色部分是相同的,那么是不是就是等价于求绿色部分的最长公共前后缀。
还是举一个例子
我们知道绿色部分的字符串ABAB和红色部分的字符串ABAB相等,我们无外乎就是要找到红色部分的ABAB中后面几个能和绿色部分ABAB中前面几个相等的最长长度。既然绿色部分和红色部分是相同的,那么红色部分的ABAB的后面几个和绿色部分的后面几个不是一个意思吗?那么就变成了找绿色部分中的前面几个和绿色部分中的后面几个最长的相等的部分。绿色部分中的前面几个不就是前缀嘛,绿色部分中的后面几个不就是后缀嘛,要求最长的相等部分,那么就是最长的公共前后缀啊。
而这里我们知道此时j指针指向的位置是绿色部分的后一个字符,其next数组的值就是绿色部分的最长公共前后缀,于是就有j=next[j]了
于是我们的这个Next数组的求法就有了。
def getNext():
i=0
j=-1
Next=[0]*(n) #n是字符串T的长度
Next[0]=-1
while i<n-1:
if j==-1 or T[i]==T[j]:
i+=1
j+=1
Next[i]=j
else:
j=Next[j]
return Next
T=input()
n=len(T)
print(getNext())
输出结果
好像有一点区别,就是最后一个整个字符串的没有输出,因为一般来说整个字符串的在KMP进行的时候是用不到的。当然决定因素还是这个while 循环条件,它最终只处理了Next[n-1]也就是说是前面几个字符的字符串的最长公共前缀,因为完整的字符串的最长公共前缀实际上是在KMP算法中没有用的,如果想要输出可以对这个T进行处理,给他加上一个$符号或者改变循环条件去掉-1
看似全部讲完了实际上还没有,Next[0]=-1还没有解释,上文虽然也有提到,就是说如果j指针回溯到0的位置的时候还不能相等,那么就说明不存在公共的前缀了此时Next[0]=-1可以告诉程序不存在公共前缀,把它赋值给j就行。
但实际上这不是Next[0]=-1的全部作用,还有一个作用在于KMP算法
回顾上文中的KMP算法
def KMP(S,T):
i=0
j=0
lens=len(S)
lent=len(T)
while i<lens and j<lent:
if S[i]==T[j]:
i+=1
j+=1
else:
j=f[j]
if j==lent:
return i-j
我说过这个KMP算法是有问题的,问题在于如果j回溯了还是失配,那么就接着回溯,如果一直回溯到0都还是失配的话,说明了i指针要后移,j指针要从0开始比较,此时-1的效果就体现了,此时j==-1,我们对第一个if语句的条件做一个变形
def KMP(S,T):
i=0
j=0
lens=len(S)
lent=len(T)
while i<lens and j<lent:
if S[i]==T[j] or j==-1:
i+=1
j+=1
else:
j=f[j]
if j==lent:
return i-j
变成上面的代码,这才是KMP算法正确的代码实现,j==-1的时候也执行i+=1,j+=1,此时i指针后移了,j=0。
完整KMP算法实现
def getNext():
i=0
j=-1
Next=[0]*(lent)
Next[0]=-1
while i<lent-1:
if j==-1 or T[i]==T[j]:
i+=1
j+=1
Next[i]=j
else:
j=Next[j]
return Next
def KMP(S,T):
i=0
j=0
while i<lens and j<lent:
if j==-1 or S[i]==T[j]:
i+=1
j+=1
else:
j=Next[j]
if j==lent:
return i-j
S=input()
T=input()
lens=len(S)
lent=len(T)
Next=getNext()
print(KMP(S,T))
模板题
该题为KMP的模板题,只需要进行一点变换即可。
def getNext():
i=0
j=-1
Next=[0]*(lent)
Next[0]=-1
while i<lent-1:
if j==-1 or T[i]==T[j]:
i+=1
j+=1
Next[i]=j
else:
j=Next[j]
return Next
def KMP(S,T):
i=0
j=0
global ans
while i<lens and j<lent:
if j==-1 or S[i]==T[j]:
i+=1
j+=1
ans=max(ans,j)
else:
j=Next[j]
S=input()
T=input()
lens=len(S)
lent=len(T)
ans=0
Next=getNext()
KMP(S,T)
print(ans)
好了,这就是我对于KMP算法的全部理解。当然了网上还有一些文章的代码和我这个不太一样,但是本质思想是一样的。