串的模式匹配可以说是非常常见的算法了,我们首先分析最简单易懂的蛮力算法(Brute-Force,简称BF),然后分析蛮力算法存在的问题以及如何改进,引出KMP算法,然后对KMP算法进行分析,关于蛮力算法和KMP算法我都会给出代码,这些代码都是我经过测试可以正确执行的,当然如果有疏漏或者没有考虑到的地方还请读者批评指正。
串的模式匹配问题
有两个字符串,一个是文本串(text串,后文简称为t串),另一个是模式串(pattern串,后文简称p串),我们常常希望判断出p串是不是t串的子串,同时返回p串在t串中的位置,这就构成了串的模式匹配问题。在实际应用中有很多这种需求的例子,比如我们最常用的搜索引擎就是在一个海量的文本中匹配我们的搜索关键词,或者我们在一个word文档里匹配我们要搜索的单词等等。
蛮力算法
这里要引入三个概念:
- t的对齐位置,我们用变量r表示;
- t的当前位置,我们用变量i表示;
- p的当前位置,我们用变量j表示。
对于t中的每一个字符,都要作为一次对齐位置进行比较,当t[i]==p[j]时,i和j同时加一,当不相等时,要返回当前对齐位置的下一个对齐位置进行比较。我知道这样说有点绕,接下来我们看一个实际例子。
我们设t="chillchia",p="chia",那么最初r=0,i=0,j=0。因为t[0]==p[0],于是i++和j++,然后t[1]==j[1],以此类推,知道t[3]!=p[3],注意,此时需要从r的下一位置作为对齐位置重新进行比较,那么需要执行r++,i=r处开始比较,直到匹配成功或者t串到达末尾,匹配失败,上述思想对应的代码如下:
int match(char *t,char *p){
int i=0,j=0;
for(i=0;i<strlen(t)-strlen(p);){
for(j=0;j<strlen(p);){
if(t[i]==p[j]){
i++;
j++;
}
else
break;
}
if(j==strlen(p))
return i-j;//r隐含在(i-j)中
else
i=i-j+1;//相当于r++
}
return i;
}
接下来我们分析一下这个算法的时间复杂度和空间复杂度,由于这个算法并没有以数组或者循环的形式申请大量的空间,所以它的空间复杂度只是O(1),就不再深入讨论了。
蛮力算法的时间复杂度
在最坏的情况下,我们假设t="000……00001",p="00001",设m=strlen(p),显然,这里m=5。每一次比较都需要经历m-1次成功和一次失败,所以对于p中的前(n-m)个字符都需要经过m次对比,由于t中共有n=strlen(t)个字符,而且一般情况下,我们认为n>>m>>2(这里的2代表常数),所以n-m和n可以近似相等(在封底意义下),所以前n-m个字符和n个字符是相同量级的。所以时间复杂度应该为O((n-m)m)=O(nm);
记忆力和预知力
这里我们讨论一个记忆力和预知力的问题,事实上,当p串经过m-1次成功的比较之后,它已经记下了一些t串和p串中的对应信息,因为是成功的比较,所以此时t串和p串对应的值相等,我们只考察p串就可以了。我们需要考虑这样一种情况,当p串中的某一个位置和t串比较发现不相等时,是否需要从下一个对齐位置开始,从头比较?事实上,这时我们已经记下了一些信息,完全不必从头开始,那么我们应该从何处开始呢?这就是说我们如何有效利用我们已经记下的这些信息呢?KMP算法正是为了解决上述问题应运而生的,下面我们进入KMP算法。
KMP算法
上面已经分析过,由于t串和p串已经比较的部分是相等的,这里我们只需考察p串即可。这里我们通过一个实际例子来分析,设t="chichichichia",p="chichia",可以看到,当i=6,j=6时,c和a进行比较,不相等,那么按照蛮力算法,需要从j=0开始从头比较,但是根据KMP算法,这里我们可以从j=3,也就是p串的'c'处和i=6进行比较,这样的话,实际上i处不需要返回到对齐位置重新比较,可以一次缩进多个位置同时不需要很多重复比较。那么这里的第一个问题就是,我们如何知道当j=6也就是p串的p[6]发生比较失败的失败返回到j=3处继续执行呢?这里设计一个查询表,我们称之为next[]表,那么next表如何构造呢?这其实是p串的一个自相似问题,以当前的p串为例,对于发生故障的点,也就是j=6处,需要保证它的后缀和前缀完全相等,而且在相等的前缀和后缀中取最长的,这样可以保证没有遗漏,也就是算法的正确性得以保证,比方说这里的p串,当j=6时发生比较失败,它的前缀是chi,后缀也是chi,而且是最长的前缀和后缀完全相等的情况,所以我们可以从前缀+1的位置,也就是j=3的位置继续比较(因为j从0开始,所以前缀+1是j=3)。
前缀表的构建
通过上面的分析我们知道,KMP算法的一个核心就是构建前缀表,在给出具体代码之前,我们先分析一下实现的原理,按照递推的方式,假设我们已经知道next[]表对应0到j的值,那么可不可以据此算出next[j+1]呢?事实上有这样的不等式成立:next[j+1]<=next[j]+1,这个不等式的证明读者可以自己思考下,这里我就不细致介绍了。看到这个不等式,我们首先要问,什么时候取等号?不难发现,当p[j+1]==p[next[j]+1]时,后缀和前缀在相等的基础上长度延长了一个单位,那么当这2者不相等呢?那就无法继续延长,甚至要从j的上一个前缀处继续比较直至可以延长,具体来说就是去试探p[j+1]与p[next[next[j]]+1]是否相等,读者可以画一个图就比较清晰了,如果读者能够理解这部分内容,那么接下来的代码也就很好理解了。
int* buildNext(char *p){
int s=strlen(p);
int j=0;
int *N=new int[s];
int t=N[0]=-1;
while(j<s-1){
if(t<0||p[j]==p[t]){
N[++j]=++t;
}
else
t=N[t];
}
return N;
}
KMP算法的实现
有了前缀表的构建算法,我们接下来分析KMP算法的总体框架。其实关于设计思路我在前面已经介绍过了,这里不再赘述,直接看代码。
int match(char *t,char *p){
int* next=buildNext(p);
int i,j;
int n=strlen(t);
int m=strlen(p);
for(i=0,j=0;i<n&&j<m;){
if(j<0||t[i]==p[j]){
i++;
j++;
}
else
j=next[j];
}
delete []next;
return i-j;
}
这个代码我在测试的时候发现一个bug,这里一并记录一下,当时我没有引入变量m和n,而是在m和n处分别用strlen(t)和strlen(p)代替,然而结果是当j<0时循环就退出了,这显然和我期望的不一致,其实这是一个很简单的问题,我自己编码经验不足才没发现。事实上,参看c库的文档可以得知,strlen这个函数返回一个size_t类型的变量,这个size_t其实是typedef unsigned int size_t,所以在进行不等式判断j<strlen(p)的时候,c语言做了一个隐含的类型转换,由于strlen返回一个unsigned int的变量,j也就自动由signed int转变成unsigned int了,当j是负数的时候这种转变是危险的,因为最高位置1将把j变成一个很大的数,所以循环的条件不成立,就退出了,结果办法由两种,一种如上所示,另一种就是将strlen的返回值强制转换成int型,这里我采用了第一种。
KMP算法的再改进
考察这样一种特殊情况,我们假设t="0001……00001",p="00001",这里构造的next[]表为"-1,0,1,2,3,4",当p[3]和t[3]第一次进行比较时,回逐渐调到p[2],p[1],p[0],结果不出预料,都会比较失败,这里的问题是,KMP算法不断的在犯相同的错误,也就是当1与0不相等时,继续拿上一个0与1比较,或者我们可以说,KMP算法没有在错误中吸取经验。改进的策略是,在构造前缀表时,只有当当前字符与p[t]对应的字符不相等时,才设置对应的前缀,否则继续向上一级next[]表项探索,代码如下所示。
int* buildNext(char *p){
int s=strlen(p);
int j=0;
int *N=new int[s];
int t=N[0]=-1;
while(j<s-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;
}
KMP算法的时间复杂度分析
这里我们看到,i指向当前t中的字符,对于t中的每一个字符,i始终没有后退,不像蛮力算法那样,i还要回退到对齐位置,这里每一个位置的i都至多进行2次比较。再考虑构建next[]的时间开销,可以得知它的算法和KMP的主算法类似,而且文本的长度为n,因此我们可以得知算法的时间开销不会超过2n,在封底估算的情况下,我们可以得知算法的时间复杂度为O(n)。其实回到蛮力算法,通过大量的统计数据表明,蛮力算法的时间开销一般也就是n这个量级,只有当算法对应的字符集较小时,蛮力算法的性能才下降得很明显,所以我们可以看到,很多书在举例的时候都采用0-1构成的串。
OK,至此,本篇博客就结束了。