KMP算法--模式匹配算法(所有代码和思想基于c语言)


前言

字符串简称串,计算机上非数值处理的对象基本都是字符串数据。
例如:我们常见的信息检索系统(搜索引擎)、文本编辑程序(Word)、问答系统。


一、本文所用名词注解(有一定基础可跳过)

串,即为字符串。(由0个或者多个字符串组成的有限序列)
串名:串的的名字。
串长度:字符个数。
空串:字符个数为0个。
子串:串中任意个(可以为0个也可以和串的个数相同)连续的(必须为连续,勿忘)字符组成的子序列。
主串:包含子串的串。
字符在主串中的位置:从‘1‘’开始。(切记为1)
子串在主串中的位置:从‘1‘’开始。(切记为1)

串是一种特殊的线性表,数据元素之间呈线性关系。

串的存储结构
1、静态数组实现(定长的顺序存储)

#define MAX 255 //预定义最大串长为255.
typedef struct SString{
char ch[MAX]; //每个分量存储-一个字符
int length; //串的实际长度
}SString;

2、动态数组实现(堆分配存储也就是malloc,free函数)

typedef struct HString{
char *ch; //按串长分配存储区,ch 指向串的基地址
int length; //串的长度
}HString;

以上为顺序存储
3、链式存储

typedef struct LString{
char ch; //串元素
struct LString *next;//指向下一个节点的next指针
}LString,*L_LSting;

(缺点:存储密度低,因为指针要占用一定空间)

二、串的模式匹配算法

串的模式匹配:在主串中找到与模式串相同的子串,并返回其所在位置

(所谓串的模式匹配算法说人话:就是在一个主串里面寻找符合要求的串)

1.朴素模式匹配算法(就是暴力算)有基础也可以跳过

朴素模式匹配算法:直接在串中比,不用抽出子串比。(也就是说直接比,如果不符合主串不动,子串回到第一个元素继续对比)。

代码如下(基于静态数组实现):

int Index(SString S,SString T){
	int i=1,j=1;
	while(i<=S.length && j<=T.length){
		if(S.ch[i]==T.ch[j]){
			++i;++j;  //当匹配相同时,继续比较后续字符
		}
		else{
			//指针后退重新开始匹配
			i=i-j+1; //主串回到最初匹配位置的下一个位置
			/*例如:    123456
					主串aabaac
					子串aaa
					当子串到3时不匹配,则主串回到2的位置,再次比对
			*/
			j=1;//子串回到开头
		}
	}

}

性能分析:
若模式串长度为m,主串长度为n,则
匹配成功的最好时间复杂度O(m)
匹配失败的最好时间复杂度O(n-m+1)≈O(n-m)≈O(n)
最坏时间复杂度O(nm)


三、改进的模式匹配算法–KMP算法(正文)

请添加图片描述

如图匹配过程,在第三趟匹配中,i=7、 j=5 的字符比较不等,于是又从i=4、j=1 重新开始比较。然而,仔细观察会发现,i=4 和j=1,i=5 和j=1及i=6和j=1这三次比较都是不必进行的。从第三趟部分匹配的结果可知,主串中第4、5和6个字符是’b’、‘c’和’a’ (即模式中第2、3和4个字符),因为模式中第一个字符是’a’,因此它无须再和这3个字符进行比较,而仅需将模式向右滑动3个字符的位置,继续进行1=7、j=2时的比较即可。
在暴力匹配中,每趟匹配失败都是模式后移-一位再从头开始比较。而某趟已匹配相等的字符序列是模式的某个前级,这种频繁的重复比较相当于模式串在不断地进行自我比较,这就是其低效率的根源。因此,可以从分析模式本身的结构着手,如果已匹配相等的前缀序列中有某个后缀正好是模式的前缀,那么就可以将模式向后滑动到与这些相等字符对齐的位置,主串i指针无须正好是模式的前缀,那么就可以将模式向后滑动到与这些相等字符对齐的位置,主串i指针无须回潮,并从该位置开始继续比较。而模式向后滑动位数的计算仅与模式本身的结构有关,与主串无关(这里理解起来会比较困难,没关系,带着这个问题继续往后看)。

说了这么多是什么意思呢?就是说如果在已经比较过的那一段,如果出现了前一部分和后一部分一样的,并且中间没有重复的则可以跳过不比较了。(这句看不明白也没事先往下看)

1、字符串的前缀、后缀和部分匹配值

要了解子串的结构,首先要弄清楚几个概念:前缎、后缀和部分匹配值。

前缀指除最后一个字符以外,字符串的所有头部子串;
后缀指除第一个字符外,字符串的所有尾部子串:
部分匹配值:为字符串的前缀和后缀的最长相等前后缀长度。
(这三个概念一定要记牢)

下面以' ababa,为例进行说明: ●'a'的前缀和后缀都为空集,最长相等前后缀长度为0. ●'ab'的前缀为(a).后缀为{b), {a}∩{b} =0,最长相等前后缀长度为0.

●’aba’的前缀为{a,ab},后缀为{a,ba}, {a, ab}∩la, ba}={a}, 最长相等前后缀长度为1.
●’abab’ 的前缀{a, ab, aba}∩后缀(b, ab, bab)={ab),最长相等前后缀长度为2.
●’ababa’的前缀{a, ab, aba, abab1∩后缴{a, ba, aba, baba}={a,aba}, 公共元素有两个,最长相等前后缀长度为3.
故字符串’ ababa’的部分匹配值为00123.

这个部分匹配值有什么作用呢?

回到最初的问题,主申为a b a b c a b c a c b a b,子串为a b c a c。利用上述方法容易写出子串'abcac'的部分匹配值为00010,将部分匹配值写成数组形式,就得到了部分匹配值(Partial Match, PM)的表。

请添加图片描述

下面用PM表来进行字符串匹配:
请添加图片描述
(中间两个为a和c因为扫描的书所以看不清)

第一趟匹配过程: 发现c与a不匹配,前面的2个字符'ab'是匹配的,查表可知,最后一个匹配字符b对应的部分匹配值为0,因此按照下面的公式算出子串需要向后移动的位数:

移动位数=已匹配的字符数-对应的部分匹配值(这个公式很重要)

因为2-0=2,所以将子串向后移动2位,如下进行第二趟匹配:

请添加图片描述(水印请忽略,想看的话原书是王道考研教材)

第二趟匹配过程:
发现c与b不匹配,前面4个字符’abca’是匹配的,最后一个匹配字符a对应的部分匹配值为1,4-1=3,将子串向后移动3位,如下进行第三趟匹配:
请添加图片描述
(中间两个为b和c因为扫描的书所以看不清)

第三趟匹配过程:
子串全部比较完成,匹配成功。整个匹配过程中,主串始终没有回退,故KMP算法可以在O(n + m)的时间数量级上完成串的模式匹配操作,大大提高了匹配效率。
某趟发生失配时,如果对应的部分匹配值为0,那么表示已匹配相等序列中没有相等的前后缀,此时移动的位数最大,直接将子串首字符后移到主串i位置进行下一趟比较:如果已匹配相等序列中存在最大相等前后缀(可理解为首尾重合),那么将子串向右滑动到和该相等前后缀对齐(这部分字符下一-趟显然不需要比较),然后从主串i位置进行下一趟比较。

2、KMP算法的原理是什么?

我们刚刚学会了怎样计算字符串的部分匹配值、怎样利用子串的部分匹配值快速地进行字符串匹配操作,但公式“移动位数=已匹配的字符数-对应的部分匹配值"的意义是什么呢? 如图4.3所示,当c与b不匹配时,已匹配abca '的前级a和后缀a为最长公共元素。已知前缀a与b、c均不同,与后缀a相同,故无须比较,直接将子串移动“已匹配的字符数-对应的部分匹配值",用子串前缀后面的元素与主串匹配失败的元素开始比较即可,如图4.4所示。

在这里插入图片描述

对算法的改进方法:
已知:右移位数=已匹配的字符数-对应的部分匹配值。
写成: Move-(j-1)-PM[j-1]。
使用部分匹配值时,每当匹配失败,就去找它前一个元素的部分匹配值,这样使用起来有些不方便,所以将PM表右移一位,这样哪个元素匹配失败,直接看它自己的部分匹配值即可。
将上例中字符申’ abcac’的PM表右移一位, 就得到了next数組:

请添加图片描述

我们注意到: 1)第一个元素右移以后空缺的用-1来填充,因为若是第一个元素匹配失败,则需要将子串向右移动一位,而不需要计算子串移动的位数。 2)最后一个元素在右移的过程中溢出,因为原来的子串中,最后一个元素的部分匹配值是其下一个元素使用的,但显然已没有下一个元素,故可以舍去。 这样,上式就改写为

Move- (J-1) -next [j]

相当于将子串的比较指针j回退到

j=j-Move=j-((j-1) -next{j1)-next[j]+1

有时为了使公式更加简洁、计算简单,将next数组整体+1。因此,上述子串的 next数组也可以写成

在这里插入图片描述

最终得到子串指针变化公式j=next[j].在实际匹配过程中,子串在内存里是不会移动的,而是指针在变化,文中画图举例只是为了让问题描述得更加形象。next[j]的含义是:在子串的第i个字符与主串发生失配时,则跳到子串的next [j]位置重新与主串当前位置进行比较。
如何推理next数组的一般公式?设主串为’s1s2s3…sn".模式串为’p1p2…pn’,当主串中第i个字符与模式串中第j个字符失配时,子串应向右滑动多远,然后与模式中的哪个字符比较?
假设此时应与模式中第k (k<j)个字符继续比较,则模式中前k-1个字符的子串必须满足下列条件,且不可能存在k’>k满足下列条件:
‘P1P2…Pk-1’=‘Pj-k+1Pj-k+2…Pj-1’
若存在满足如上条件的子串,则发生失配时,仅需将模式向右滑动至模式中第k个字符和主串第i个字符对齐,此时模式中前k-1个字符的子串必定与主串中第i个字符之前长度为k-1的子串相等,由此,只需从模式串第k个字符与主串第i个字符继续比较即可,如图4.5所示:

请添加图片描述

当模式串已匹配相等序列中不存在满足上述条件的子串时(可以看成k=1),显然应该将模式串右移j-1位,让主串第i个字符和模式第一个字符进行比较,此时右移位数最大。 当模式串第一个字符(j=1) 与主串第i个字符发生失配时,规定next[1]-0。将模式串右移一位,从主串的下一个位置(i+1)和模式串的第一个字符继续比较。 通过上述分析可以得出next函数的公式:

请添加图片描述

上述公式不难理解,实际做题求next值时,用之前的方法也很好求,但如果想用代码来实现,貌似难度还真不小,我们来尝试推理求解的科学步骤。 首先由公式可知:next[1]=0 设next[j]=k,此时k应满足的条件在上文中已描述。此时next [j+1]=?可能有两种情况:

(1)若Pk=Pj.则表明在模式串中
‘P1…Pk-1Pk’=‘Pj-k+1… Pj-1Pj’
并且不可能存在k’>k满足上述条件,此时next [j+1]=k+1,即next [j+1]=next[j]+1
(2)若pk!=pj,则表明在模式串中
‘P1…Pk-1Pk’!=‘Pj-k+1… Pj-1Pj’

此时可以把求next函数值的问题视为一个模式匹配的问题。用前缀P1-Pk去跟后缀Pj-k+1 -
P匹配,则当Pk!=Pj;时应将P1-Pk向右滑动至以第next [k]个字符与Pj比较,如果Pnext[k]与
Pj还是不匹配,那么需要寻找长度更短的相等前后缀,下一步继续用Pnext[next[k]]与Pj比较,
以此类推,直到找到某个更小的k’ =next [next…[k]] (1<k’<k<j), 满足条件
‘P1…Pk ‘=Pj-k’+1…Pj’
则next[j+1]=k’ +1.
也可能不存在任何k’满足上述条件,即不存在长度更短的相等前缀后缀,令next [j+1]=1。
理解起来有一点费劲?下面举一个简单的例子。

请添加图片描述

图4.6的模式串中已求得6个字符的next值,现求next [7],因为next [6]=3,又P6!=P3, 则需比较P6和P1(因next[3]=1),由于P6!=P1,而next [1]=0,所以next[7]=1;求next [8], 因P7=P1,则next[8]=next[7]+1=2;求next[9],因P8=P2。 则next[9]=3. 通过上述分析写出求next值的程序如下:
void get_next(String T,int next[]){
	int  i=0,j=0;
	next[1]=0;
	while(i<T.length){
		if(j==0||T.ch[i]==T.ch[j]){
			++i,++j;
			next[i]=l;  //若pi=pj,则next[j+1]=next[j]+1
		}
		else 
			j=next[j];//否则令j=next[j],循环继续
	}

}

计算机执行起来效率很高,但对于我们手工计算来说会很难。因此,当我们需要手工计算时,还是用最初的方法。

与next数组的求解相比, KMP的匹配算法相对要简单很多,它在形式上与简单的模式匹配算法很相似。不同之处仅在于当匹配过程产生失配时,指针i不变,指针j退回到next[j]的位置并重新进行比较,并且当指针j为0时,指针i和j同时加1。即若主串的第i个位置和模式串的第一个字符不等,则应从主串的第i+1个位置开始匹配。具体代码如下:

int Indx_KMP(String S,String T,int next[]){
	int i=1;j=1;
	while(i<=S.length && j<=T.length){
		if(j==0||S.ch[i]==T.ch[j]){
			++i;++j;  //继续比较后继字符
		}else
			j=next[j];  //模式串向右移动
	}
	if(j>T.length)
		return i-T.length;  //匹配成功
	else
		return 0;	//匹配失败
}

尽管普通模式匹配的时间复杂度是O(mn), KMP算法的时间复杂度是O(m+n),但在一般情况下,普通模式匹配的实际执行时间近似为0(m + m),因此至今仍被采用.。KMP算法仅在主串与子串有很多“部分匹配”时才显得比普通算法快得多,其主要优点是主串不回溯。

四、KMP算法的进一步优化

前面定义的next数组在某些情况下尚有缺陷,还可以进一步优化。如图4.7 所示,模式' aaaab '在和主串' aaabaaab '进行匹配时:

请添加图片描述

当i=4、j=4时,S4跟P4(b!=a)失配,如果用之前的next数组还需要进行S4与P3的、S4与P2、S4与P1这3次比较。事实上,因为Pnext[4]-3=P4=a、Pnext[3]-2=Pz=a、Pnext[2]-1=P2=a,显然后面3次用一个和P4相同的字符跟S4比较毫无意义,必然失配。那么问题出在哪里呢? 问题在于不应该出现Pj=Pnext[j].理由是:当Pj!=Sj,时,下次匹配必然是Pnext[j]跟sj比较,如果Pj=Pnext[j],那么相当于拿一个和Pj相等的字符跟s,比较,这必然导致继续失配,这样的比较毫无意义。那么如果出现了Pj=Pnext[j]应该如何处理呢? 如果出现了,则需要再次递归,将next [j]修正为next [next[j]],直至两者不相等为止,更新后的数组命名为nextval,计算next数组修正值的算法如下,此时匹配算法不变。
void get_nextval(String T,int nextval[]){
	int i=1,j=0;
	nextval[1]=0;
	while(i<T.length){
		if(j==0||T.ch[i]==T.ch[j]){
			++i.++j;
			if(T.ch[i]!=T.ch[j]) nextval[i]=j;
			else nextval[i]=nextval[j];
		}
		else
			j=nextval[j];
	}
	
}

总结

本文进行了KMP算法和KMP改进算法的解释。
其中KMP算法的本质其实就是从模式串中不匹配字符的前一个字符进行回溯。(注意一定要是前一个)
然后第一个难点就是next数组的求法,next数组其实就是看模式串 从前往后直到当前字符的位置(不包括当前字符)和(不包括第一个字符)从前往后直到当前位置(包括当前位置)的所有子串进行对比,看最长的相同子串,从而进行较少的比较。第二个难点就是在于从模式串中不匹配字符的前一个字符进行回溯的理解。(因为到这个已经不匹配了所以没必要再去对比,而是从前一个对比)。第三个难点就在于当不匹配时,在前next数组往前迭代的过程,其实就是不管前面已经完成了的对比,而是从最新的开始往回对比,如果合适就不管了,不合适则继续往前迭代。
笔者的学习过程是先明白了手算,再学习机算。建议先理解手算next数组再学习机算next数组。然后在学习改进。
好了,今天的学习就到这里。
笔者是个小菜鸡,有什么错误或者更好的见解欢迎评论区留言讨论。

本文仅限于学习!!!

  • 3
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 3
    评论
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值