KMP算法原理描述,告诉你为什么要“j = next[j]”
研究KMP算法的起因,是在刷leetcode的 214.最短回文串时,一开始使用了 O ( n 2 ) O(n^2) O(n2)时间复杂度的暴力算法,结果在一条 n = 40000 n=40000 n=40000的测试数据上超时了,后来在题解的启发下,用了KMP算法双95%+成功过了该题。但是在用KMP算法的时候,对于next数组的计算方式,尤其是回退的那一步始终没想明白,看了许多博客也没有描述清楚,当初上课学习KMP算法的时候对这块就是一知半解,今天花了点时间,终于算是把最令人困惑的"j = next[j]"这里搞清楚了。
0. KMP算法简介
KMP 算法是 一种字符串匹配算法,D.E.Knuth、J,H,Morris 和 V.R.Pratt 共同提出的,称之为 Knuth-Morria-Pratt 算法,简称 KMP 算法。该算法相对于暴力算法的最核心优化点是在匹配过程中,通过某些规则移动模式串指针,使得主串指针可以保持不回退,从而提升匹配效率。
1. 暴力算法的缺陷
在暴力算法中,当主串的某一段与模式串的前i-1位相同,但第i位不同时,本次匹配失败。模式串会向后移动一位、指针回退到0,而主串的指针也会回退到与模式串首位相同的位置上,如下图所示:
每次主串指针都会回退到上一次开始地方的后一位,模式串的指针也会回退到0,这样的时间复杂度是 O ( m ∗ n ) O(m * n) O(m∗n)。
2. KMP算法的核心概念:最长公共前缀子串
当一次匹配失败后,我们每次将模式串右移一位,并且将主串和模式串的指针全部回溯,其实是浪费了我们在本次匹配中已经发现的一些经验。考虑下面的一种情况:
如图,在这种情况下,我们可以将原来模式串头部深绿色的部分,挪动到模式串没有匹配成功的红色前面的深绿色部分去,这样主串的指针就不用回退了,模式串的指针也不再需要回退到开头的位置了。
模式串中,重复的两个深绿色的部分具有这样的特点:
1. 第一个深绿色的部分,从模式串的头部开始。为什么是从模式串的头部开始?因为我们要保证,模式串右移后,和主串仍有一部分是匹配的,我们可以从模式串的这个前缀后面继续判断,这是模式串指针不需要回退到开头的原因;
2. 第二个深绿色的部分,以模式串当前未匹配成功的位置为结尾。为什么是以模式串当前未匹配成功的位置为结尾?因为只有这样,当我们右移模式串,使得这两个深绿色的部分“重合”之后,我们仍可以从主串出错的位置(红色部分)和模式串(第一个深绿色部分后的下一个字符)进行匹配,这是主串指针不需要回退的原因。
至此,KMP算法的核心概念已经呼之欲出了:深绿色的部分,就是浅绿色部分的“最长公共前缀子串”。“公共”,代表着两个深绿色部分的内容相同;“前缀”,代表着从模式串的头部开始。
3. 恼人的“next数组”
你可能已经意识到了,“最长前缀公共子串”和主串无关,它完全是模式串的一个属性。而KMP算法求“最长前缀公共子串”的方法,使用的就是“next数组”的概念,这个也是大多数人在学习KMP算法上面最大的一个坎。next数组的概念如下:
next[i] = j,即模式串的前i个字符组成的字符串中,最长前缀公共子串的长度为j。
是不是有点困惑?看看下面的例子:
在上一个例子中,next数组元素的值分别为:
next[0] = 0;
next[1] = 0;
next[2] = 0;
next[3] = 0;
next[4] = 1;
next[5] = 2;
next[6] = 0;
再抽象一点,next数组的含义是这样的:
emmm,与其叫它”next“数组,还不如叫它”最长公共子串前缀长度数组“呢。。。
那么问题来了,怎么求这个next数组呢?
根据我们前面分析的next数组的定义,可以找到下面的规律:
-
如果新加入的字符与前一个最长公共前缀子串的后一个字符相同:
这里的深绿色部分长度是可以为0的,对后面计算没有影响。
这种情况下,很简单,next[i] = next[i-1] + 1。这个很好理解,相信大家一看就能想到。 -
如果新加入的字符与前一个最长公共前缀子串的后一个字符不同:
很多人看KMP算法就是卡在了这一步上,我最开始看的时候也是对这个地方百思不得其解,在其他人的代码中,那个最让人头痛的回退操作——j = next[j],就是在这里出现的。为了说明白这个问题,我们看下这张图:
在这种情况下,原来的深绿色部分就不能用了,显然,我们要缩小深绿色的部分,还要满足最长公共前缀子串的要求,也就像下面这个样子:
这个蓝色的部分怎么求呢?这就是很多博客和代码里面没有说清楚的,最让人头痛的回退操作,就是那个臭名昭著的”j= next[j]“。
我们换一种理解方式,这个蓝色的部分,首先要内容相同,其次还要位于深绿色部分的开头和结尾,这不就是深绿色部分的最长公共前缀子串吗?
也就是这样:
更新j = next[j]后,又回到这个问题的起点了,然后再次判断s[i-1]和s[j]是否相同就可以了。
至此,我们终于搞懂那个让人费解的回退操作到底是什么意思了,其实就是在当前的公共前缀已经不能用了,那就继续去尝试这个公共前缀的公共前缀,一直试到成功或者公共前缀的长度为0时为止。
4. Java实现
最后贴个Java实现代码。注意模式串长度小于2就没有必要用KMP了,这里简单先不考虑了,要抄代码的话记得检查,小心数组越界哦:
/**
* 模式串长度小于2就没有必要用KMP了,这里简单先不考虑了,要抄代码的话记得检查,小心数组越界哦
*/
private int[] getNext(String s) {
int[] next = new int[s.length()];
//前0个字符,肯定没有最长公共前缀子串
next[0] = 0;
//前1个字符,那也没有嘛
next[1] = 0;
//上一个最长公共前缀子串长度
int subPublicPreSubStrLength = 0;
for (int subStrLength = 2; subStrLength < s.length(); ) {
//需要求前i个字符组成的字符串中的前缀子串,这个是新加入尾部的字符
char addedSubStrChar = s.charAt(subStrLength - 1);
//位于最长公共前缀子串后的字符
char addedPreSubStrChar = s.charAt(subPublicPreSubStrLength);
if (addedSubStrChar == addedPreSubStrChar) {
//新加入的字符和之前的最长公共前缀子串后的字符相同
//最长公共子串是在前一个的基础上,再加一个字符
next[subStrLength] = ++subPublicPreSubStrLength;
//继续求i+1个字符组成的字符串中的前缀子串
subStrLength++;
} else {
//新加入的字符和之前最长公共前缀子串后的字符不同,说明之前的公共子串已经不能用了
if (subPublicPreSubStrLength == 0) {
//前一个公共子串长度已经为0了,那说明当前情况下的公共子串也只能是0了
next[subStrLength] = 0;
//继续求i+1个字符组成的字符串中的前缀子串
subStrLength++;
} else {
//继续求前一个前缀子串的前缀子串,这里就是那个最让人搞不懂的,k = next[k]的回退
subPublicPreSubStrLength = next[subPublicPreSubStrLength];
}
}
}
return next;
}
本来可以写的很简洁,但是我特意把各个变量的命名写的清楚一些,配合注释希望大家能看懂。建议大家要是抄代码的话,可以换个其他人的写法来抄,我这个为了看清楚写的很冗长,很多地方可以优化调整,主要是能看明白思路就好。