LeetCode刷题--基础知识篇--KMP算法
KMP算法
关于字符串匹配的算法,最知名的莫过于KMP算法了,尽管我们日常搬砖几乎不可能去亲手实现一个KMP算法,但作为一种算法学习的锻炼也是很好的,所以记录一下。
KMP算法是根据三位作者(D.E.Knuth, J.H.Morris和V.R.Pratt)的名字来命名的,算法的全称是Knuth Morris Pratt算法,简称为KMP算法。
关于字符串匹配,我们假设要在字符串A中查找字符串B,那么我们可以把字符串A叫做主串,把B叫做模式串。所以字符串匹配其实就是要在主串中找到与模式串相同的子串。假设主串长度是n,模式串长度为m,最简单直接的想法是,我们在主串中检查起始位置分别是0,1,2...n-m且长度为m的子串,看有没有跟模式串匹配的。这其实也是字符串匹配BF算法的思想,所谓BF就是Brute Force的缩写,中文叫做暴力匹配算法,也叫朴素匹配算法。
在BF算法中,如果我们遇到了不匹配的子串,会将模式串向后移动一位并再次进行匹配。而KMP算法的核心思想是,如果遇到了不匹配的字符串的时候尝试寻找一些规律,将模式向后多移动几位,跳过那些肯定不会匹配的情况。
好前缀与坏字符
先来看一个例子:
主串 | a | b | a | b | a | e | a | b | a | c |
模式串 | a | b | a | b | a | c | d |
在模式串与主串的匹配过程中,我们把以及匹配好的那部分叫做好前缀(蓝色部分),把不能匹配的那个字符叫做坏字符(红色部分)。当遇到坏字符的时候说明这次匹配失败了,因此我们要向后移动模式串。KMP的核心思想是不匹配时利用规律向后多移动几位。观察一下好前缀本身,在它的后缀子串中,查找到最长的那个可以跟好前缀的前缀子串匹配的子串。上面的文字描述有一些绕口,我们基于上面的表格尝试将模式传向后移动两位就可以达到符合条件的那种效果,结合图来看一下。
主串 | a | b | a | b | a | e | a | b | a | c |
模式串 | a | b | a | b | a | c | d |
第一次匹配时我们获得的好前缀是‘ababa’,在它的后缀子串中,最长的可以跟它的前缀子串匹配的字符串是‘aba’(上图黄色部分)。假设好前缀的长度是L,最长的可匹配的哪部分前缀子串的长度是l,那我们就可以直接把模式传向后移动L-l位,然后再继续比较。结合上面的内容,好前缀的长度是5,最长的可以跟它的前缀子串匹配的后缀子串的长度是3,因此可以直接向后移动2位。
在上面的过程中,其实并没有涉及到主串,只需要模式串本身就可以求解。因此可以提前构建一个数组,用来存储模式串中每个前缀(这些前缀都有可能是好前缀)的最长可匹配前缀子串的结尾字符下标。这个数组定义为next数组,在一些地方把这个数组称之为“失效函数”(failure function)。
数组的下标是每个前缀尾部字符的下标,数组的值是这个前缀的最长可匹配前缀子串的结尾字符下标。还是用表格记录一下:
模式串 | a | b | a | b | a | c | d |
下标 | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
模式串前缀 | 前缀结尾字符下标 | 最长可匹配前缀字符串结尾字符下标 |
a | 0 | -1(不存在) |
ab | 1 | -1 |
aba | 2 | 0 |
abab | 3 | 1 |
ababa | 4 | 2 |
ababac | 5 | -1 |
上面表格中,存在最长可匹配前缀字符串的模式串前缀有'aba','abab','ababa'这样三个,注意这三个前缀的最长可匹配前缀字符串结尾字符下标的值,再加1其实就是我们在匹配过程中遇到坏字符后可以向后移动的长度。
因此,如果我们在匹配之前就可以利用模式串得到一个类似与上面表格的内容,那么匹配过程就变成了这样:依次比较主串与模式串,直到遇到了坏字符或者整个模式传匹配完成。如果全部匹配上了就是我们找到了对应的结果。如果遇到了坏字符我们就利用预先求得的内容去数组中查询应该向后移动几位,并直接移动模式串,并继续进行匹配。
1 public int kmp(char[] a, char[] b) //a为主串,b为模式串 2 { 3 int[] next = getNext(b); //利用模式传预先求得next数组的值。 4 int j = 0; //检测模式串移动的下标 5 6 for(int i = 0; i <= a.Length; i ++) 7 { 8 /*注意这里,使用while来判断而不是if,因为可能移动后的下一位,即最长可匹配前缀的下一位仍然与坏字符不匹配的情况,此时需要再次查表,直到找到了匹配的内容或是返回到模式串的首字符。*/ 9 while(j > 0 && a[i] != b[j]) 10 { 11 j = next[j - 1] + 1; 12 } 13 14 if(a[i] == b[j]) 15 { 16 j++; 17 } 18 19 if(j == b.Length) //找到匹配的字符串了 20 { 21 return i - b.Length + 1; 22 } 23 } 24 25 return -1; 26 }
经过上面的内容我们可以看出,KMP较暴力匹配方法高效的原因是可以利用事先求得失效函数的值,在遇到不匹配的字符时快速向后移动多位,因此如何预先求得失效函数的的值变成了问题的关键。
为了保证KMP的高效,我们获取next数组的值的方法也应该尽量高效。这个计算方式其实有一些动态规划的思想,我们按照下标递增的方式依次计算next数组的值,当计算next[i]的时候,next[0],next[1].....next[i-1]应该已经计算出来了。这里重温一下,next数组的下标代表模式串的前缀结尾字符下标,值为对应的前缀最长可匹配的前缀子串的字符下标。
先来看一种比较简单的情况。假设模式串数组为b,我们的目的是求得next[i],那么应该已经求得了next[i-1]的值。假设next[i-1] = k - 1。那么就说明b[0,k-1]也是b[0, i-1]的后缀。那么我们考察b[k]这个字符是否与b[i]这个字符相等,如果相等,那么b[0,k]也就是b[0,i]的后缀,也就求出了next[i] = k。(相等的两个字符串在末尾分别添加一个相等的字符,新的字符串仍然相等。)
如果b[k] != b[i],那就不能这么计算了。下面的过程有些不好理解,笔者能力一般,水平有限,尽量解释吧。我们顺着刚才的思路,既然不能直接利用next[i-1]的最长可匹配前缀字符串了,我们就尝试去使用次长可匹配前缀字符串。举个不恰当的例子,比如我们的模式串b[0, i-1]='ababa',那么它的最长可匹配前缀字符串是’aba‘(最长可匹配后缀字符串也是'aba'),当这个最长的值不能使用时,我们就退而求其次,使用次长字符串a。注意这个次长的值,当它是前缀字符串时,它较最长前缀减少的是末尾,当它是后缀字符串时,它较最长后缀减少的是开头字符。这个时候我们再去考察这个次长子串的下一位,假设是b[x],如果b[x]与b[i]相等,说明我们找到了结果,那么next[i] = x。否则我们就需找再次的最长前缀。
KMP算法的复杂度
KMP算法的空间复杂度是O(M),M是模式串的长度。
KMP算法的时间复杂度,第一部分计算next数组的时间复杂度是O(M),M是模式串的长度,第二部分匹配的时间复杂度是O(n),n为主串的长度。所以综合来看,KMP算法的时间复杂度是O(m+n)。