解开KMP的结__初学

  初学数据结构,被KMP算法内部的实现细节绕的有点晕,闷头想了大半天,才稍稍有些理解。

0.KMP的特点

  失去匹配后,主串指针不用动,等待子串指针的新反馈。子串指针也不必直接回到首位,只需要回到该失匹位置下记录的跳转点继续尝试匹配。
  对next数组,每一位记录的都是基于公共前后缀的跳转点的位置。如果只是数公共前后缀个数也很好理解,但**代码是怎么数前后缀的?**我认为厘清这点,是扎实理解本算法内核的关键。

1.首先应明确几点

①在子串与主串失匹的那一个位置之前,子串的这部分元素都已经和主串相匹配。

②已匹配的子串的内部也可能有重复的、可匹配的小子串。
  子串和主串部分匹配,子串前缀和子串后缀匹配,所以子串前缀可以和主串继续匹配。
ab-ab-ab
③子串只会从首位开始的前缀跳到当前后缀,不管中间的。
  比如cdabcdabcdabX—该做何解?最大后缀是cdab,前缀也同样是cdab,中间的cdab怎么办?建立next数组的过程中,中cdab的指向是最前面的位置,后cdab的指向也是最前面的位置。现在是三个cdab,说明了这时候子串的指针已经指到了第三个cdab,当b和前缀匹配之后,在下一位X(失匹位)设置的跳转值,是跳到第一个cdab之后的下一位,即第二个cdab的c处,也就是一跳转,前cdab就盖到了后cdab上,继续和主串对比。中间被略过的原因,是因为现在只看后缀,中间的cdab其实在更之前四位的匹配中就当过后缀了。

④无论子串还是主串,指针后方的内容都不被考虑。回溯
  在子串指针回溯(j=6 -> j=3)之后,之前经过的内容(j=4~6,a b c)和主串指针之后的内容是否匹配是未知的,需要一个个重新再比较。如这第四趟,(ab)a之后的内容是要重新去比较对比的,模式串的aabc之前已经被指过,但还需要重新对比。因为子串唯一可以得知的信息,是next数组反馈的跳转位置,只有前缀-(ab)和主串指针左侧对应长度内的符号-ab是确定相同的–②。

⑤没有公共前后缀,就回溯到子串第一位。一直匹配不上,回到零后加一。
  子串和主串匹配时,如果没有很长的公共前后缀,子串指针回溯的距离就远,那么需要更多次的连续匹配才能查询成功,比如next[10]里放的是1,现在第十个元素失匹,j从第十个跳到第一个,后面继续从第一个元素开始匹配,若能完全匹配也要走很多步。公共的前后缀越短,跳的越远。如果有比较长的前后缀,子串指针便不必回溯太远的距离,距离完全匹配的差距是比较小的。
  越重合,回溯的距离越短,越接近胜利。越不重合,回溯的距离越长,越快跳过这片区域。

2.了解一下代码的组成

我这里认为串从1开始,忽视0号索引内容
①KMP函数里用到哪些成员?
  两个用于记录长度的变量
  两个用于指示地址的变量
  一个储存跳转地址的数组
  一个为跳转数组赋值的函数
②有多少句判断?
  判断是否处理完主串
  判断两指针指示的数据是否相等
  判断子串指针是否回零
  判断子串指针是否到头
③有多少句赋值?
  初始化中有五次
  循环体中,判断回零和判断匹配后为两指针赋值
  既没回零,又不匹配,为子串指针赋值

int Index_KMP(const char S[], const char P[], int pos) {
	int sPointer = pos;
	int tPointer = 1;
	int m = strlen(S) - 1;//这里不计入首位
	int n = strlen(P) - 1;
	int *next = new int[n];
	Get_nextval(P, next);
	while (sPointer <= m && tPointer <= n) {
		if (tPointer == 0 || S[sPointer] == P[tPointer]) {
			sPointer++; tPointer++;
		} else {
			tPointer = next[tPointer];//把之前记录在这个位置的最大重复前缀数赋给子串指针,保证匹配效率
		}
		if (tPointer > n)return sPointer - n;
	}
	return 0;//这里0代表不存在
}

KMP算法的特点和原理总大体上来说还是比较好理解的,代码上看,最关键的是不匹配的时候,模式串指针跳转去指向相应的最大前缀处

             else  tPointer = next[tPointer];

所以要基于这点展开,知晓这个next跳转数组的建立过程,才可大致理解KMP。

完整代码

#include<stdio.h>
#include<string.h>

int Index_BF(const char S[], const char P[], int pos) {
	int sPointer = pos;
	int pPointer = 1;
	int m = strlen(S) - 1;
	int n = strlen(P) - 1;
	while (sPointer <= m && pPointer <= n) {
		if (S[sPointer] == P[pPointer]) { sPointer++; pPointer++; } else {
			sPointer = sPointer - pPointer + 2;//pP同时记录了本次的增长,连续判断几次成功,往右走了几步。这样i回溯去自己原来的位置的下一位,就先扣掉j回原,再+1
			pPointer = 1;//回子串首位
		}
	}
	if (pPointer > n)return sPointer - pPointer + 1;
	else return 0;
}

void Get_next(const char P[], int *next) {
	int b = 0, f = 1;//behind front
	int m;
	m = strlen(P);
	next[1] = 0;
	while (f < m) {
		if (b == 0 || P[b] == P[f]) {//b==0相当于下面这个b=next[b]的递归结束条件
			next[++f] = ++b;//b++; f++; next[f] = b;  //匹配,下一位!++b也就是落后指针匹配位的下一位,刚才的匹配是已知的,所以从下一位继续
		} else {// 原来写的是while (b>0&&P[b]!=P[f]),但其实只要上面的if不执行,f不增长就可以一直循环。if判断失败,肯定符合这while			
			b = next[b];
		}
	}
}
void Get_nextval(const char P[], int *nextval) {
	int b = 0, f = 1;//behind front
	int m;
	m = strlen(P);
	nextval[1] = 0;
	while (f < m) {
		if (b == 0 || P[b] == P[f]) {//主要的不同是,改变对数组的赋值
			nextval[f] = ((P[++b]!=P[++f])?  b : nextval[b]);
		} else {
			b = nextval[b];
		}
	}
}
int Index_KMP(const char S[], const char P[], int pos) {
	int sPointer = pos;
	int tPointer = 1;
	int m = strlen(S) - 1;//这里不计入首位
	int n = strlen(P) - 1;
	int *next = new int[n];
	Get_nextval(P, next);
	while (sPointer <= m && tPointer <= n) {
		if (tPointer == 0 || S[sPointer] == P[tPointer]) {
			sPointer++; tPointer++;
		} else {
			tPointer = next[tPointer];//把之前记录在这个位置的最大重复前缀数赋给子串指针,保证匹配效率
		}
		if (tPointer > n)return sPointer - n;
	}
	return 0;//这里0代表不存在
}
int main() {
	char S[] = " ACACACAcFungreatagain";//0位先空,以序号1位首位
	char T[] = " AcFun";
	printf("%s\n", S);
	printf("%s\n", T);
	printf("%d\n", Index_KMP(S, T, 0));

	int n = strlen(T);
	int *next = new int[n];
	Get_next(T, next);
	for (int i = 1; i < n; ++i)
		printf("%d ", next[i]);
	printf("\n");
	return 0;
}

3

3.next数组

  在next数组的构建函数中,我把会回溯的、总是在更左侧的指针称为副指针,另一个有增无减的称为主指针。
有几大原则:
①主指针只会增加,而且只会和副指针一起增加。
  如果失去匹配,可以说主指针会等待副指针。
②next里求前后缀的一头一尾,不会是中间的!–同1.③
  每次都是从最左边的头部开始算前缀,后缀的末尾也一定含最右边贴着主指针的那个元素。中间不会略过有效输出。
③副指针说明了前缀的长度,同步移动的主指针隐性体现了后缀的长度。
  next数组创建过程中,副指针每次回溯,前缀也被缩短。此位置的后缀也必定会被缩短。失去匹配后,主指针不动,副指针回溯,回溯到和主指针指向重新匹配或者回零+1为止,然后双指针右移,此时才会为next数组赋值,因为赋值的内容是副指针的位置,副指针回去了,当然代表前缀长度的值就小了。

在两种情况下,主指针会右移/增加

        if (b == 0 || P[b] == P[f]) next[++f] = ++b;

  ①两元素匹配,主指针右移,同时副指针会右移,可以保持前缀与后缀的连续性,虽然每次只比较一个元素,但在这种连续性下,相当于处理了一连串后缀前缀。也说明后缀前缀的比较是从各自最左边开始的。两元素匹配也是副指针结束回溯的条件之一。
  ②没元素匹配—副指针回溯到头也没法匹配主指针的指向,回零,右移。这是开始循环的第一步,也是副指针回溯的结束条件之一。
  每次主指针右移,副指针也必定会右移(我这段代码是这样),并且之后会紧跟着一次对next数组的赋值。

代码是如何数公共前后缀的?

  next数组,它里面记录的是本位置的前一个位置的最大前缀后缀重合度。next唯一赋值的时机,就是主指针移动时。赋的是什么值?就是必定与主指针共同右移的副指针移动后的位置。这个位置有什么?有此刻副指针在本次从0开始的过程中积累的变化,这积累的变化就与前缀的长度息息相关,前缀长度是跳转位置确立的基础,因此主指针每移动一次,就有一个跳转位置被确定!这个跳转位置的地址放在主指针移动后的位置,指向副指针移动后的位置。求最大的重合度,只需要承接之前在连续匹配成功中积累的副指针数值大小。
  而最关键的副指针回溯,是让副指针跳转到[副指针当前位置]上[指向的位置],这个指向的位置是以前主指针根据当时副指针位置的,放着过去记录的,比较短的串部分中所含的比较短的公共前后缀。这里便有一个嵌套的过程。
  副指针表明了前缀的长度,同步移动的主指针体现了后缀的长度。next数组创建过程中,副指针每次回溯,前缀也被缩短。此位置的后缀也必定会被缩短,相当于同步移动的次数被换成了回溯之后–以前主指针在现在副指针位置时两者的同步移动长度。失去匹配后,主指针不动,进入等待;副指针回溯,回溯结束后主指针移动了才会再赋值,因为赋值的内容是副指针的位置,副指针回去了,当然代表前缀长度的值就小了。
  副指针回溯前,与跳转点的距离,等于以前主指针在副指针当前位置时,与副指针的距离。
  比较绕的一点,就是副指针回溯之后,是怎么保证前缀是能重合的。
4  主指针(8)前面几位后缀(B),和回溯之后副指针(4->2)前面的前缀(B)是一样的,只用对比副指针当前位置尝试匹配。
  为什么这前后缀会一样?
  回溯之前副指针(4)前缀(BAB),与主指针(8)后缀(BAB)相匹配。–1
副指针(4)和副指针回溯之后(2)的前后缀重合(B)–2
所以,1位置的B和7位置的B,证明了2与8的前后缀重合–3
  为什么能确保重合?
  因为在早些时候,主指针移动【 (3)->(4) 】/【 (1)->(2) 】之时,记录下了与自己同步前进的副指针的位置【BAB 得1,+1→2】/【B得0,+1=1】。同步前进,即一致,即前缀后缀重合。
  重新整理下时间线。
1.主指针在3->4的时候,在4记下了将来副指针在4处的跳转位置2,副指针在4处需要跳转的时候,就会跳转到2。
2.主指针在4对比的时候,副指针在2,A!=C,失匹,副指针回溯到1,B!=C,回零。
3.回零之后,双指针右移。0->1 , 4->5。 B=B,匹配,双指针右移。
4.副指针从1走到4,主指针从5走到8,这一过程就记录了三个相同的前缀。然后4与8的C和D失去匹配,副指针到了需要跳转的时候。
5.借助主指针留下的跳转位置,副指针跳转到2,这个2左边是B,8左边也是B。
  跳转位置之所以能作为跳转位置,是因为跳转位置的前缀和跳转前失匹点的前缀相同。前缀长度由跳转位置决定。所以跳转后,指针前缀仍是匹配的,直到回零。
  “副指针每次回溯,前缀也被缩短”,正是这样体现的。前缀也是可看作一个子串,同样有匹配的前后缀。较长的前缀,在回溯后变短又变短。

来点画面感

  小主和小副在沙滩上玩捡贝壳的憨憨游戏。小主在前面,小副在后面。小副每次在起点的时候,小主和小副会同时往前走一步;小主和小副各自在新一步捡到的贝壳长的一样,两人也会一起往前走一步。小主每走一步就在脚下记好小副现在的位置,但不是为自己记录的,他是个从不后退的人。小副后面到起点之间的所有贝壳,都要和小主后面几个贝壳一样,他们才能满意。小副如果捡不到和小主一样的贝壳,她就会转头去小主记录的位置。
  为什么要去小主记录的位置?那位置之前的一步,小副捡到了能让他们同时往前走的贝壳;甚至可能那以前的很多步,她都捡到了能让他们同时往前走的贝壳。这个位置的后面到起点的所有贝壳,和自己当前位置的后面几个贝壳一样,自己当前位置后面的所有贝壳,又和小主当前位置的后面几个贝壳一样。虽然现在两人没有捡起一样的贝壳,但去记录点后可能会捡到一样的贝壳;至少能保证到起点的所有贝壳,和小主后面几个贝壳一样。

优化

  优化相当于去掉赚时间差价的中间商,本来俩元素就一样,左边元素也是跳转到更左边,不如直接告诉右边的元素:我都是去那的,你跟我一样的话,你自己直接去那吧。所以在代码上只是增加了一个可能的赋值结果。

next[++f] = ++b;
变成了
nextval[f] = ((P[++b]!=P[++f])?  b : nextval[b]);
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值