1. 概述
字符串是编程中常用的一种数据结构,在各个方面都有广泛的应用,而字符串的一种基本操作就是给定一段长度为N的文本,而后给定一段长度M的pattern字符串,在文本中找到和该模式相同的子字符串。
模式 -> N E E D L E
文本 -> I N A H A Y S T A C K N E E D L E I N A
解决这个问题有一种简单的方法:
- 从文本的第一个字符开始,逐一的与模式字符串的每个字符进行比较,如果找到完全符合的,查找结束
- 在某个位置失配,则从文本的下一个字符串开始处重复步骤1的操作
这种方法在大多数情况下都能良好的工作,然而在一些极端情况下运行时间可能会和MN成正比,如:
模式 -> A A A A B
文本 -> A A A A A A A A A A A A A A A A B
为了解决这个问题,Knuth, Morris和Pratt发明了一种快速查找子字符串的算法,保证最坏情况下运行时间为O(N), 这种算法被称为KMP算法。
2. KMP 算法
2.1 基本思想
该算法的基本思想是,当出现不匹配当字符串时,我们已经知晓了一部分文本当内容,我们可以通过这部分信息减少比较的次数,如当字母表中只有两个字符A,B时:
模式 -> B A A A A A B
当在第六个字符位置匹配失败,那么我们肯定可以知道文本当前六个字符是B A A A A B,那么我们无需再从第二个字符比起,而是可以从文本的第7个字符开始,与模式的2到7个字符比较,如果其7-13个字符是A A A A A B,则找到了符合模式的子字符串,这样就能减少一次比较。
上面的模式有什么规律嘛?那就是由于模式字符串的开头和结尾处有相同的字符串 B ,所以可以跳过这段相同的字符串。如果模式字符串中已经和待搜索字符串有有9个字符 ABCDEFABC 匹配时,如果第 10 个字符失配,就可以快速的跳过模式的前3个字符,比较模式的第 4 个字符 D 是否和待搜索字符串中当前字符相同。为了记录模式字符串的特性,我们需要记录一些额外的数据,所以从某种角度说 KMP 可以认为是一个用空间换时间的算法,只不过由于一般模式字符串都比较短,所以消耗的额外空间很小。
2.2 next 数组的计算
int[] makeNext(String pat)
{
int q,k;//q:模版字符串下标;k:最大前后缀长度
int m = pat.length;//模版字符串长度
int[] next = new int[pat.length];
next[0] = 0;//模版字符串的第一个字符的最大前后缀长度为0
for (q = 1,k = 0; q < m; ++q)//for循环,从第二个字符开始,依次计算每一个字符对应的next值
{
while(k > 0 && pat.charAt(q) != pat.charAt(k))//递归的求出P[0]···P[q]的最大的相同的前后缀长度k
k = next[k-1]; //这个while循环是整段代码的精髓所在
if (pat.charAt(q) == pat.charAt(k))//如果相等,那么最大相同前后缀长度加1
{
k++;
}
next[q] = k;
}
}
解释一下上面到代码,q代表当前已经计算到模板的第q个位置,k为位置q之前的最大相同前后缀长度。
那么当第k+1个字符(位置 k 处的字符,注意下标由 0 开始)与第q个字符相等时,我们就可以确定当前位置的最大前后缀长度应当为k+1.
然而,当第k+1个字符与第q个字符不相等时,应该如何处理呢?我们知道此时pat.charAt(k)已经和pat.charAt(q)失配了,然而pat.charAt(q-k) ··· pat.charAt(q-1)又与pat.charAt(0) ···pat.charAt(k-1)相同,那么我们如果我们能找到0到k-1内的最大前后缀字符串,这个字符串肯定也和q-k到q-1的最后一段相同,此时我们就可以继续看看这个字符串的后面一个字符是否和pat.charAt(q)相同了,如果不相同,则可以继续重复这个步骤直到 k = 0,说明没有共同的前后缀,必须从模式的第一个字符开始比较。
得到next数组后,匹配的过程就很简单了:
如果匹配到文本到某个位置i的时候失配了,此时的模式指针值为j,那么从next就能读出此时的最长前后缀长度,也即是下一个比较的j的值(j应该在最长前缀子串的后面一位, 因为数组下标从0开始,所以两者正好相同),如下图:
这里在 j=6 处失配,next[5] 为 2,所以将 j 改为 2
这里在 j=2 处失配,next[1] 为0,所以 j 改为0:
这里 j 为 0 时就不匹配,需要将 i 加1。
重复以上步骤就能找到相应的子字符串。