注意:本文首发于我的博客,如需转载,请注明出处!
给定一个字符串 A A A,要求从字符串 A A A中查找另一个字符串 B B B是否出现。如果出现,返回查找的字符串 B B B在 A A A中的位置,否则返回-1。
枚举法当然可以解决这个问题:我们可以在从 A A A字符串中取第一位,然后逐位往后与 B B B串比较。如果匹配则返回答案;若不匹配,则从 A A A字符串的第二位开始,重复上述操作,直到找到字符串位置或者字符串 A A A被遍历完毕。
我们来分析一下这个的算法复杂度。我们假设字符串 A A A和 B B B的长度分别为 m m m和 n n n,那么这个算法的最差情况需要比较 m × n m\times n m×n次,即这个算法是 O ( m × n ) O(m\times n) O(m×n)的。
KMP算法是一个很好的解决算法,它可以 最多只扫描一次字符串 A A A 就能完成任务,即它的算法复杂度是 O ( n ) O(n) O(n)的。之所以被叫作KMP算法,是因为它是由Knuth、Morris、Pratt三个人提出的。下面来介绍一下该算法的思想。
为了说明KMP的大致过程,我们先给出一个例子。
我们参考上例,并且称大数组为 c h ch ch,目标数组为 t a r g e t target target。我们从第一位开始逐个比较 c h ch ch和 t a r g e t target target中的每一位,直到不匹配为止,如图(a)所示。我们发现, c h [ 6 ] ! = t a r g e t [ 6 ] ch[6]!=target[6] ch[6]!=target[6]。但是在 i ⩽ 5 i\leqslant 5 i⩽5都有 c h [ i ] = t a r g e t [ i ] ch[i]=target[i] ch[i]=target[i]。我们此时不将 t a r g e t target target往后移动一位,选择往后移动两位,因为**这个时候 c h [ 2 ] ch[2] ch[2]到 c h [ 6 ] ch[6] ch[6]仍然和目标字符串是匹配的。**我们可以证明,这个位置是除了初始位置外第二个可能的匹配发生的位置。因为如果在此之前还存在一个匹配的字符串,那么我们要么已经找到了它,要么重定位会定位到它,而不是 c h [ 2 ] ch[2] ch[2] 。如果你能理解这点,这就是KMP算法的基本原理。
那么随之而来的问题是:我们如何确定应该向后移动几位呢? 以及,如果每次向后移动都可以看做字符串 t a r g e t target target正在比较元素的后退,(正如上例中本在比较 t a r g e t [ 6 ] target[6] target[6]和 c h [ 6 ] ch[6] ch[6],向后移动后相当于比较 t a r g e t [ 4 ] target[4] target[4]和 c h [ 6 ] ch[6] ch[6]) 那么到底后退几位呢? 如果我们把字符串中正在比较的元素位置记作 j j j,后退后正在比较 j ′ j^\prime j′,那么我们可以知道它们满足: t a r g e t [ 0 ] target[0] target[0]到 t a r g e t [ j ′ ] target[j^\prime] target[j′]之间的字符串和 t a r g e t [ j − j ′ ] target[j-j^\prime] target[j−j′]到 t a r g e t [ j ] target[j] target[j]之间的字符串完全相同。这里需要读者仔细理解一下,我们实际上取的 j ′ j^\prime j′是使得 t a r g e t target target中前 j ′ + 1 j^\prime+1 j′+1和后 j ′ + 1 j^\prime+1 j′+1个字符完全相同,也就是我们在前一节说的直观模拟的结果。
我们把 j j j和 j ′ j^\prime j′的对应关系称为失效函数,即
j ′ = P [ j ] . j^\prime =P[j]. j′=P[j].
有了这个对应关系,我们就可以轻松决定,当字符串正在比较位置 i , j i,j i,j相同,即 c h [ i ] = t a r g e t [ j ] ch[i]=target[j] ch[i]=target[j]时,但位置 i + 1 , j + 1 i+1,j+1 i+1,j+1不同,即 c h [ i + 1 ] ≠ t a r g e t [ j + 1 ] ch[i+1]\neq target[j+1] ch[i+1]=target[j+1]时, j j j需要更新成什么了。
我们从上述论断中可以知道,失效函数的产生只和目标数组 t a r g e t target target有关,因此我们可以对目标数组预处理,得到其失效函数。那么,失效函数的算法是什么呢?
首先,我们知道对于 t a r g e t [ 0 ] target[0] target[0],是没有满足失效函数定义的下标 j ′ j^\prime j′的,那么,我们定义这种条件下,其失效函数值为-1。然后我们用递推的方式求出其他元素的失效函数。我们现在要求下标为 j j j的元素的失效函数,那么我们考察 j − 1 j-1 j−1位置上的元素,其失效函数的值为 j 0 j_0 j0,那么也就是说, t a r g e t [ 0 ] target[0] target[0]到 t a r g e t [ j 0 ] target[j_0] target[j0]之间的字符串和 t a r g e t [ j − 1 − j 0 ] target[j-1-j_0] target[j−1−j0]到 t a r g e t [ j − 1 ] target[j-1] target[j−1]之间的字符串完全相同,那么我们只要比较 t a r g e t [ j 0 + 1 ] target[j_0+1] target[j0+1]和 t a r g e t [ j ] target[j] target[j]是否相同,即比较 t a r g e t [ P [ j − 1 ] + 1 ] target[P[j-1]+1] target[P[j−1]+1]和 t a r g e t [ j ] target[j] target[j]是否相同。如果也相同,那么 P [ j − 1 ] + 1 = P [ j ] P[j-1]+1=P[j] P[j−1]+1=P[j];否则,我们可以令 P [ j 0 ] P[j_0] P[j0]是新的 j 0 j_0 j0,继续反复上次的比较,直到找到 t a r g e t [ 0 ] target[0] target[0]并且 t a r g e t [ 0 ] target[0] target[0]和 t a r g e t [ j ] target[j] target[j]也不相等,那么其失效函数的值为-1 。
这节给出失效函数failurefunc()的实现。
int* failurefunc(const seqString& target)const{
int* p=new int[target.len];//申请一个数组,存放失效函数
int j;
p[0]=-1;//首元素的失效函数值为-1
for(int i=1;i<target.len;++i){
j=i-1;//往前退一位
while(j>=0&&target.data[p[j]+1]!=target.data[i]) j=p[j];//如果最长相同子序列不存在,那么缩短相同子序列长度
if(j<0) p[i]=-1;//直到找完都没有找到,则失效函数为-1
else p[i]=p[j]+1;//找到了,更新失效函数的值
}//end for
return p;
}//end function
有了上面的失效函数,我们可以完成KMP算法中的查找部分了。
查找函数使用两个指针 i i i和 j j j,分别指向字符串 c h ch ch和 t a r g e t target target中正在比较的元素。从两字符串头开始,对 i , j i,j i,j一同自增,直到发现 c h [ i ] ! = t a r g e t [ j ] ch[i]!=target[j] ch[i]!=target[j]就停止;此时,利用失效函数,对 j j j的值进行更新,更新为其前一个元素失效函数的值后的元素,即 P [ j − 1 ] + 1 P[j-1]+1 P[j−1]+1,然后重新比较 i i i和 j j j,重复上述步骤,直到 j = 0 j=0 j=0时还是无法匹配,则固定 j j j,对 i i i自增,直到匹配为止。搜索过程退出的条件是:找到了 t a r g e t target target的位置,即 j = t a r g e t . l e n − 1 j=target.len-1 j=target.len−1;或者未找到,即 i = c h . l e n − 1 i=ch.len-1 i=ch.len−1。
本节实现查找函数。
int find(const seqString & ch, const seqString & target){
int* p=NULL;//存放失效函数
p=failurefunc(target);//获取失效函数
int i=j=0;//定位指针
while(i<ch.len&&j<target.len){
if(ch.data[i]==target.data[j]) {i++;j++;}//相等则自增
else if(j==0) i++;//不相等但是j已经为零时,i自增
else j=p[j-1]+1;//否则更新j的值
}//end while
delete p;//释放内存
if(j==target.len) //比较完最后一个字符后,j又自增了一次,所以应该是最后一个元素的下标+1
return i-j;//如果找到了以后才退出,那么返回位置
else
return -1;//否则就是没找到,返回未找到
}
KMP算法的时间复杂度是 O ( n ) O(n) O(n)的,因为我们即便可能需要扫描目标字符串许多遍,但主字符串我们只需要扫描一遍,也就是说不会超过 n n n次。KMP算法为什么能节省时间,本质上使我们利用了扫描匹配过程中匹配失败的信息。对于匹配失败的节点,我们利用两个指针直接跳过了许多次扫描,因而节省了时间。可以说,KMP算法相比于枚举法有着更高的信息利用率。