KMP算法
导读
大家好,很高兴又和大家加面啦!!!
在上一篇内容中,我们详细介绍了朴素模式匹配算法及其实现。朴素模式匹配算法简单的理解就是将主串中以每一个位序上的元素为开头的子串与模式串进行匹配,直到匹配成功,或者匹配完主串中的所有可能的子串。
这种模式匹配方式有一个很明显的缺陷——因为需要不断的进行指针的回溯,导致消耗了大量的时间,而且还会多次执行一些不必要的匹配,如下图所示:
当我们在以主串首元素a开头的子串与模式串进行匹配后,我们可以很清楚的直到主串的第二个元素肯定是与模式串中的首元素不匹配的,这时如果能够直接跳过这个匹配的过程直接来到主串中的第三个元素与模式串的首元素进行匹配,如下所示:
像这样我们就可以节省一次匹配的过程。同理,如果在其它的例子中,我们都能够跳过这些无用的匹配过程,算法的效率就可以大幅度的提升。
能够在模式匹配过程中自动过滤掉无用匹配过程的算法,就是我们今天要介绍的串这个章节中唯一的重难点——KMP
算法。
看到KMP
算法,想必有些朋友已经开始脑壳疼了,什么是KMP
算法?它的原理是什么?如何实现KMP
算法?等等诸如此类的问题已经占满了各位的大脑。为了帮助大家减轻痛苦,接下来我们就直接进入今天的正题吧!!!
一、KMP
算法
KMP
算法全称是Knuth-Morris-Pratt Algorithm
(克努特—莫里斯—普拉特算法)实际上就是优化后的模式匹配算法。这个算法是由D.E.Knuth、J.H.Morris、V.R.Pratt这三位大佬提出。
Donald·E·Knuth大佬帅照附上
James H. Morris大佬帅照附上
Vaughan·R·Pratt大佬帅照附上
1.1 重要术语
在介绍KMP算法之前,我们先要对子串的结构有一个初步的认识,首先给大家介绍串中的几个重要概念:
- 前缀:前缀是指除了最后一个字符以外,字符串所有的头部子串;
- 后缀:后缀是指除了第一个字符以外,字符串所有的尾部子串;
- 部分匹配值:部分匹配值是指字符串的前缀和后缀的最长相等前后缀长度。
为了更好的理解这些概念,下面我们以一个具体的实例来说明,如字符串"abcabc"
:
- 前缀指的是除了最后一个字符
'c'
外,字符串所有的头部子串,如""
、"a"
、"ab"
、"abc"
、"abca"
、"abcab"
; - 后缀指的是除了第一个字符'a'外,字符串所有的尾部子串,如
""
、"c"
、"bc"
、"abc"
、"cabc"
、"bcabc"
; - 部分匹配值指的是字符串中的前缀和后缀的最长相等前后缀长度:
- 子串
"a"
的前缀为空串""(∅)
,后缀为空串""(∅)
,其中"" == ""
,最长相等前后缀长度为0; - 子串
"ab"
的前缀为空串""
和"a"
,后缀为空串""
和"b"
,其中"" == ""
,最长相等前后缀长度为0; - 子串
"abc"
的前缀为空串""
和"a"
和"ab"
,后缀为空串""
和"c"
和"bc"
,其中"" == ""
,最长相等前后缀长度为0; - 子串
"abca"
的前缀为空串""
和"a"
和"ab"
和"abc"
,后缀为空串""
和"a"
和"ca"
和"bca"
,其中"" == ""
、"a" == "a"
,最长相等前后缀长度为1; - 子串
"abcab"
的前缀为空串""
和"a"
和"ab"
和"abc"
和"abca"
,后缀为空串""
和"b"
和"ab"
和"cab"
和"bcab"
,其中"" == ""
、"a" == "a"
、"ab" == "ab"
,最长相等前后缀长度为2; - 子串
"abcabc"
的前缀为""
、"a"
、"ab"
、"abc"
、"abca"
、"abcab"
,后缀为""
、"c"
、"bc"
、"abc"
、"cabc"
、"bcabc"
,其中"" == ""
、"a" == "a"
、"ab" == "ab"
、"abc" == "abc"
,最长相等前后缀长度为3;
因此,字符串"abcabc"
的部分匹配值为000123。
1.2 部分匹配值
现在大家对前缀和后缀应该是理解了,但是这个部分匹配值可能还不是很理解,为什么我们找部分匹配值是从左往右找呢?这些值又有什么含义呢?下面我们来通过图片进一步理解:
在上图中,我们对主串T和模式串K进行模式匹配时,不难发现,我们匹配的方向是从左往右进行匹配,实际上就是从左往右将主串和模式串的子串进行匹配,因此我们所求得的部分匹配值也是从左往右进行求解。
现在可能有朋友还是会有疑问,为什么在求部分匹配值时是将模式串的子串前缀后缀进行比较呢?
对于这个问题,我们可以理解为,这是为了检测模式串中重复字符的位置,如果我们将模式串的部分匹配值(Partial Match,PM)以表格的形式表示,那就可以得到下表:
从表中我们可以看到,所谓的PM值,实际上就是重复的子串中各元素上一次在模式串中出现的位置。这里我们要思考一下为什么是上一次?
在上图中,我们计算出了模式串"abcabcabc"
的各子串所对应的PM值。不难发现,从第4个元素开始,每个子串所对应的PM值刚好就是对应的元素前一次的位序。
导致这种情况的原因正是因为我们在计算PM值时采用的是比较该字符串中的后缀与前缀。当一个子串的前缀后缀相同时,那就说明后缀中的元素与前缀中同位序的元素相同。在前缀子串中,子串的长度、元素个数与元素的位序满足:
因此,我们求解的PM值,实际上就是在找与前缀字符串中元素有重复的子串,当找到该子串时,我们会将前缀字符串重复的子串最大长度给记录下来,而这最大长度刚好就对应了前缀字符长中重复子串最后一个字符所对应的位序。
这里又是重复子串,又是重复子串最大长度,又是重复子串最后一个字符所对应的位序,可能有朋友都已经有点晕乎乎的了。没关系,下面我们通过图片来进一步的理解:
从图中我们可以看到,对于子串"abcabc"
而言,它的前缀子串和后缀子串中有重复的子串为"abc"
,而该子串在前缀子串中的最大长度为3,子串的最后一个字符为'c'
,该字符在前缀子串中所在的位序为3。
同理,继续往后匹配的话,子串的长度增加,在上图的例子中,重复的子串长度也会增加,重复子串中最后一个字符会发生改变,而该字符所对应的位序正好就是前缀子串中重复部分最后一个字符所对应的位序。
因此,我们可以得到结论:PM值是该子串中的最后一个字符前一次出现的位序。
1.3 部分匹配值的作用
现在大家应该对部分匹配值有一定的理解了,接下来我们就需要探讨一下这个PM值的作用了。接下来我们还是通过实例来进行理解,如下图所示:
在这个例子中,模式串的前五个字符都完成了匹配,在第六个字符发生了失配。此时我们就需要对模式串重新开始进行匹配,从逻辑上来看的话就好比将模式串向后进行了移动。在朴素模式匹配算法中,此时我们需要将模式串向后移动一位,使得第二次匹配从主串的第二个字符也就是'b'
开始与模式串的首字符'a'
进行匹配。
显然'b' != 'a'
,这时的匹配过程就是一个无效的匹配过程,这就是朴素匹配模式存在的缺陷,会进行很多的无用匹配,
如果我们要避免这些无用匹配过程的发生,我们就需要移动一个更加准确的次数来进行下一次匹配,这个移动次数的获取就需要用到我们之前求出的PM值。
在获取了模式串的PM值后,我们可以借助下面的公式来推算出我们需要向后移动的位数:
在第一次匹配的过程中我们已经完成了5个字符的匹配,而子串"abcab"
对应的部分匹配值为2,此时根据公式我们可以得到我们需要移动的位数为
位。
为了更直观的看到PM值与移动位数的关系,我们可以列出下面的关系表:
在这个表中可能有朋友会对第一个字符的匹配成功的PM值有些许疑问,为什么是-1而不是0?
- 这里我们简单的理解一下,如果当第一个字符发生失配时,我们只能将模式串往后移动一位,让模式串的首字符与主串的下一个字符进行匹配,因此我们通过-1来表示首字符失配时匹配成功的PM值。
从上表中我们不难发现,从模式串第4个字符开始失配时移动的位数都是3,刚好跳过了前三个字符的依次匹配,也就是说,根据这个关系表,我们在进行二次匹配时,可以直接将失配的元素与模式串的第三个元素进行匹配:
通过这种方式,我们可以看到,相比于朴素模式匹配算法,此时的模式匹配跳过了2次无用匹配的过程,很好的提高了匹配的效率。
这种借助PM值进行移位来完善模式匹配的算法就是我们要介绍的KMP
算法。惊不惊喜,意不意外。我们在不经意间就已经介绍完了KMP
算法了。今天的内容到此结束,下一篇再见!!!
哈哈哈,开了个小玩笑。对于KMP
算法,我们现在才只简单的进行了了解。接下来我们才要开始继续深挖KMP
算法。
二、KMP
算法原理
现在我们对KMP
算法的了解停留在需要记住PM值来进行移位从而达到快速匹配的效果,它的原理是什么,我相信肯定还有朋友不太了解,下面我们会通过几个角度来理解KMP
算法的原理。
2.1 从指针的角度理解KMP
算法
在朴素模式匹配中,我们是通过借助3个指针完成的模式匹配,这时会遇到几个问题:
- 主串的指针在失配时需要经常性的回溯
- 已经成功匹配的部分在下一次匹配中会进行无意义的匹配
这两个问题都是导致朴素模式匹配算法时间开销增加的原因,而KMP
算法就是用来完善这两个问题来达到减少时间开销的算法,如下所示:
从演示中我们可以看到,在KMP
算法中,我们通过PM值获取的移位信息使指针y不再进行回溯,从而减少无用的匹配过程,进而达到降低算法的时间复杂度。
2.2 从匹配的角度理解KMP
算法
通过指针来理解的话,我们看到的是KMP
算法的表象,确实在KMP
算法下,指向主串的指针y是不需要进行回溯,只需要对主串完成一次遍历即可完成整个匹配的过程。下面我们就来看一下它为什么可以不用回溯。
对于计算机而言,它在实际进行匹配的过程中是无法做到一次性读取整个主串的内容的,因此它只能从头对主串依次进行扫描,所以,只有当我们访问主串的元素时,计算机才能知道主串中当前元素是什么。因此实际的匹配过程如下所示:
在整个匹配的过程中,只会出现两种匹配结果——匹配成功和匹配失败:
- 当匹配成功时,此时计算机会继续往后扫描,直到匹配结束或者匹配失败
- 当匹配失败时,此时已经匹配过的元素是计算机已知的,这时我们就可以根据模式串的元素排列规律来进行匹配
这里我们需要重点关注的就是匹配失败时的处理,如下所示:
在第一次失败时,对于计算机而言,此时主串中的元素只访问了出现失配的子串"abcabb"
,对于失配元素后续的字符是未知的,而我们已知的是该字符串的最长前缀字符串"abcab"
肯定是与模式串相匹配的,因此我们不难得到以下信息:
- 模式串的首字符
'a'
肯定不可能与主串中的第二个字符'b'
和第三个字符'c'
相匹配,那这两个匹配对象我们就可以不需要再进行考虑了; - 主串中的第四个元素
'a'
和第五个元素'b'
肯定和模式串的前两个元素完成匹配,也就是说,这两个元素的匹配我们也是不需要考虑的; - 我们真正需要考虑的就是模式串中的第三个元素与此时失配的主串元素是否匹配。
因此指向主串该元素的指针不需要进行任何回溯,而对于模式串而言,我们只需要根据PM值得到的移动位数将指向模式串的指针进行回溯即可。
现在大家就能很清楚的看到,在这个演示的例子中,通过KMP
算法在第一次匹配失败后,到下一次进行有意义的匹配时就比朴素模式匹配算法节省了4次匹配的过程——两次肯定不匹配和两次肯定匹配。不难想象,通过KMP
算法进行模式匹配时比朴素匹配算法缩减的时间开销是非常可观的。
2.3 小结
通过上述的两个角度,我们不难得到下列结论:
KMP
算法在进行匹配的过程中,通过对模式串进行求PM值的预处理来规避无用的匹配过程;- 在模式匹配中无用的匹配过程包括两类——明显不匹配的过程和肯定匹配的过程;
- 模式串移动的位数就是指向模式串的指针回溯的位数;
- 记录主串中当前匹配子串起点的指针移动的位数与模式串移动的位数相等;
现在大家应该对KMP
算法有了更加深刻的理解了。
三、KMP
算法的实现
在理解了KMP
算法原理后,下面我们就可以尝试着实现KMP
算法的整个过程了。
3.1 next数组
在整个算法原理中,我们不难发现,KMP
算法的实现很依赖于通过PM值计算出的模式串的移动位数,而算法在移动时实际上就是指向模式串的指针指向移动后的元素对应的下标。
如果我们能够获取每个元素在进行失配时需要将指针指向的对应下标个记录下来的话,这样是不是就会更加方便了呢?
根据这个思路,我们在进行模式匹配前,可以手算出模式串中每个字符在失配时所对应的下标,并将这些下标数据依次存放入一个整型数组中,这样就能够帮助我们实现整个KMP
算法了。
这种记录下一次匹配时模式串指针指向的元素下标的数组,我们将其称为next数组。
接下来我们就来探讨一下如何计算模式串的next数组;
3.2 next数组的计算
接下来我将会给大家介绍几种next数组的计算方式,各位可以根据自身的情况来选择适合自己的方式。
在这之前,我们先要明确一个前提条件——字符串中的字符的存储形式:
- 在我们熟悉的字符串中,字符的位序与它所对应的数组下标是相差1的;
- 而我们现在接触的串这种数据结构所遇到的串在进行实际存储时,可能会通过舍弃数组下标为0的空间进行串元素的存储,这种情况下字符串中字符的位序与字符所对应的数组下标是相同的;
在后续的实现过程中我们都是以第一种存储形式——字符的位序与其对应的数组下标相差1的存储形式进行介绍。
3.2.1 通过PM值计算next数组
在前面我们介绍了如何通过PM值来获取失配时指针移动的位数,下面我们就通过PM值来进一步获取模式串所对应的next数组。
由前面的介绍可知,对于模式串"abcabc"
而言,串中的各个元素在失配时的PM值以及所对应的需要移动的位数如下所示:
通过这个移动位数,我们可以很快的得到下一次匹配时指针指向的新的位序,这里我们将其称为next位序,计算公式为:
在获得每个元素的next位序后,我们只需要将位序-1就能得到元素所对应的next数组的下标:
在这整个过程中我们进行了:计算字符对应的PM值、计算已匹配的字符数、获取前一个字符对应的PM值、计算移动位数、计算next位序、计算next数组。
!!!当然这里需要注意的是,如果在对串的元素进行存放时采用的是位序=数组下标的方式,那我们获得的next数组中的内容是与next位序的内容是一致的。
在这些过程中我们不难发现,实际对我们有用的只有next位序和next数组的内容,也就是说前面的PM值、匹配字符数和移动位数都是不需要的,因此我们现在要探讨的就是,能不能通过PM值求解next数组是对该过程进行简化呢?下面我们来分析一下PM值与next数组之间的关系;
元素 | a | b | c | a | b | c |
---|