引言
串或字符串,属于线性结构,自然的可以利用向量(Vector)或者链表(List)等序列结构加以实现,通常具有如下两种特性:
- 结构简单:组成串的字符集合本身规模不大,典型的如二进制串,字符集仅有两个元素
- 串规模巨大:通常由这些字符集可以组成超大规模的文本数据
以字符串形式表示的海量文本数据的高效处理技术,一直都是相关领域的研究重点,而KMP算法就是,模式匹配算法中最出名的算法之一。
正文
何为匹配?
以下图为例:
我们称被某一匹配的串为主串,用于匹配的串为模式串,通常,主串的长度 >> 模式串长度;按照正常的逻辑,我们最开始的想法就是按照顺序,一个接着一个的比对,如下图所示:
经过五次比对,我们终于在主串中找到了第一个与模式串匹配的字串,当然,后续可能还有其他匹配的位置,我们根据实际需要进行筛选,这里我们选取主串中第一个匹配的位置作为返回值;还有一种情况是:在主串中没有找到与之匹配的字串,这种情况我们会特殊处理。
算法分析
假设主串的长度为n,模式串的长度为m,从理论上讲,上述蛮力算法在最坏情况下,需要需要迭代(n-m+1)次,并且每次都要至多进行m次比对,总共需要m(n-m+1)次字符比对,因m << n,该算法的渐进复杂度为o(nm),这种最坏情况的的确会发生吗?答案是是肯定的。
从上面的例子我们可以发现,实际上蛮力算法没有利用到它此前匹配时所产生的重要信息:模式串中的某一字符与主串字符能够产生匹配情况的条件(无论该字符是否匹配成功),就是模式串的该字符前的所有字符,都已经与主串对应位置匹配成功了(边界问题我们放在后面讨论),这是一个非常强的条件,利用好这个条件可以帮我们极大地提高匹配算法的效率!!!
KMP算法分析
[站外图片上传中...(image-bf41a0-1566700509974)]
如何利用这个条件呢?我们可以先假设已经做好了充分的预处理,使得发生不匹配的情况时,模式串不必回到头再重新开始比较,而是按照预处理提供的办法右移很多位,并且不会遗漏匹配,我们通过如下图示来感受一下对于蛮力算法最坏情况下这种提前预处理方式会是怎样的情况:
直到第四次比对时,出现了不匹配现象!
通过想象中的预处理,我们可以将模式串右移一位,和蛮力算法不同的是,此时我们无需再管模式串前两个字符是否匹配,有了预处理的保证,我们仅需要将目光对准黑箭头指向的位置即可。
接下来就是不断地迭代,找到预处理的位置,然后继续匹配,如此往复,直到匹配成功或者失败。
如法炮制,直到匹配成功
为了更能够凸显出这一策略的优势,我们另外一个字符串作为示例:
[站外图片上传中...(image-5a01a0-1566700509974)]
接下来的匹配情况就不赘述了;与蛮力算法相比,该算法减少了很多不必要的匹配动作,使得模式串匹配更加高效。
如何预处理
体会到这个算法带来的优化,我们就要开始考虑预处理的问题,通过上述示例,我们发现如何使模式串快速右移到指定位置是使得匹配效率的关键,之前谈及的预处理,实际上是构建一个查询表,我们称为next表,当哪个位置发生不匹配现象,我们就根据next表上的信息,移动到我们预先设计好的位置。
以下是KMP算法的主体代码:
typedef int Position; //位置
Position match(char* P ,char* T){ //KMP算法,P是模式串,T是主串
int* next = buidNext(P); //构造next表
int n = (int)strlen(T);//主串长度
Position i = 0 ; //主串指针
int m = (int)strlen(P); //模式串长度
Position j = 0 ; //模式串指针
while((j < m)&&(i < n)){ //强条件,一旦两个串的其中一个遍历完,另一个就没有再遍历的必要,循环便终止
if((j<0) || (P[j] == T[i])){ //若匹配,或P已移出最左侧
i++ ; j++; //转到下个字符
}
else
j = next[j]; //模式串按照既定的位置右移
}
delete[] next;
return i-j; //返回主串中最先与模式串匹配的首位置,也可以根据实际需要重新设计返回值
}
注意! 有两个地方值得我们思考:
1. int* next = buidNext( P ); //构造next表
2.if((j<0) || (P[j] == T[i])){ //若匹配,或P已移出最左侧
构造next表竟然仅需要模式串参与,与主串无关!!!
这是一个非常重要的信息,因为模式串的长度通常要远远小于主串的长度,这意味着构造next表所花费的时间是可以接受,甚至是微乎其微的。
第二个需要思考的地方我们到如何构造next表的部分再给出解答,现在我们的问题是,为什么仅需要模式串便可以构造出next表?
我们再来看一组串匹配,涉及到模式串右移,就能够想到这是跟已匹配字符有关,我们发现,能够影响预处理的两个部分,红框内的串,居然一模一样!这本就是注定的,我们刚验证了这一点,所以,构造next表仅需使用模式串。
那么next表中的信息代表着什么含义呢?
以上是一个模式串的next表,-1位置是我们假象出来的哨兵,它所对应的是一个通配符,即任何字符都可以与之匹配,实际并不存在,设立这个哨兵是为了使算法更加统一,简洁。
除此之外,next[0]是被常驻设为-1的,代表如果在P[0]出没有匹配成功,那就只能搬出哨兵元素对应的通配符来与之匹配,再将指针从哨兵位置移回到p[0]位置,该方法可以等效于匹配第一个元素失败,于是整体往后移一位,现在可以感受到这个哨兵的作用了吗?
next表的具体意义,可以这么理解:
该位置所对应的next表中的值,正是在该元素之前的元素组成的串之中,前缀字串于后缀字串相等的长度
还是这个模式串,在h字符这个位置,它的前面是字串 “chinc”,前缀和后缀有一位相等,于是它的next表上的信息是1 。
同样,在i这个位置,它前面的元素组成的字串为“chinch”,前缀和后缀有两位相等,一次类推。
当然,前缀和后缀所包括的元素也可以存在交集,如:
[站外图片上传中...(image-e158e4-1566700509975)]
以下是构造next表的算法:
typedef int Position; //位置
int* buidNext(char* P){ //构造next表 未改进版
int m = strlen(P) ; // 模式串串长
Position j = 0; //“主”串指针
int* N = (int*)malloc(sizeof(int)*m);
int t = N[0] = -1; //通配符,哨兵
while(j < m-1){
if(t < 0 || P[j] == P[t]){
t++; j++;
N[j] = t; // 可改进
}
else t = N[t];
}
return N;
}
细心的你可以发现,这个next表的构造和KMP算法非常类似,其if语句中的 t<0 就如我上面解释,是因为哨兵的缘故,仔细琢磨会发现,它其实等效于或运算符后面的那个判断。
下面解释next表的构造过程,会使得上面的代码更加易于理解。
如果我们已知next[ 0 , j ],那么如何得到next[j+1]呢? 首先,按照我们先前对于next表的理解,next[j+1] 处的值应该是前 j 项字符组成的字串前后缀相等元素的长度,于是便可以确定:
next[j+1] <= next[j]+1 , 并且是在 p[j] == p[next[j]] ,即 p[j] 与他的继任者 p[next[j]] 相等时,等号成立,代码中的 t 指代的就是 next[j] 。
[站外图片上传中...(image-7f9654-1566700509975)]
我们来模拟一下,继续看这个串,构造最开始,next[0] = -1 ,现在要求next[1] ,我们知道了next[0] , p[0] == p[next[0]] 所以next[1] = next[0]+1 。。。。。。
执行上述循环一直到构造 next[4] , 已知next[3] , p[3] != p[next[3]] , 有意思的来了,我们需要一直递归找到 p[next[next[3]]] , 也就是p[1],再继续与p[3]匹配,依旧失败,再递归一层,p[next[next[next[3]]]],也就是p[0] ,依旧不相等,继续找p[next[next[next[next[3]]]]] ,找到了通配符,完成了相等的条件 , next[4] = next[next[next[next[3]]]]+1 , 也就是0。
再改进
上面构造的next表,我们还可以让它再“智能”一些,因为上面的构造方法面对一些较为特殊的串(即字串大多数都是相同字符时)会显得相当笨拙!
如上图的串,我们设置next表的初衷是为了在该位置失配时,找到可以在已经匹配完成的字串中找到一个继任者,使得我们可以减少一些匹配次数,单通过上面的例子我们发现,在p[2]处 , next[2] = 1 , 而p[1] 处的字符依旧等于p[2] ,这必然会导致一次失配,然后继续寻找p[next[next[2]]],即p[0] ,很遗憾,因为依旧相等,所以又是一次必然的失配,这种笨拙实质上是设计的next表的缺陷,我们完全可以在构造next表的时候就可以规避掉,幸运的是,我们仅需要修改一处便可以得到改进后的next表。
int* buidNext(char* P){ //构造next表 ,改进版
int m = strlen(P);
Position j = 0;
int* N = (int*)malloc(sizeof(int)*m);
int t = N[0] = -1;
while( j < m-1){
if(t < 0 || P[j] == P[t]){
j++; t++;
N[j] = (P[j] !=P[t])? t:N[t]; //改进
}
else
t = N[t];
}
return N;
}
没错,我们只需在找到next[j+1]的时候,判断一下,p[j+1] 是否等于p[next[j+1]] ,如果相等,那必然就是一次失配情况,我们就需要将next[j+1] 修改位next[next[j+1]],因为是从底层构造的,所以每个位置只有一次修改,没有递归的情况。
这是修改过后的next表,较之上一个next表,它失去了原本的一些性质(如可以肉眼轻易算出原next表,其表中的值代表前面元素组成的字串前后缀相等的长度的含义),但对于机器来说,新表会更加具有 “先知性”,计算的次数也会降低很多。
复杂度
之前提到的蛮力算法最坏复杂度位o(nm) , n为主串长度,m为模式串长度,那么KMP算法是否效率会更高呢?
答案是必然的,我们先从KMP主算法开始,不考虑构造next表的时间,算法中用作字符指针的变量 i ,j ,假设有一个值k ,令k = 2i - j ,不难发现,k是严格递增的.
两种情况,i++,j++,此时必然递增,还有一种情况是i不变,j = next[j] ,必然小于j ,所以一直是严格递增。
从整体上来看,启动算法时i= 0 , j = 0, k = 0 , 算法结束时 i < = n 且j >= 0 , 所以k严格递增但是至多不会高于2n , 故while循环至多执行2n次,while循环体内的消耗都是常数次,所以不算上构造next表的消耗,KMP算法的运行时间不超过o(n),分摊复杂度仅为o(1);
再考虑构造next表的消耗,仅有一个while循环,循环体内的消耗是常数级,所以改进后的buildNext函数仅需o(m)的时间。
综上,KMP算法的复杂度为o(n+m) , 相较之蛮力算法,效率提升非常明显。
完整代码及测试
#include
#include
#include
#include
using namespace std;
typedef int Position; //位置
/*
int* buidNext(char* P){ //构造next表 未改进版
int m = strlen(P) ; // 模式串串长
Position j = 0; //“主”串指针
int* N = (int*)malloc(sizeof(int)*(m));
int t = N[0] = -1; //通配符,哨兵
while(j < m-1){
if(t < 0 || P[j] == P[t]){
t++; j++;
N[j] = t; // 可改进
}
else t = N[t];
}
return N;
}
*/
int* buidNext(char* P){ //构造next表 ,改进版
int m = strlen(P);
Position j = 0;
int* N = (int*)malloc(sizeof(int)*m);
int t = N[0] = -1;
while( j < m-1){
if(t < 0 || P[j] == P[t]){
j++; t++;
N[j] = (P[j] !=P[t])? t:N[t];
}
else
t = N[t];
}
return N;
}
Position match(char* P ,char* T){ //KMP算法,P是模式串,T是主串
int* next = buidNext(P); //构造next表
//putNext(next);
int n = (int)strlen(T);//主串长度
Position i = 0 ; //主串指针
int m = (int)strlen(P); //模式串指长度
Position j = 0 ; //模式串指针
while((j < m)&&(i < n)){ //强条件,一旦两个串的其中一个遍历完,另一个就没有再遍历的必要,循环便终止
if((j<0) || (P[j] == T[i])){ //若匹配,或P已移出最左侧
i++ ; j++; //转到下个字符
}
else
j = next[j]; //模式串按照既定的位置右移
}
delete[] next;
return i-j; //返回主串中最先与模式串匹配的首位置
}
char* buildArray(){//用于构造主串
char Transform[2] = {'0','1'};
int *t = (int*)malloc(sizeof(int)*50);
char *a = (char*)malloc(sizeof(char)*50);
// int* t = new int[50];
// char* a = new char[50];
srand((unsigned)time(NULL));
for(int i = 0 ; i < 50 ; i++){
t[i] = rand()%2;
}
for(int i = 0; i < 50 ; i++){
a[i] = Transform[t[i]];
}
return a;
}
void putArray(char *a){
int i = 0;
while(i < 50){printf("%c ",a[i]); i++;}
printf("\n");
}
int main(int argc, char const *argv[])
{
char *T = buildArray();
printf("主串为:\n");
putArray(T);
char *P = "01011";
printf("模式串为:\n");
printf("%s\n",P);
printf("匹配的位置为:\n");
printf("%d\n",match(P,T));
delete[] T;
return 0;
}