别再暴力匹配字符串了,高效的KMP才是真的香!
如果你想了解KMP算法,请静下心读完这篇文章,一定不会辜负你的时间
暴力匹配(BF)
字符串匹配是我们在编程中常见的问题,其中从一个字符串(主串)中检测出另一个字符串(模式串)是一个非常经典的问题,当提及到这个问题时我们首先想到的算法可能就是暴力匹配,下面的动图就展示了暴力匹配的流程。
上图中箭头指向的字符都为蓝色时代表二者匹配,都为黑色时代表二者不匹配,红色则代表在主串中找到模式串。
这种算法大致思路就是每当模式串和主串中有字符不匹配,模式串与主串对应的位置整体向后移动一位,再次从模式串第一位开始比较,重复上述做法直至在主串中匹配到模式串或者匹配到主串最后一位结束。
如果主串与模式串都比较短时,用暴力匹配还是不错的选择,编码也相对容易;但是如果主串与模式串过长时,我们只是简单想想就知道这个过程是非常耗时的,那么会不会有对应的优化算法呢?
下面就介绍本文的主角——KMP算法,不扯没用的概念,直接讲算法的应用过程及利用Python实现该算法的代码,最后会通过二者时间复杂度的分析,总结出为何KMP算法会优于暴力匹配算法。
KMP算法
构建前缀表
我们首先要确定一下引例的主串和模式串:
- 主 串 S = "abacaababc"
- 模式串P="ababc"
在模式串与主串匹配时,我们暂时只看第4步,明显主串S中的c和模式串P中的b是不匹配的:
如果用暴力匹配算法,那么就是后移模式串P,在从P的第一个字符开始比较。但是现在通过匹配我们可以知道的是当第4位不匹配时,前三个字符为"aba"是确定的,这个已知信息是十分有用的。
而KMP算法的核心就是利用匹配失败后获取的信息,尽量减少模式串与主串的匹配次数以达到快速匹配的目的,比如对于这个不匹配现象我们是不是可以直接这样移动模式串呢?
那么信息从何而来呢?在KMP算法中,对于一个模式串都可以先计算出其内部的匹配信息,这样在匹配失败时可以最有效的移动模式串,从而减少匹配次数。在此之前,需要先理解一下前缀和后缀。
- 前缀:abcde的前缀可以是a、ab、abc、abcd
- 后缀:abcde的后缀可以是e、de、cde、bcde
这里需要引出一个新的概念——前缀表,可以用profix表示、且下标从0开始,profix[i]存储的信息就是前i+1个字符的最长公共前后缀,并且这个最长公共前后缀长度一定是小于字符串长度的。
可以看到"ababc"不是前后缀,但也被列到了表中。如果你曾经了解过KMP算法,那你可能听过next数组,当前缀表转化为next数组时,最后一位的值会被覆盖掉,对过程是没有什么影响的。由于本文仅是靠着前缀表profix完成KMP算法,所以不再过多讲述next数组,不同的方法只是表示形式不一样,但归根结底原理还是相同的。
上面的前缀表是我们通过肉眼比对得出的,程序毕竟不是人嘛,所以需要通过一种程序能够识别的方法构建前缀表,依据下图进行讲述流程。
通过这个动图可以将构建前缀表规划成下面五步:
- 首先创建两个指针,指针j指向模式串第一位(下标为0)、指针i指向模式串第二位(下标为1)。
- 由于模式串最开始是单一字符,没有前缀和后缀,所以对应前缀表第一位总为0。
- 当j=0时,比较j和i指向的字符,如果字符不匹配,i对应的前缀表位置填入0,且将i向后移动移位,j原地不变。
- 当j和i指向的字符匹配时,i对应的前缀表位置填入(j+1),且将j和i都后移一位。
- 如果j和i指向的字符不匹配,并且此时$j\neq 0$,j需要回溯到profix[j-1]的位置,再次与i指向的字符比较,重复此步骤直至j和i指向的字符匹配或者j=0。
当结合动图读完这五个步骤时,我猜你会不理解第五步,如果你都理解了,我也只能感叹一句NiuBi,利用下面这个例子更能凸显出步骤五的回溯机制。
依据上面步骤我写出了前缀表的前五位,而此时j和i指向的字符不匹配且j不等于0,这里j的下标是3,所以需要在前缀表中找到下标为j-1的值,即profix[2],然后将j回溯到对应的位置。
这样回溯是因为可以在模式串头部找到和j和i之间的字符串相匹配的前缀,也就是这个例子中的a,如果此时j和i指向的字符相匹配,那么最长公共前后缀的长度就是已匹配的前缀的长度(a)再加1。由此可见如果j和i之间字符串很长时,这个操作可以节省很多时间。
而此时j和i指向的字符仍然不匹配,那么需要继续回溯j,方法和上述一致,回溯的位置就是profix[0]。
此时j和i指向的字符还是不匹配,但这里需要做的就不是回溯了,因为j=0已经满足回溯结束条件,只需将i对应前缀表的位置(profix[5])中填入0即可,用肉眼匹配也会发现此时的确没有公共前后缀。
在理解上述步骤之后,可以将其当成伪代码,依据伪代码很容易编写出构建前缀表函数。
def PrefixTable(Pattern): i = 1;j = 0 prefix = [0]*len(Pattern) while(i<len(Pattern)): if Pattern[j]==Pattern[i]: prefix[i] = j+1 j+=1;i+=1 else: if j == 0: prefix[i] = 0 i+=1 else: j = prefix[j-1] return prefix
可以输入一个模式串,测试一下该代码是否能够得出对应前缀表。
优化前缀表
经过上文解释你可能会发现一个基本事实,即前缀表最后一位没有任何作用,这么说的理由是什么呢?因为当j和i指向的字符不匹配时,这里的解决办法是回溯j,而回溯依据一直都是prefix[j-1],j是永远不可能超越i的,所以前缀表最后一位永远也不会用到。
那么最后一位就可以去掉,将所有元素整体后移一位,并向前缀表第一位填入-1,如下图:
填入-1这个操作的原理等下结合图片一起讲述会更易懂,目前我们只需知道这个操作并且了解其对应代码即可。
def MoveTable(prefix): for i in range(len(prefix)-1,0,-1): prefix[i] = prefix[i-1] prefix[0] = -1 return prefix
KMP匹配机制
主串和模式串还是利用上文所举例子,这里省略了一些简单的匹配过程,直接看关键点。
可以看到主串和模式串的第4位是不匹配的,现在需要做的是将Pattern[prefix[4]]对应主串中需要匹配的元素,也就是模式串下标为1的元素后移至与主串第4位对应的位置,看图可懂。
对应位置仍然不匹配,需要继续后移模式串,该位置对应前缀表的值为0,所以将Pattern[prefix[0]]对应主串中需要匹配的元素,即模式串下标为0的元素与主串该位置对应。
此时两串对应位置还是不匹配,但是a已经是模式串的第一位元素了,如果按照上面方法需要继续后移模式串,让主串那个位置与模式串下标为-1的元素匹配,可是前缀表中并不存在下标为-1的元素。
所以比较时如果模式串和主串对应位置不匹配,且模式串的元素对应前缀表的值为-1,那么直接将模式串整体后移一位,并且将指向主串的指针后移一位即可,这也是为什么在前缀表第一位插入-1的原因。
下面动图是利用KMP算法在主串中查找模式串的全过程。
KMP算法的代码如下:
def KMP(TheString,Pattern): m = len(TheString);n = len(Pattern) prefix = PrefixTable(Pattern) prefix = MoveTable(prefix) i = 0;j = 0#i为主串指针,j为模式串指针 while(i<m): if j==n-1 and TheString[i]==Pattern[j]: print("已在主串下标%d处找到模式串" % (i-j)) j = prefix[j] if TheString[i]==Pattern[j]: i+=1;j+=1 else: j = prefix[j] if j==-1: i+=1;j+=1
这里只讲一下第一个if语句,当j指向了模式串最后一位,并且此时如果主串和模式串对应位置匹配,则代表在主串中找到了模式串,并打印出第一个字符出现的位置。而j = prefix[j]这个语句的作用是在找到模式串后继续匹配剩余的主串,因为可能会有主串中含有若干个模式串的现象出现。
最后整个程序运行截图如下:
BF与KMP比较
为什么KMP会优于BF,这里通过对比二者的时间复杂度给出原因,假设有这么两个比较极端的主串和模式串:
- 主 串 S = "aaaaaaab"
- 模式串P="aaab"
首先看一下BF算法解决该匹配问题的流程:
然后再看一下KMP算法解决该匹配问题的流程:
假设主串长度为m,模式串长度为n。对于BF算法,每当遇到不匹配字符时,都要从模式串开头再次匹配,所以对应时间复杂度$O(m*n)$;对于KMP算法,每当遇到不匹配字符时,根据获得的信息它不会重复匹配的已知前缀,所以对应时间复杂度为$O(m+n)$。当字符串较长时,就时间复杂度而言KMP算法是完全优于BF算法的。
总结
个人认为KMP算法难度不低,讲这个算法的博客与视频很多,但都各有差异,虽然原理都是大致相同的,但不要同时看前缀表和next数组,由于这两个很像所以会容易混淆,可以先弄透前缀表然后再看next数组相关知识点,这样对于KMP的理解才算透彻。
关注公众号【奶糖猫】获取更多精彩好文呀