KMP算法第一眼看上去可能会让人懵逼,但是其实仔细搞懂之后还是比较好理解的,核心代码并不复杂并且可以被应用到很多题上。这一篇文章会大概介绍KMP的思路和如何计算next数组,如果需要更详细的讲解,可以参考这篇文章。
假设我们有两个string s和t,我们要寻找t是否在s当中出现。普通的字符匹配算法是,假设s匹配到i,t匹配到j,我们check s[i]和t[j]是否相等。如果相等的话我们继续匹配下一位,否则i会回溯到这一轮匹配的起点的下一位,如下所示:
我们可以看到,因为t中c之前的ab我们在上一轮匹配的时候已经匹配过了,并且我们知道t是以ab开头的,那么我们完全可以跳过s中间的所有字符,直接把j移动到c继续匹配即可。这就是KMP算法可以让我们做到的,KMP算法中i永远不会回溯,并且时间复杂度是O(n + m) ,这个我们之后会详细分析。
KMP如何可以做到这一点呢,我们先看代码:
我们可以看到,当每一次失配的时候,j是移动到next[j]的。那么next数组是什么?我们又应该如何求呢?为了便于理解,我们先考虑,对于一个string t,如何求其最长的公共前后缀p,并且p不能为t自身,如下图所示:
以string t,abcdabd为例,假设我们用LCPS[i](Longest common prefix and suffix)来表示前i个数的LCPS,我们可以得到以下数组:
第一个a虽然LCPS为0,但是为了便于得知其为string的起始位置,我们给它标记为-1。那么这个数组用处是什么呢。假设t匹配到t[6] == d的时候发现其和s对应的位置i并不适配。这时候我们发现t的前六个字符也就是d前面的所有字符,其有一个长度为2的最长公共前后缀。这代表了abcdab中后缀ab已经和s中某两位匹配过了,那么前缀ab可以直接移动到后缀ab的位置然后让c继续和s中的i位进行匹配。也就是我们一开始的图中所示的i不回溯的方法。
那么我们在s中跳过了一些字符,直接放弃以其为起始点再度匹配,为什么我们可以确定不用去再次匹配呢?那么在上面的例子中,s中已经匹配过的字符就是abcdab,也就是t中的前六个字符,我们知道其LCPS的长度为2,这代表什么呢?这代表前缀ab的下一次匹配的起始位置只能从后缀ab开始,因为其他更长的后缀都没有对应的公共前缀,比如dab,cdab,bcdab,这些在s中都对应以i - 3, i -4, i - 5位置开头的substring。我们完全可以把以ab开头的string t跳过这些位置,因为我们可以断定string t不会和s中以这些位置开头的substring匹配。
所以我们知道next数组为何可以正确的指示j下一次的匹配位置,那么我们如何求next数组呢。
求next数组的代码如下,就是之前贴过的代码的一部分:
i代表当前匹配的后缀尾端,k代表当前匹配的前缀尾端。第5行是比较好理解的,如果当前i和k适配,我们各自移动到下一位并且更新next数组当前LCPS的长度,也就是++k的长度。但是如何理解第7行,也就是当k和i的位置不适配是,k要移动到next[k]。这个是求next数组中最核心的部分,我们以abacabab作为例子:
理解了以上的方法,我们就可以在O(m)的时间求出next数组,然后按照next数组的指示,在s和t失配的时候移动j的位置。我们可以观察出i永远不会回溯,但是我们的j是有可能回溯的,那么匹配的时间复杂度还是O(n)么?
要分析这一点,我们首先看j可能增加多少次,因为j的增加和i的增加是同步的,所以j最多增加n次,也就是s的长度。那么j = next[j]是减小j的操作,并且j最小值为-1,很显然我们减小j的操作也是不可能超过n的,不然的话j就小于-1了。所以平摊下来总的时间复杂度还是O(n)。之前我们求next数组的时候k也会回溯,我们用同样的方法分析可得求next数组的时间复杂度为O(m),所以总的时间复杂度为O(m + n)。
KMP的优化,对于如下的例子:
我们可以看出来在d和c失配之后,根据我们的next数组我们又让d和c去匹配了。这个问题出现的原因是,因为我们在构建next数组的时候,发现t[k] == t[i]的时候,直接就让next[i + 1] = k + 1,事实上我们们应该先check一下i + 1和k +1位的字符是否相同因为如果相同的话,到时候j = next[j]之后还要匹配的仍然是一样的字符。所以如果相同的话,我们让next[i + 1] = next[k + 1],如下图所示:
所以我们可以进一步优化KMP的代码,代码如下:
No comments:
Post a Comment