串匹配--KMP算法的实现与优化
前言:在C语言学习的时候,学习了字符串以及字符串操作的相关知识,其中字符串匹配为其中的一个关键的算法,而字符串的匹配也在生活中大量用到,之前实在太懒,老师让自己了解KMP算法,但是自己迟迟未看相关算法,最近得空(其实是学了Java后又学到了字符串的相关操作,所以想起来了)在网上看了一下相关算法,写一篇文章来聊聊此算法以及其优化,也算是补上前边欠下的账。当然,自己对KMP算法的理解仍有些许的欠缺,欢迎批评指正。
串
0相关概念
串,又称字符串,其是由来自字母表的字符组成的有限序列,当然,这个有限序列允许这些字符重复。
通常这个字母表中的字符是有限个数的,例如一篇英文文章来自英语26字母表和若干个标点符号,计算机中所有的一切都是由二进制存储的,而构成二进制的字母表仅有[0,1]两个字符,再如,假设我们把蛋白质看成一个字符串,蛋白质是由成千上万的氨基酸构成的,在高中我们学到过,人体内部所含的氨基酸仅有二十种,即构成蛋白质这个字符串的字母表种仅有20个氨基酸字符。同样的,对于我们目前这篇文章,当然如果您特别感兴趣的话,不妨做个统计,构成本文的字符也是有限个的。
因此,我们可以分析得出,字符的种类不多,但是串的长度要往往高出若干个数量级。
文本(Text),我们一般也把由字符集组成的字符串称作文本。把要匹配的相对于文本来说长度更小的串称为模式串(pattern)。即我们通常说的串匹配即为在Text寻找是否含有pattern。
匹配:如果两个字符相等,我们称之为匹配,否则称之为失配。
真前缀:长度严格小于字符串本身的前缀称之为真前缀。同理,长度严格小于字符串本身的后缀称为真后缀。
1关注的问题
1.1是否出现?detection
串匹配的最基本问题就在于模式串是否在文本中出现,如果没有出现,那么其他的相关问题则无从谈起。因此,串匹配的最基础最核心的部分在于判断模式串是否存在于文本中。例如计算机的病毒防御系统,就是根据病毒的特征码是否出现在相关文件中以决定是否要对其进行拦截或隔离。
1.2第一次在哪出现?location
当模式串出现在文本中后,我们往往就会将注意力转移至此:既然它出现了,那它在哪出现的呢?更进一步的,它第一次在哪出现的呢?例如我们在搜索引擎搜索我们想要的东西,系统会根据我们输入的字符串进行搜索,如果搜索成功,就要把它所出现的网页找出来返回给我们,这些网页则恰恰是其出现的位置。
1.3出现了几次?counting
有些时候,我们会更关心我们搜索的对象出现了多少次,例如对于一个学校,我们想要统计某个年级的人数(假设没有留级和跳级的学生),我们只需统计每个学生学号中对应该年份的个数即可。
1.4所有出现的位置在哪里?enumeration
例如:有些时候,我们想知道名字叫张三的同学在该年级中的分布。
1.5重点问题
然而,一切的一切在于,模式串是否存在?首先我们从文本起点(也可以从终点)开始找到模式串是否出现,当找到模式串时,自然也会找到其位置,也就是问题1,2,而对于问题3,4,我们只需按照解决问题1,2的方法亦步亦趋的往后继续执行,同时记录模式串出现的位置和次数即可。
假设模式串在文本确实存在,所以,本篇文章的重点在于解决问题2。
2串匹配算法
2.1算法一:蛮力匹配
蛮力匹配,又叫暴力匹配,蛮力,通常意义下为贬义词性,百度百科解释为粗笨的力气。因此不用我多解释,你也能略知此算法的优劣,然而,一切却都要从蛮力说起。当我们遇到问题时,首先要有解决办法,有了解决办法之后,我们才再要想着如何优化相关的办法,而蛮力匹配,正是我们首先想到的解决方法。
2.1.1原理
蛮力匹配的思想如下(如图所示):
我们分别使用两个指针i
和j
指向文本和模式串的起始位置,当指针对应的字符匹配时,则向后移动,如果失配,即如下图所示:
此时,i
向前回滚j-1
个位置,而j
则回滚置模式串的开头位置。(不难看出,在上图中,i
当前所指的位置是4
,注意我们的第一个位置下标是0
,j
所指的位置是4
,i
向前回滚j-1=3
个位置,即指向T的下标为1
的字符,而j
则回滚至P的开头位置)如下图所示:
继续重复我们上述所说的两步,直到在文本中找到模式串然后停止,或未找到模式串但文本串已遍历结束停止。找到模式串时,我们则返回模式串在文本中第一个字母对应的下标,因为所有的下标都为非负数,所以当我们未找到模式串时,返回一个负数,表示未在文本中找到该模式串。通常情况下,我们选择-1
作为这个未找到的返回值。
2.1.2代码
所以其算法实现方法如下:
/**
* 蛮力匹配法
* @param ts 主串
* @param ps 模式串
* @return 如果找到,返回在主串中第一个字符出现的下标,否则为-1
*/
public static int bf(String ts, String ps) {
char[] t = ts.toCharArray();
char[] p = ps.toCharArray();
int i = 0;
int j = 0;
while (i < t.length && j < p.length) {
if (t[i] == p[j]) { // 当两个字符相同,就比较下一个
i++;
j++;
} else {
i = i - (j - 1); // 一旦不匹配,i后退
j = 0; // j归0
}
}
if (j == p.length) {
return i - j;
} else {
return -1;
}
}
可以看到,蛮力匹配中,每次失配时,模式串相对于文本右滑一个字符,然后再次匹配,如果每次都失配于模式串的最后一个字符,这样匹配的时间代价是极其高昂的,事实上,这样的情况是存在的。那么,如何优化这个代码呢?
注:请注意这里的细节,程序的参数从左至右是文本、模式串,含义为从文本中匹配模式串。尽管将这两个参数颠倒对我们的函数内部没有什么影响,然而,我们通常的表述为在某段文本中寻找某串字符,这里的参数设计即是按这一表述设计的,调用时也方便我们直接调用,而反过来的话就不太符合语义,同时还要明确知道这两个参数的含义。
2.2算法二:KMP算法
与其他一般的算法不一样的是,KMP算法并不是按照算法实际用途的名字命名的,而是由D.E.Knuth,J.H.Morris和V.R.Pratt提出的,因此人们称它为克努特—莫里斯—普拉特操作(简称KMP算法)。
2.2.1KMP算法的原理
在蛮力匹配算法中可以看出,当我们指针往后移动时,我们已经扫描过一段字符,可以说我们已经掌握了这段字符的信息(即这段字符串是由那些字符在哪个位置构成的),因此可以在下一次发生失配时,我们完全可以用业已掌握的信息来进行指针的移动。该思想如下图所示:
我们在此时发生失配,对于T,指针i
前面的字符我们已经扫描过,因此我们无须再去费力的扫描一次,我们只需移动模式串的指针,指向某个对齐的位置,然后继续进行扫描,那么j
应该指向哪个位置呢?经观察,你或许可以迅速得出如下结论:
因为他们前面有一个共同的A。再如下面的这个例子:
在上述位置发生失配后,经观察不难得出,j
应该按照如下方式移动:
那么,我们的问题就转换为了如何来移动指针j
的问题:当发生失配时,指针j
该移向何处?
2.2.2记忆力 OR 预知力
前文说到,我们在第一次发生失配时,已经得到失配前的子字符串的信息,事实上,只要我们的记忆力足够强,就可以将上一轮比对中所获得的的信息存储起来,并为后续的比对所利用,这类信息的使用原理和方法如下图所示:
上图是一个串匹配过程,并且不失一般性。第一行T中灰色阴影部分为已经扫描过并且不匹配的部分,我们失配于图中红色方框框起来的位置,其分别对应于T(i)和P(j),而第三行则是我们要将指针j
移向的新位置,我们不妨把这个位置记作T。
在此前的匹配中,T[i-j)和P[0,j)是完全匹配的,这一点毋庸置疑,只要能够利用此类信息,就能够迅速的排除掉大量的对齐位置,使得模式串大幅度的“右滑”,同时利用这一信息,我们也没有必要再次比对文本i
之前的字符,而是利用这一信息,从模式串新的位置t
与文本i处的字符继续向后比对。
由上图可以看出,模式串新移动得到的位置t的前边的t
个字符(从位置t
开始,往前数t
个字符),与文本中i
前边的t
(从位置i开始数,往前数t个字符)个字符(图片的第一行与第三行)是完全匹配的,而从图片的第一行和第二行可以看出,文本i
前边的t
个字符,与模式串j
前边的t
个字符也是匹配的,因此,也就是说模式串中j
前边的t
个字符与模式串前t
个字符也是匹配的,t
正恰恰是发生失配时,k
指向的新的位置(请再次注意我们的下标是从0
开始的)。通过上一节的两个例子,可以对该规律进行验证。
t
也就是模式串中位置j前边子字符串的真前缀与真后缀(文章一开始介绍了概念)相匹配的那个最长前缀(或后缀)的长度。
为了同时实现上述所说的没有必要再次比对文本i
之前的字符和大幅度右滑模式串的要求,与其说我们有强大的记忆力,不如说我们未雨绸缪,提前设置好相关的预案,当在某个位置发生失配时,j
可以迅速的跳转到位置t
,这也正是所谓的预知力。
2.2.3Next[j]
表
我们把存放在j
位置处发生失配时j
所要跳转到下一个位置t
的表形象的称为Next[j]
表,即Next[j] == t
。
2.2.4KMP主算法
关于Next[j]
表的构造,我们稍后再议,假想此处我们已经构造出来了该表,先根据我们以上的分析,给出KMP算法。
public static int KMP(String ts, String ps) {
char[] tc = ts.toCharArray();
char[] pc = ps.toCharArray();
int i = 0; // 主串的位置
int j = 0; // 模式串的位置
int[] next = getNext(ps);//构造Next[]表
while (i < tc.length && j < pc.length) {//自左向右,逐个字符匹配
if (j == -1 || tc[i] == pc[j]) {//若匹配,则携手共进
i++;
j++;
} else {
j = next[j]; //若失配,则i不动, j回到指定位置
}
}
if (j == pc.length) {
return i - j;
} else {
return -1;
}
}
可以看出,相对于蛮力匹配,除了构造Next[j]
表外,我们的算法仅在else
分支处按照我们的分析略做了改动。然而,细心的你不难发现,在我们判断字符是否相等的语句(while
中的if
语句)中,多了一条j == -1
,那么,又该如何理解这句代码呢?(请看下文的通配哨兵一节)
2.2.5Next[j]
表的构建
介绍完KMP的主算法之后,就可以介绍Next[j]
表的构建方法了,或许你会好奇,Next[j]
表明明是KMP算法的一部分,为什么不先构建呢?这恰恰是这里的关键,我们在记忆力 OR 预知力一节最后分析得到,Next[j]
的值恰恰是j前边子串真前缀与真后缀相匹配的最大串的长度,这就又回到了串匹配上(警告:禁止套娃)。
2.2.5.1通配哨兵
我们知道,当发生失配时,j
则向前移动到位置t
,然而不幸的是,并不是所有的位置都可以向前移动,这个及其不幸的位置即为模式串的第0
个位置(再再强调一遍,我们的下标从0开始),因此,我们假想的认为,在这个位置的前边有一个位置,其位置是-1
,在-1
的这个位置上放有一个通配符,它可以与文本串中的任意一个字符都可以发生匹配。我们把下标为0
位置处的Next[j]
值置为-1
,即Next[0] = -1
.(为了防止混淆,这里我们把一个=表示为赋值符,两个等号==表示为数学上的判定相等)
这样,如果一开始就发生失配,即j == 0
,对应KMP算法的else
分支,查Next[j]
表,j应当移动到Next[0] = =-1
处,即j = -1
我们刚刚说过,-1
处我们假想了一个通配符,它与文本中的任何字符都相配,根据KMP算法,i
和j
应携手并进。如下图所示:
一开始就发生了失配,根据算法,将j
移动到-1
处,即:
假想的通配符与文本中的字符匹配,所以携手并进:
这正是我们KMP算法中if
语句里j == -1
的由来,如果不这样做,正如我们上边所分析的,我们需要单独做一个逻辑分支,来处理模式串首字符失配的情况,引入通配哨兵则巧妙的化解了这一难题。
同时还要注意的是,由于逻辑运算具有短路求值的特性,当j == -1
成立时,并不会执行||
后的语句,从而也就不会产生下标越界的异常,这是值得警惕的的,如果不按照上述代码的书写顺序,就会引发下标越界异常,因为我们真实的下标最小是0
,而-1
仅仅是辅助我们理解和处理首字符失配这一特殊问题假想出来的,实际下标并不能到达-1
。
2.2.5.2递推构造
由上一小节知,我们将所有的模式串对应Next[]
表的首项统一初始化为-1
,即Next[0] = -1
。
那么,如何构造Next[]
表的其他项呢?假设我们已经构造出了该表的前j
项,我们只需将目光放置高效的构造Next[j+1]
项。
回顾定义:所谓Next[j]
,就是在模式串中,j
位置前面子字符串真前缀与真后缀相匹配的最大的那个(前缀/后缀)长度。前边说过,这也为串匹配,因此,我们完全可以按照KMP串匹配的思路来构造表,为了便于区分,我们把Next[j]
记作t
,依定义,t
是严格小于j
的。
我们不妨把模式串看做文本P
,模式串的前t+1
项看做一个新的模式串P1
。Next[]
表前j
项已经构造出来就等同于,模式串P1
的前t
项与文本P
的[j-t,j]
匹配,如下图所示:
由KMP算法知,当j+1
处和t+1
处的字符匹配时,t
和j
携手并进,因为Next[j]
等于t
,所以Next[j+1]
就等于t+1
。
当j+1
处和t+1
处的字符失配时,模式串中的指针则要退回到它对应的Next[]
表所指的位置,即t = Next[t]
。
(实际上可能在此会有一个疑问:P1是P的前t+1个字符,所以P1和P从第一个字符就开始匹配呀,为什么还会有失配的情况?上边经我们分析,Next[0] = -1
,所以实际上是从P1的下标为0
的字符和P的下标为1
的字符开始做匹配工作的,这也恰恰是启动Next[]
表的钥匙)
2.2.5.3Next[]
表算法实现
基于以上分析,我们就可以给出Next[]
表的算法,其主体算法与KMP算法框架一至。
public static int[] getNext(String ps) {
char[] pc = ps.toCharArray();
int j = 0;
int[] next = int[pc.length];
int t = -1;
next[0] = -1;
while ( j < pc.length-1) {//自左向右,逐个字符匹配
if (t == -1 || pc[t] == pc[j]) {//若匹配,则携手共进
next[++j] = ++t;
} else {
t = next[t]; //若失配,则j不动, t回到指定位置
}
}
return next;
}
上述代码中,应值得注意的是,我们算的是Next[j+1]
,而该数组的下标最长不超过pc.length
,即j+1<pc.length
,j<pc.length-1
。
2.2.6美中不足
至此,我们的KMP算法貌似构造完成了,然而,其仍然存在一点小小的瑕疵。
对于上图所给定的文本和字符串,依据我们上述的算法,不难构造出P的next表,可以看出,我们在图中指针指向的位置处发生失配,按照KMP算法,j
应移动的它的Next[j]
处,即下标为2
的位置,如下图所示:
和上边一样,此时又发生了失配,j
继续往此时对应的Next[j]
移动……如此分析下去,我们似乎发现,程序好像又回到了蛮力匹配上?
我们不难看出,当失配于P[j]
时,如果P[Next[j]]
和P[j]
字符一致时,自然而然的也会发生失配,(Next[j]
与t
是相等的),此时我们需要把j
继续移动到Next[Next[j]]
也就是Next[t]
的位置上,如果仍然一致,则继续移动,但是遗憾的是,我们上边构造的Next[]
表却并未甄别这一问题,因此,再做进一步的改进以弥补这一缺点:
public static int[] getNext(String ps) {
char[] pc = ps.toCharArray();
int j = 0;
int[] next = new int[pc.length];
int t = -1;
next[0] = -1;
while ( j < pc.length-1) {//自左向右,逐个字符匹配
if (t == -1 || pc[t] == pc[j]) {//若匹配,则携手共进
j++;
t++;
next[j] = (pc[t] == pc[j] ? next[t] : t);//这句代码解决了上边所描述的问题
} else {
t = next[t]; //若失配,则j不动, t回到指定位置
}
}
return next;
}
同样的对于上述的例子,经优化后得到的Next[]
表如下图所示:
可以看出,此时又恢复到了我们所期望的效果。
3总结
虽然KMP算法相较于蛮力匹配效率提升了不少,但其并不一定是最优的算法,比如关于串匹配还有BM算法,Karp-Rabin算法等等,由于能力有限,不在此叙述。(等以后有时间了再了解了解,学会了回来填坑)