前言
何谓KMP算法?如果你作为一名新人,就跟游戏刷图打怪一样,当你经历新手任务之后,好不容易接触到算法中阶门槛的时候,KMP算法就是你必须要啃下的硬骨头,它和Manacher算法一起被誉为算法的左右门神,差不多就是个地位,它解决的是字符串匹配问题,由D.E.Knuth,J.H.Morris和V.R.Pratt共同提出,尤其是Morris大神,后面我们还会接触到Morris遍历:一种骚的不要不要的二叉树遍历算法。闲话不多说,今天呢,我们就先来看看KMP算法。
暴力解
开始讲解之前,我们先来看这么一个问题: 假设字符串str长度为N,字符串match长度为M,M <= N,想确定str中是否有某个子串是等于match的。
首先我们先来看暴力求解的过程,就是遍历str,以str每个位置的字符作为开头,去尝试能不能匹配出match串。具体过程如代码所示:
// 暴力方法,以每个位置作为开头去尝试看看能不能匹配出子串
// 如果可以,返回与之匹配的开头位置,不能匹配就返回-1
// 比如str:abcdaa match:cda 可以匹配,与之匹配的开头位置是2
// 时间复杂度 O(N * M)
public static int func(String str, String match) {
char[] str1 = str.toCharArray();
char[] str2 = match.toCharArray();
int N = 0;
int M = 0;
while (N < str1.length && M < str2.length) {
// 当前字符匹配成功
if (str1[N] == str2[M]) {
N++;
M++;
} else { // 匹配失败,去到下一个位置继续尝试
N = N - M + 1;
M = 0;
}
}
if (M == str2.length) {
return N - M;
} else {
return -1;
}
}
很明显暴力解肯定不是最优解,为什么呢?举个例子:st =aaaaaaaaab,match=aaab,在暴力求解的过程中,需要去尝试str串中每个位置的字符作为开头的情况,在我们的例子中,str从第一个字符开始尝试,和match串配到第四个字符的时候失败了;str串来到第二个字符进行尝试,match串又要从0位置开始依次匹配,然后配到第四个字符的失败,继续重复这种匹配的过程。也就是说之前你做过的努力丝毫不会减少你下一次尝试的成本,每次尝试的成本是一样的,所以暴力解肯定不是我们要的最优解。
KMP原理
那么KMP算法能做到什么程度呢?告诉你,O(N)的时间复杂度,相当于只把str撸一遍,就能知道能不能匹配出match串,那KMP是怎么做到的呢?我们正式进入今天的主题——KMP算法。
在正式进入KMP算法之前,我们先来认识一种信息:前缀串与后缀串的最长匹配长度信息,称之为next信息,怎么理解呢?比如字符串abcabck,我想求 6 位置字符的信息,也就是字符k的信息,这里规定两点:1.求某个位置的信息,与自己本身无关,只与自己之前的字符串有关,2.求某个位置信息的时候,前缀串与后缀串不能达到整体。
啥意思呢?回到上述的例子,想求k字符的信息,在求解过程中,与k字符本身无关,只与自己之前的字符串abcabc有关;前缀串与后缀串不能取整体的意思是指求解的过程中前缀串和后缀串长度都不能达到abcabc的长度,最多只能达到abcabc长度减1的情况,那就剩下最后一个问题:怎么求前缀串和后缀串最长匹配长度?以abcabc为例,如下图所示:
求next信息这一步很重要,一定要理解:对于任意字符串s1,求取任意 i 位置的信息,就是求取 i 往前的字符串(记为s2),其前缀串和后缀串的最长匹配长度,且前缀串和后缀串不能覆盖到s2整体。再举一个例子:s1 = aaaab,我想知道 b 位置信息,就是求 b 位置之前的字符串aaaa的前缀串和后缀串最长匹配长度,那我们就把目光盯住字符串aaaa,为了方便,我们就把前缀串和后缀串的长度称之为len,字符串 aaaa 记为 s2 。当len == 1时,前缀串:a,后缀串:a;当len == 2时,前缀串:aa,后缀串:aa;当len == 3时,前缀串:aaa,后缀串:aaa,我们要的是前缀串与后缀串最长匹配长度,所以最后结果是len == 3。一定要清楚,len能不能取4?不能!因为前缀串与后缀串不能覆盖到 s2 整体。
理解了next信息的概念,我们再来理解KMP算法,回到最先提出的问题:如何确定str中是否有某个子串是等于match,首先我们先收集match串每个位置的next信息,组成next数组返回,这是KMP算法很关键的一步:根据next数组,可以加速我们的匹配过程,那么为什么它可以加速匹配过程?还是用举例子的方式来帮助大家理解。举例子之前呢,我们先不展开next数组求解的过程,先知道 O(N) 的时间复杂度可以拿下它,暂时先把它当做黑盒进行使用,后面我们再详细讲述它的求解过程,我们先解决它为什么可以加速匹配过程。
假设有s1和s2两个字符串,s1的长度 >= s2 的长度,s2 每个位置的字符的next信息都记录在next数组里。这里我们发挥一下自身的想象力,想象一下s1串与s2串匹配的场景:哎,s1串从某个位置(记为 i 位置) 开始,与s2串匹配成功了,接下来s1、s2一路都能成功匹配,玩的很开心,直到有一个位置匹配失败了(为了方便描述,在 s1串中把失败位置记为 x,s2 串中把失败位置记为 y),是不是就像暴力解的过程那般,s1直接跳到 i + 1位置,s2 回到0位置,从头开始再一一进行匹配呢?
显然不是,既然 s2 串在 y 位置宣告匹配失败,此时,在next数组中取出 y 位置的信息记为len,len代表着前缀串与后缀串的最长的匹配长度,这里不妨把后缀串的区域记为L1,前缀串的区域记为L2,然后在s1串中的失败位置x也往前推len长度,这片区域记为L3,显然L1 == L2 == L3,得到了这个结论很有用。有什么用呢?如果下一次验证的时候,s1串依旧停在x位置,直接和s2串中L2区域的下一个位置(把它记为z位置)的字符再次进行匹配,s1就不必再跳到i+1位置,s2回到开头位置再重新开始验证了,因为此时在s1串中 i 到 L3往前的区域,其中任何一个字符作为开头都一定配不出s2,唉,这是为什么呢?下面给大家一一解释,而且由于L2 == L3,所以在这个区域内的字符是不需要再去重新验证的,x 位置的字符直接和 s2 中 z 位置字符去匹配,相当于把s2串往前推了一段距离,啥意思呢?来,画图帮助大家理解,先解释s2往前推的过程,如下图所示:
还是很抽象,我们再来看一个具体的例子:s1 = aaaaaab,s2 = aaab,
开始的时候 s1 和 s2 一路都匹配,直到来到 3 位置时,匹配失败。此时,我们把 s2 中 3 位置字符的next信息取出来记为len,len == 2,它代表着此时s2中的b字符的最长前缀串和后缀串长度2,此时我们分别把s2的后缀串和前缀串记为 L1 和 L2,在s1的3位置也往前推len长度(不包括其本身),这段区域记为L3,显然L1 == L2 == L3,那么再次匹配时,s1中3位置的字符就直接和s2中前缀串L2的下一个位置进行匹配(即与s2中2位置的字符进行匹配),相当于把s2串往前推了一段距离,这就是KMP的匹配过程。
讲到这里,相信大家已经能体会到KMP算法为什么比暴力解要好,因为它不会浪费你之前做的努力。此时,我们再来看看如何实现s2往前推的效果,其实很简单,就是根据next信息去推的,我们再看看上面那个具体的例子,s2串中3位置字符的next信息为2,哎?是不是刚好就是s2串下一次要进行匹配的字符的位置下标?下一次验证时,s1中3位置的字符是不是要与s2中2位置的字符进行匹配,而刚好s2中3位置(匹配失败位置)的next信息就为2!这是玄学吗?当然不是,这就是next信息的另外一个作用:如果当前匹配失败了,取出失败位置的next信息值记为len,下一次匹配就直接跳到len位置的字符再重新进行比较,这不就是相当于把s2往前推的效果吗?这也是为什么求next信息时,规定任何字符串,0位置的next信息为-1。想象一下,当配置串跳着跳着,某一刻你的next信息值小于0了,你就知道不能再往前跳了,也就代表着较长串在这个区间内已经是不可能配出我了,较长串你该重新换个开头了。
那就还剩下一个问题:s1串从任意i位置开始与s2串成功匹配,到x位置匹配失败,此时s2的失败位置记为y,取出y的next信息记为len,然后s1从x位置往前推len长度,这个区域记为L3,为什么在 i 位置到L3往前的区域内,其中任意一个字符作为开头都是不可能配出s2的?还是以画图的形式,帮助大家去理解。
说到这里,KMP算法的验证过程相信大家应该就可以理解了,我们来看下代码如何实现:
public class KMP {
// str串中能否配出match串
// 能配出,返回第一次匹配成功的起始位置,不能配出返回-1
// 比如:str= a a a b c c a b match=ab
// 0 1 2 3 4 5 6 7
// str中可以匹配成功两次,分别是2位置和6位置
// 返回第一次匹配成功的位置:2
public static int getIndexOf(String str, String match) {
// 无效参数
if (str == null || match == null || match.length() < 1 || match.length() > str.length()) {
return -1;
}
char[] str1 = str.toCharArray();
char[] str2 = match.toCharArray();
// 获取next信息数组
int[] next = getNextArray(str2);
// str串中的位置指针
int x = 0;
// match串中的位置指针
int y = 0;
while (x < str1.length && y < str2.length) {
// str串当前位置的字符与match串当前位置的字符匹配成功,跳下一位去进行验证
if (str1[x] == str2[y]) {
x++;
y++;
} else if (next[y] == -1) { // match串已经不能再往左跳了,str你换个开头吧
x++;
} else { // match串还能往左跳,取出next信息,告诉我该跳到哪个位置
y = next[y];
}
}
// 从while出来了
// 情况一:y先越界,配出来了,开始位置为x - y
// 情况二:x先越界,但是还不知道是否配成功, 所以需要判断 y == str2.length
// true:配成功了 所以开始位置为x - y false:配不出来,返回-1
return y == str2.length ? x - y : -1;
}
}
理解了KMP的验证过程和相关原理,我们再来研究一下:如何快速求取next信息数组?前面说了求取next数组可以O(N)的时间复杂度拿下,现在我们就来看看具体是如何实现的。
上面在使用next信息数组的时候,说了一个规定:任何字符串0位置字符的next信息为-1,因为在它之前根本就没有字符;1位置字符的next为0,因为1位置只有一个字符,而前缀串和后缀串不能取到整体。这两个位置的next信息是固定的,接下来的位置怎么求next信息呢?如果我说求任何字符串的next信息数组都是从2位置开始从左往右求,当前来到i位置,就代表着i之前位置的next信息我已经得到了,那我能不能利用i之前位置的next信息帮助我求取i位置的next信息呢?说到这里,就有动态规划的味道了,下面我们就来看一看具体的流程:
假设当前来到 i 位置,想求 i 位置的next信息,我就关心它的前一位 i -1 位置的next信息,因为 i 位置的next信息与i位置的字符本身也没有半毛钱关系,这里把 i - 1位置的next信息记为y,也就是i-1的前缀串长度为y,不妨把这个前缀串区域的边界记为z,骚的来了:如果z+1位置的字符与i-1位置的字符相等,那么i位置next信息就是y+1。哎,为什么i 位置的next信息就一定是y+1?就不可能存在比y+1更大的情况了?很费解是不是,还是画图来进行理解:
证明了如果i-1位置的next信息为y,且z+1位置与i-1位置的字符相等,,那么i位置的next信息一定是y+1,那如果z+1位置与i-1位置的字符不相等呢,那就跳到z+1位置,把它的next信息拽出来,记为u,盯住u位置的字符,和i-1位置字符进行比较,如果相等,i位置的信息就是u+1,不相等就根据当前位置的next信息继续往前跳到对应位置,把新位置的next拽出来,盯住对应位置的字符,和目标字符进行匹配,直到跳不动为止。太抽象了,再给大家举一个例子:
我想求22位置k字符的next信息,先看21位置的b字符,b字符的next信息是9,我就盯住9位置的字符,拿9位置的字符与21位置的b字符进行比较,如果9位置的字符是b,那22位置k字符的next信息就是9+1=10。很可惜9位置的字符不是b,而是t字符,他们不相等,所以我跳到9位置的t字符,t字符的next信息是3,我就盯住3位置的字符:哎,3位置的字符与21位置的字符相等,所以22位置k字符的next信息就是9位置t字符的next信息加1,结果为4,唉,结果就一定是4吗?有没可能比4还大呢?答案是不可能的,证明方法还是跟上面的图中描述的一样,如果结果大于4,那就证明之前你求取的next信息是错误的,所以结果一定不大于4,。那如果3位置的字符与21位置的字符还不一样呢,那我就跳到3位置,取出next信息,盯住对应位置的字符,再拿去进行比较,重复上述过程,直到跳不动为止,如果跳到最后还不一样,那22位置k字符的next信息就是0。
讲到这里,KMP算法的全部流程就全都讲完了,我们来看看求取next数组代码实现:
public static int[] getNextArray(char[] match) {
// 边界判断
if (match.length == 1) {
return new int[]{-1};
}
// 准备一个next数组收集match中每个位置的next信息
int[] next = new int[match.length];
// 0位置的字符next信息为-1
next[0] = -1;
// 1位置的字符next信息为0
next[1] = 0;
// 目前在哪个位置求next信息
int index = 2;
// 根据我们讲述的流程,想知道i位置next信息
// 永远根据是i-1位置的字符和某一段前缀串的下一个位置的字符的匹配情况进行求解
// cn代表当前是哪个位置的字符在和i-1位置的字符进行比较
// 为啥cn初始值设置0?
// 此时index在2位置,想求取next信息,也就是1位置的字符在与0位置的字符在进行比较,因为1位置的next信息为0
int cn = 0;
while (index < next.length) {
// 当前来到index位置,如果index-1位置的值与cn位置的值相等
if (match[index - 1] == match[cn]) {
// 既记录当前位置的next信息值,也把index指针向后移动一位,继续去求解下一个位置next信息
next[index++] = ++cn;
} else if (cn > 0) { // 如果cn>0,也就是说如果匹配失败了,我还能往前跳
cn = next[cn];
} else {
// 当前位置的next信息为0,去下一个位置继续求它的next信息吧
next[index++] = 0;
}
}
return next;
}
讲到这里,那我们还剩下一点尾巴,就是证明KMP的时间复杂度确实是O(N),我们简单回顾一下KMP算法的代码实现:
首先是求取next数组的过程,我们把目光转到getNextArray的方法实现上:不难发现影响整个算法的核心流程就是while循环的处理,决定整个循环的因素就只有index,cn两个变量,循环的内部就三条分支,在不同的分支上两个变量的变化程度也不尽相同,这时候怎么证明:数学上一般采用作差或作除的方式进行变换处理,在这里我们利用数学上的技巧,使用index ,index-cn的变化范围对此过程进行描述:
循环的第一条分支:index增大,index-cn不变,因为cn也在增大,index和cn的变化程度一样
循环的第二条分支:index不变,index-cn上升,因为index不变,而cn在减小
循环的第三条分支:index增大,index-cn增大,因为index在增大,而cn保持不变
由于index的变化范围最大不会超过match的长度,整个过程中index也不会回退,而match的长度最大可以取到N,所以此过程的时间复杂度是O(N)。
KMP匹配的过程也是同理,影响整个流程的关键因素也是while循环的处理,而影响循环的因素也就x,y两个变量,因此我们用x,x-y的变化范围对整个流程进行描述:
循环的第一条分支:x增大,x-y不变,因为y也在增大,x和y的变化程度一样
循环的第二条分支:x增大,x-y增大,因为x在增大,而y保持不变
循环的第一条分支:x不变,x-y增大,因为x不变,而y在减小
由于x的变化范围最大也不会超过str的长度,整个过程中x也不会回退,str的长度最大可以取到N,所以此过程的时间复杂度是O(N)。
综上情况:KMP的时间复杂度是O(N)。
行文至此,总算是彻底的把KMP算法的原理给讲完了,作为一个知名的算法,其本身确实也蕴含的深刻魅力以及简洁美,不禁感慨先贤的优秀,作为初学者的我,虽然也是经历过被其折磨的过程,不过也是遇上方知,谁说不是呢?