本文结合了较多博主的见解的精华,融入了自己的思考,全面详细地剖析KMP算法以及next数组的运作过程,帮助大家直接痛处,分分钟拿下KMP算法。
没有学过算法的人可能是这么解决的:
public static int violentMatch(String str,String ptr){
for(int i =0;i
假设有一个文本串S,和一个模式串P,现在要查找P在S中的位置,现在文本串S匹配到 i 位置,模式串P匹配到 j 位置。
KMP算法与暴力匹配的不同之处在于,如果匹配失败(即S[i]! = P[j]),不会使 i 回溯、j 置为0,而是 i 不变,j 移动到特定位置(j = next[j])。即KMP算法充分利用了目标字符串ptr的性质(比如里面部分字符串的重复性,即使不存在重复字段,在比较时,实现最大的移动量)。
如下图,当文本串匹配到A,模式串匹配到B,A!=B,此时若两者已经匹配到的部分的前缀和后缀有相同的部分(1和2,3和4),那么此时不需要文本串往前移动一个距离,重新从头开始比较,那必然存在很多重复的比较。现在的做法是,我把模式串往前移动,使3和2对齐,直接比较C和A是否一样。
KMP算法利用了模式串在匹配过程中的前缀和后缀最大的相同部分来快速定位到模式串的位置,这个前缀和后缀最大的相同部分的长度就记录在 next 数组中。什么叫最大的相同前后缀呢,举个栗子,ABCDAB有前缀A,AB,ABC,ABCD,ABCDA,后缀有B,AB,DAB,CDAB,BCDAB,最大的相同前后缀就是AB了,长度为2。
next 数组的各个元素代表当前字符之前的字符串中,有多大长度的相同前缀后缀。例如如果next [j] = k,代表j 之前的字符串中有最大长度为 k 的相同前缀后缀。
此也意味着在某个字符失配时,该字符对应的 next 值会告诉你下一步匹配中,模式串应该跳到哪个位置(跳到 next [j] 的位置)。如果next [j] 等于0或-1,则跳到模式串的开头字符,若next [j] = k 且 k > 0,代表下次匹配跳到 j 之前的某个字符( j > next[j] ),而不是跳到开头,且具体跳过了k 个字符。
next 数组的求解看起来竟然如此简单:就是找最大对称长度的前缀后缀,然后整体右移一位,初值赋为-1(当然,你也可以直接计算某个字符对应的next值,就是看这个字符之前的字符串中有多大长度的相同前缀后缀)。(至于为什么要赋-1而不是0是为了后面操作方便,的确对一开始不理解算法的情况下不太友好)
例如:对于给定的模式串:ABCDABD,它的最大长度表及next 数组分别如下:
然实际实现时,却不似这般简单。
首先,对于字符串的首个元素组成的子串是没有相同的前后缀的,即 next[0] 赋为-1。
当有两个元素,子串的(后缀)末位索引 j=1,子串的前缀末位索引 k=0,若 p[j]=p[k],表示找到相同的前后缀,则 ++k,令 next[j]=k,代表当前最大的相同前后缀长度 +1,且下轮要匹配的前缀末位索引后移,若p[j]!=p[k]则不操作。
当有三个元素,假设前轮匹配成功,子串的(后缀)末位索引 j=2,子串的前缀末位索引 k=1,若 p[j]=p[k],由于前面已经证实 p[0]=p[1],则表示找到更长的相同前后缀,即 p[0]p[1]=p[1]p[2],则 ++k,代表当前最大的相同前后缀长度 +1 且为 2(但 k 记为2-1=1),令 next[j]=k=1,且下轮要匹配的前缀末位索引后移,且可以判断 next[j]=next[j-1]+1。若p[j]!=p[k]则需要把 k 前移找是否有(比2)更小的相同前后缀长度(这个往前找也有相应的规律,不是简单地往前,会在后面说明)。假设前轮匹配不成功,子串的前缀末位索引 k=0,进行相同的判断。
现在来看一般的情况子串的(后缀)末位索引 j,子串的前缀末位索引 k,若 p[j]=p[k],则可以证实p[0]p[1]...p[k]=p[j-k]...p[j-1]p[j],则 ++k,令 next[j]=k。到目前为止都是水到渠成的,接下来需要分析较难理解的,当p[j]!=p[k]时的操作。
//示例代码
public static int[] GetNext (char[] p,int[] next){
int[] next = new int[p.length];
int pLen = p.length;
next[0] = -1;
int k = -1;
int j = 0;
while (j < pLen - 1){
//p[k]表示前缀,p[j]表示后缀
if (k == -1 || p[j] == p[k]) {
++k;
++j;
next[j] = k;
} else {
k = next[k];
}
}
return next;
}
前面已经提到,当不等时需要减小 k,即把要匹配的位次前移,相同长度减小,那么需要如何减小,--k吗?显然没有这么简单,我们若证实p[k-1]=p[j](k没有自减),由于我们并不能得到p[0]p[1]...p[k-2]=p[j-k+1]...p[j-2]p[j-1],所以由p[k-1]=p[j]并不能得到p[0]p[1]...p[k-1]=p[j-k+1]...p[j-1]p[j],所以由 k-- 进行判断自然是可以遍历所有的k值但是断定匹配的条件过于宽松。
现在我们具体来看,如下图,已知p[0]p[1]...p[k-1]=p[j-k+1]...p[j-2]p[j-1],p[j]!=p[k],如下图所示。(p[0]...p[k]实际上是k+1个元素,前面 k 从 -1 开始也是为此)
现在要把p[0]...p[k]的(黄色+红色)部分向左压缩,p[j-k]...p[j]的(黄色部分+红色)部分向右压缩,找到一个最大的相同部分。对于黄色部分,其既是当前的前缀和后缀,同时也是模式串的一部分,p[0]...p[k]是前面已经判断过的,它也有最大的相同前后缀长度,这个长度是 next[k],我们可以利用这个特点(有一种回溯,翻旧账的感觉),黄色的部分可以再拆解为相同的前后缀(蓝色)。
再回顾现在要解决的问题:在下面图中p[0]...p[k]和p[j-k]...p[j]这两端中找到最大的相同部分。由于p[0]p[1]...p[k-1]=p[j-k+1]...p[j-2]p[j-1],可以把两端并在一起看,等效为我们可以在p[j-k]...p[j-1]p[j](后面一段)找最大的前后缀相同部分(?这你不熟悉??),再加上已经有p[j-k]...p[j-k+next[k]]和p[j-next[k]]是相同的(p[j-k]...p[j-1]的最大相同前后缀长度已经算好了是next[k]),那么我们只需要令k=next[k],判断p[j-k+next[k]](即p[next[k]])和p[j],若p[j-k+next[k]]==p[j],则长度为next[k]+1,若不等,我们可以再次使 k=next[k],不断缩小范围,直到 k=-1 返回当前子串没有相同的前后缀。
首先确定匹配结束的条件:①找到模式串,此时模式串的索引已到最后 j=pLen,要返回的结果为 i-j;②没有找到,文本串的索引已到最后 i=sLen。匹配中,若匹配成功(s[i] == p[j])令 i++,j++,继续匹配下个字符,若匹配失败,i 不变,让 j 指向当前串的最大相同前后缀长度的后面一个(文本串最大相同后缀前的内容铁定无法匹配到了,最大相同后缀后的内容,如下图中AB还可以抢救一下),恰为 next[j](next[j] 我们发现如果某个字符匹配成功,模式串首字符的位置保持不动,仅仅是i++、j++;如果匹配失配,i 不变(即 i 不回溯),模式串会跳过匹配过的next [j]个字符。整个算法最坏的情况是,当模式串首字符位于i - j的位置时才匹配成功,算法结束。 (相当于文本串中每个字符只匹配了1遍,不再回溯) 所以,如果文本串的长度为n,模式串的长度为m,那么匹配过程的时间复杂度为O(n),算上计算next的O(m)时间,KMP的整体时间复杂度为O(m + n)。而没学过算法的人的的时间复杂度是O(m*n)。 部分内容参考: 「路漫远吾求索」 「阿如的修炼之路」 July//示例代码
public static int KmpSearch (char[] s,char[] p){
int i = 0;
int j = 0;
int sLen = s.length;
int pLen = p.length;
int[] next = GetNext(p);
while (i < sLen && j < pLen){
//1 如果j = -1,或者当前字符匹配成功(即S[i] == P[j]),都令i++,j++
if (j == -1 || s[i] == p[j]) {
i++;
j++;
} else {
//2 如果j != -1,且当前字符匹配失败(即S[i] != P[j]),则令 i 不变,j = next[j]
//next[j]即为j所对应的next值
j = next[j];
}
}
if (j == pLen)
return i - j;
else
return -1;
}
2.4 KMP算法的时间复杂度