关于KMP算法前前后后看了三四遍,总是看了没多久就又忘了,感觉这个算法好反人类,今天好好总结一下,争取晚点忘。
看本文的同志们需要注意的是,本文没有按照一般的套路来介绍KMP算法,而是侧重于介绍KMP算法为什么这样做,关于KMP算法和next非常值得一看的博客推荐如下:
https://www.cnblogs.com/tangzhengyue/p/4315393.html
https://segmentfault.com/a/1190000008575379
网上关于KMP的算法很多,我这里就简单提一下:KMP算法的目的是为了找出模式串P(pattern)在目标串S中出现的位置而设计的,如果给定一个模式串P,一个目标串S,让我们找到P在S中出现的位置,在编程时一般大部分人都会自然地从目标串S的开头用每个字符和P中的字符匹配,如果不能和P完全匹配,就将S比较的起始位置后移一个,然后再和P中的每个字符依次比较,直到在S中找到P,或者比较到最后也没有结果。
而KMP算法的思想则是利用模式串中已经比较过的字符,减少重复的比较,这样说太抽象,通过例子对比就清楚了:
假如我们目前比较目标串S和模式串P到了如下图箭头所示位置:字符C和字符D不匹配。
图1
那么我们下一步可以采取的动作有哪些呢?
看到当前字符不匹配,我们感觉前功尽弃,似乎之前辛苦匹配了那么多都白费了(实际上并没有白费)。但还好毅力还是有的,这时候不管三七二十一,我们是将紫色箭头移回到了P的起点,黄色箭头也移到了S这次比较的起始位置的下一个位置,信心满满开始下一次的比较。这种方法有种悲情的感觉,但悲情的原因就在于每次匹配失败过后,没有从中汲取经验教训。
图2
或者在看到当前字符不匹配后,我们痛定思痛,回顾刚才的匹配过程,猛然发现,黄色箭头并不一定要移动到上图中的位置的。为什么?因为P的开头字符是A,只有找到S中为A的字符然后再接着匹配才可能成功。于是就有以下做法:
图3
从图中,我们可以看到,黄色箭头和紫色箭头都没有像1)中那样回退到最初的位置,而是通过在S中找到下一个A来减少回退的距离,从图中我们可以看出,相同的A我们就直接跳过了,接下来就从P的第二个字符B开始比较的(因为刚才在从S中找字符A的过程已经确定S和P的开始字符A相同了)
但我们注意到其实当前S和P箭头所指的位置其实也是一样的(都是B),那我们可不可以更进一步,从下图所示箭头位置开始比较呢?这种做法其实是上面三思而后行方法的加强版,为什么这么说呢?因为我们刚才的思路是只有S中某个开头为A的子串,这样再去和A比较才可能匹配成功,而为了增大这种可能性,可以缩小一下查找范围,即找到S中某个开头为AB的子串,这样才可能匹配成功。对比图1可以看到,采用查找开头为AB的子串的做法,黄色箭头甚至都不用回退了。而不让黄色箭头回退正是我们的目的。
图4
下面我们通过一系列的情况来说明黄色箭头在所有的情况下都是不用回退的。从图4中可以看出,S中红线框中的"AB"与P中左侧红线框中的"AB"相同,这一情况在图1中发现不匹配时就是已经注定的了,为什么呢?因为通过观察模式串P,可以看出P具有相同的真前缀和真后缀"AB",当图1中的不匹配发生时,可以确定的是不匹配处即P中最后一个D之前的字符都是匹配的,也就是说,由于P中的真后缀"AB"匹配了S中红线框中的"AB",自然P的真前缀"AB"也就和S中红线框中的"AB"匹配了。
当然有人可能会有疑问,如果刚才没有在S中找到A怎么办?这种情况是有的,如果没有找到A,那就说明在P中没有刚才那样的真前后缀,那么其实黄色箭头还是不用动,而紫色箭头则移动到了P的开头,如下图所示:
图5
此外,可能还有人可能会问,有没有可能图3中箭头所指的字符是不一样的,那样的话是不是黄色箭头就还是得往回退了?对此,我首先想回答,没错,是的,yes。但我们需要接着分析下图:
图6
假如我们按照2)中的做法找到了S中的下一个A,然后开始比较字符C和字符A,发现不相同。那字符C和字符A不同是否可以像刚才可以提前预知呢?答案是肯定的。我们按照前后缀的思想分析一下模式串P(AACE):
图7
从图7可以看出,两个箭头所指位置的字符不同是早就由P自身的特点决定的。因为在P中,真前缀AA !=真后缀 AC,而P中的AC中等于S中的AC(因为之前匹配过的)。所以这种情况也是可以预知的,既然已经预知了图6中箭头所指位置的不同,那么黄色箭头也就没必要再回退了。综上,黄色箭头是不用回退的,我们可以利用字符串P自身的特性来移动紫色箭头到合适的位置,也就大大减少了1)中的重复比较。
next数组的雏形在上面的介绍中其实已经成型了,但还有一些细节需要补充。
上面一大堆废话中最重要的一点就是最后一句话——黄色箭头是不用回退的。也就是说紫色箭头还是要退的,要不然两者都不退,就没办法比较了。而next数组则指明了紫色箭头在某一次匹配失败后该回退到哪里。通过之前的分析,我们应该可以看出紫色箭头回退后的新位置实际上就是相同的真前后缀的长度(这里如果不明白的话很正常,可以去参考文章开头列出的第二篇博客),这里只简单介绍一个例子:对于模式串P :ABCDABD,如果遇到最后一个字符D不匹配,那么除去最后一个D外,前面的子串的最大长度的相同真前后缀为AB,所以,最后一个D对应的next数组元素值为2,表示紫色箭头下次要移动到字符C(P[2] == C)的位置。
通过人眼观察完全可以根据给定的一个模式串计算出其对应的next数组,但编程如何实现呢?我们需要根据递归的思想来计算P中某个位置的next数组元素值。
对于P的第一个字符,其next数组元素值为-1,表示第一个元素如若不匹配,则目标串的黄色箭头后移一个字符。
而对于第i(i > 0)个字符,其next数组元素值为取决于第i-1个字符的next数组元素值。关于这一点可以参考以下博客:KMP算法的Next数组详解,该博客中将普通的next数组和优化后的next数组都详细地介绍了一遍。
而值得注意的是,关于优化过的next数组,有人可能一开始会有疑惑,为什么在看到的一些介绍如何计算next数组的代码里,当P[i] == P[next[i]]时,仅仅就把next[i] = next[next[i]]就完事儿了,难道不应该一直往前递归吗?关于这点,其实可以注意到,循环是从P的第一个字符开始的,如果还存在P[next[i]] == P[next[next[i]]]的情况,在这之前,处理P中的第next[i]个字符时就已经处理好了,所以不会产生P[next[i]] == P[next[next[i]]]的情况。
匆忙成文,错误之处还望指正。