KMP算法详解

概述

文章几乎一直在用失配后如何选择新的合适的起点来说这个算法,总之就是为了减少不必要的匹配,直接判掉某些匹配起始点,再就是匹配时主串是不会回退的。

说明

为了方便后文叙述,先定义几个不正式的名词。(这个纯属自己所定义,为了写起来方便)

真前缀,真后缀

对于一个长度为L的字符串S,其真前缀包括由它的前1个字符,或者前2个字符,前K个字符构成的字符串,(1 <= K < L)。

例如对于S=”asdf”

它的真前缀有”a”,”as”,”asd”三个,(“asdf”不算一个真子集,不符合条件)

类似地 其真后缀包括”f”,”df”,”sdf”

相等真前后缀

对于字符串S,称S的一个相等真前后缀为:

取S的一个子串sr,它既是S的真前缀,也是S的真后缀

例如字符串

S=”abaqwaba”,其相等真前后缀包括”aba”, ”a”

S=”abcweabc”,其相等真前后缀包括”abc”

S=”aaawaaa”,其相等真前后缀包括”aaa”, ”aa”, ”a”

S=”abababa”,其相等真前后缀包括”ababa”, “aba”, “a”

而对于S=”asdf”,它是没有相等真前后缀的

针对的问题

设字符串S1作为主串,字符串S2作为待查找串,询问在串S1中能否找到一个连续的子串和S2相等,如果存在找出这个串的起始位置

例如 S1=”qaqabcde”, S2=”abc”  不需要程序,直接观察也能发现”abc”在S1中出现了,起始位置是第4个字符开始。但是如果字符串的长度足够长,就需要借助程序解决。

朴素匹配算法

随意构造一组数据,

朴素匹配算法,也就是暴力尝试

字符串S1的长度为L1,这里L=14,设S2的长度为L2,这里L2=5。

从字符串S1中选取一个起点设为p1,从p1所在位置的字符开始,选取连续的L2长度个字符,由此形成的子串去和串S2逐位比较,如果每个位置都对应相同,则找到答案;否则,换一个起始点p1,再次作上述操作。为了找到答案,我们需要尝试所有可能的起点p1。它的范围包括,1<=p1<=L1-L2+1。在本例中,就是1到10这几个位置上的字符可以作为起点。

这个思路很明确,代码也很快就能写出。不过在本文中的代码在存储字符串时,选择了从下标0开始存储,例如存储字符串”abc”,它的第1,2,3个字符a,b,c对应的下标是0,1,2。所以在编写程序时,这里的p1从0开始(数组下标原因),表示从第一个字符开始作为起点,取连续的L2个字符和S2匹配。

这里的p2最多取到9,因为下标最大到13,9到13是最后一组长度为5的连续子串

int judge(char *S1, char *S2)
{
	int ans = -1;
	int L1 = strlen(S1);//strlen函数求字符串长度
	int L2 = strlen(S2);
	for (int i = 0;i <= L1 - L2;i++)//枚举起点p1,下标从0开始,所以下标最大到L1-L2,表示第L1-L2+1个字符
	{
		bool sign = true;
		for (int j = i;j<i + L2;j++)//从起点p1开始选取连续L2个字符,逐个比较
		{
			if (S1[j] != S2[j - i])
			{
				sign = false;
				break;
				//出现不同说明当前起点开始的子串不可行,break跳出去尝试下一个起点
			}
		}
		if (sign)
		{
			ans = i;
			break;
		}
	}
	return ans;//返回值为-1时表示不存在
}

朴素算法的问题

举一个例子A

S1=”aaaabwaaaaaaaaaaabcac”

S2=”aaaabc”

按照上述算法,第一轮比较是在第6个字符处失配

接着,程序尝试第二个字符开始

模拟发现,前六个字符aaaabw其中任意一个作为起点都会失配.

从第7个字符作为起点匹配,在匹配了4个字符后出现失配

    

按上述算法

下次从第8个字符开始尝试,同样,还是匹配了4个后才发现失配

尝试第9,10,...,13个都是如此,其实这中间程序执行了很多没有必要的比较

我们直接观察很轻松就能发现,中间那一连串的a作起点都是不可行的,但是程序还是在每个位置开始连续作了4次比较。如果题目的测试数据足够大,这样的算法会被轻松地卡掉。

上述算法中,一旦出现失配。S1串就会重新选择下一个起点,S2也是再次从其第一个字符开始去和新的S1中的子串比较。例如从第7个字符作起点时,匹配到了第11个字符出现失配,

然后又让S2串回退到第8个字符为起点重新开始。然后8到12出现失配,回退,9到13..

如果我们能将一个串的匹配过程做到不回退,就可能提高效率

举例B

匹配

S1起点选到第1个字符作为起点开始去和S2匹配,在第8个字符处失配,我们把失配位置(第8个字符)之前,也就是第2,3,4,5,6,7个字符开始作为起点的情况都列出来

S1的第2个字符作起点,第一次字符匹配就失配了

第3个字符作起点,匹配成功了一次

第4个字符作起点,第一次匹配就失配

第5个字符作起点,匹配成功

第6个字符作起点

第7个字符作起点

上面两个例子都存在一些无意义的比较,每个例子主要说明了一种。或者说,有些比较是可以实现推断出来不可行,直接避免的。

改进

可以用如下图概括上述比较

假如在黄色部分是匹配成功了,在蓝色p2位置处失配

下次如果选择了一个起点为p1+k,也就是说下方图中偏移量为k

即银灰色部分长度为k

这次从p1+k位置起开始匹配

可以发现,这次匹配能否完全成功先不说,上次是S1串是从p1匹配到了蓝色位置p2处才失配,如果现在所表示的下图中,从p1+k开始的S1的红色和S2的紫色部分没有完全匹配,那这次以p1+k作为起点还不如之前用p1处作起点匹配的效果好,因为上次从p1匹配到了p2,而如果红色和紫色部分出现不一致,从p1+k开始的匹配止于p2之前的某个位置,还不如从p1开始将匹配推进的远。

单独把p1到p2-1这段拿出来

可以发现,假如把p1到p2-1这个串(上图中原来的整个黄色部分)称为串SP,串SP的红色部分一定是SP的一个真后缀,串SP的紫色部分一定是它的一个真前缀,如果这两个前后缀不完全相等,就说明选择之前选择p1+k作起点匹配的效果一定是不如p1好的。

这说明有些起点的尝试是应该直接避免掉的,判别方法就是,在假如上次起点选择p1,失配位置为p2,这次选择的起点为p1+k,如果从p1+k→p2-1这段字符和p1→p2-1-k 这段字符不完全匹配,这个起点就是绝对可以舍弃的。

所以对于朴素匹配算法的一个改进点就是,在p1作起点匹配失败后,不立即选择p1+1作为起点再从头匹配,而是先排除掉那些一定不会得到正解的起点。而剩余下来的这些可选起点虽然不一定能得到完全匹配,但至少在上述的红色和紫色部分匹配时是成功的,是有可能再向p2+1,p2+2...等更向右的地方继续匹配,有可能取得完全匹配;而那些被排除的点是一定不会得到完全匹配。

例B体现的一种无意义的比较就是,假如失配位置是p2,在p2之前,有些起点是不必要尝试的,例A的后文会提到。

例如在例B中p1=1,p2=8;在选择p1+k=5和p1+k=7时,对应的串SP的真前缀真后缀完全匹配。对应了起点选为5,7

而其他情况都不完全相等,这也分别对应了起点选为6,4,3,2

如果我们在某一个p2处失配之后,能快速知道在此之前的p1到p2-1这个字符串中,使得满足前缀串p1→p2-1-k等于后缀串p1+k→p2-1的k,在下一次选择起点时,直接从这些值中选下一个起点,就能避开所说的无意义的起点。而且,如果这样的k可能有多个,比如本例中k=4或者6都行,而完全匹配即答案出现在哪个起点是不确定的,只是在本例中出现在了更小的k上,所以应该都做尝试。如果有多个k符合,就先从这些k计算出的可行起点中选择最小的来尝试,在后文中,通过next[]数组可以实现按这个顺序尝试。

待查找的S2串的长度一定是大于等于p2-p1的,因为完全匹配的意思就是,如果从某个起点p1一直匹配到p2使得这个子串和S2一样,则完成任务。在匹配过程中,每次匹配成功的长度p1→p2是不确定的,而每次失配之后都要快速计算对于当前p1->p2-1这个子串的所有应该避开的起点。就是说,对于长度为L2的串S2,我们要计算处它的所有子串1->2,1->3,1->4....,1->L2-1,1->L2这些长度的子串的k。

比如上述的S2 = abadabak

对于其子串

如果在匹配中遇到上述任意一种失配时,直接从这些满足的条件里选起点,就会减少计算量

例如

匹配到第7个时失配,之前匹配完全相同的部分是上述的情况6,只有字符串ab满足既是abadab的真前缀也是真后缀,对应之前所说,这里的p1=1,p2=7,k应取4所以下次起点直接选择为p1+k=5

尽管这次选择不一定就找到了最终解,但是已经避开了把起点选择为2,3,4的必定失败的情况。

所以现在要做的就是把上述8中情况里对应的符合条件的子串都计算并记录下来,匹配过程中失配后直接使用这些值。

 核心--next[]数组

设一个字符串S的长度为i,即其包含i个字符,next[i]=j表示的就是这个总长度i的字串的最长相等真前后缀。

例如 S=”aaaqwqaaa”的相等真前后缀有三个 a,aa,aaa,则记录最大值 next[9]=3,表示这个S的第1到3个字符构成的字符串,或者是倒数后三个字符构成的,就是最长相等真前后缀。

对于 abadaba 它的next数组直接观察也能得到

 next数组下标 1 2 3 4 5 6 7

       对应的值  0 0 1 0 1 2 3

再比如     ababa

next数组下标  1 2 3 4 5  

       对应的值  0 0 1 2 3

肉眼直接观察也能得到,可是程序如何计算

设下图黄色部分为串ST1,黄色加蓝色部分为串ST2

我们假设下图中黄色部分长度为len1,蓝色位置为第len1+1个字符,且next[1],next[2],..,next[len1]的值都已计算出来过,现在要计算next[len1+1]的值,也就是计算串ST2的最长相等真前后缀。假设next[len1]=x1,这个值我们是知道的。x1就表示黄色部分字符串的最长相等真前后缀

这个长度x1的部分用绿色标注出来

所以图中绿色部分一样,紫色位置为第x+1个字符,如果紫色位置和蓝色位置的字符也相等那么next[len1+1]就是x+1,表示1到len1+1这个串的最长相等的真前后缀 就是如下图长为x+1这部分

可是,如果蓝色位置和紫色位置的字符不一样呢,这时不能简单把next[len1+1]赋值为0,

单独看绿色这个部分,假如绿色部分长度为len2

设next[len2]=x2,这个值我们也是知道的,即绿色部分这个串的最长相等真前后缀长度为x2,我们用红色标注出来x2

眼光再放回原来的长度为len1+1的串

绿色部分作为原来整个黄色串的真前后缀是相同的,红色部分又是绿色串的最长相等真前后缀,图中四个部分红色的都应该是相同的

把开始部分的红色之后第一个字符标注为深蓝色

如果深蓝色和后面蓝色位置的字符也相同,而红色部分又相同,则next[len1+1]=len2+1,即表示串ST2的最长相等真前后缀是如下图这部分

如果深蓝色位置和蓝色位置还不相同,那么,就继续找红色部分的最长真前后缀,即使用next[x2],如果一直不匹配,就一直这样重复,什么时候停止呢,假如对红色部分继续上述操作,即先求next[x2];

如果next[x2]=0,说明红色部分不存在相等的真前后缀,计算已经走到了边界条件。这时直接比较串ST1(原来整个黄色部分)第一个字符和后面的蓝色位置字符,相等则next[len1+1]=1,说明原来的长len1+1的串的最长相等真前后缀长度为1,否则说明ST2是不存在相等的真前后缀的,next[19]=0。

举一个例子

黄色部分长18,观察得出next[18]=8,即黄色部分的最长相等真前后缀为绿色标注的部分,长度8

所以下一步比较紫色位置和蓝色位置

相等 则 next[19]=8+1=9(next[19] = next[18]+1)

如果不相等呢,例2

这个例子同样next[18]=8

但紫色位置和蓝色位置不同

所以继续找绿色部分的next[8]=3,用红色标注其对应的部分

所以下一步比较深蓝色和蓝色部分相同,next[19]=3+1=4(next[19] = next[ next[18] ] +1)

也有可能一直不同,例3

这时,已经把原来黄色部分的所有真前后缀尝试完了,此时next[1]=0,就会直接去尝试黄色部分的第一个字符和蓝色位置字符,如果相等,next[19]=1;否则next[19]=0;这里不相等,取后者

这时终止,next[19]=0;

所以,以上其实可以写成一个递推程序,因为,next[i]的计算依赖的是事先要知道

next[1],next[2],next[3],...,next[i-1],我们可以从i=1向更大值递推,而且,对于给定的字符串S,不论它总长多少,next[1]=0是固定已知的,因为,单个字符不存在任何真前缀,真后缀。

由此就可以去推next[2],然后next[3]可以由next[1],next[2]推出。这样,对于串S,比如

S=”abcabc”,它的所有前缀串a,ab,abc,abca,abcab,abcabc的各自的最长相等真前后缀都能求得

void get_next(char *p, int next[])
{
	int len = strlen(p);
	next[0] = -1;
	next[1] = 0;
	for (int i = 2;i <= len;i++)
	{
		int k = next[i - 1];
		while (k >= 0 && p[k - 1 + 1] != p[i - 1])//减一是对齐数组下标,第i个字符在数组中下标i-1
		{
			k = next[k];
		}
		next[i] = k + 1;
	}
}

next[i]不仅记录了长度为i的串的最长的相等真前后缀。实际上也记录了其所有的真前后缀

比如abababa  

其所有的相等真前后缀有a,aba,ababa,

next[7]=5只记录了ababa,但对于ababa求next[5]即可得到aba,再对aba求next[3]=1得到a,所以借助next[]数组,我们能知道长度为L的字符串的所有相等真前后缀,而且,长为L-1,L-2,,,1的串的所有真前后缀也都可由next[]数组中找出。

得到next数组后,就可以模拟匹配了,把待查找串S2的next数组求出来。

S1串起点p1先选择第一个字符,然后和S2串匹配,假如匹配到p2位置失配,说明匹配成功的是串S2的前p2-p1个字符,然后我们求next[p2-p1];

现在又回到了之前的问题,失配后选择一个新的起点,之前我们说要避开所有无意义的起点,而有意义的,即有可能匹配成功的起点,是由p1到p2这个串的所有相等真前后缀得来,而这些前后缀都已经存在next[]中

对应上图的例子

这个例子中p1=1,p2=8;

对于abadaba其相等真前后缀有 a, aba

所以先求next[8-1]=3;即绿色标注处都是相同的

这就相当于下一个起点选择了第五个字符开始,相当于尝试了abadaba的真前后缀aba

这样还是失配了,next[3]=1;这相当于尝试了abadaba的真前后缀a

这时,已经把abadaba的所有真前后缀尝试完了,也就是p2之前所有有意义的起点都试过了,next[1]=0;

下一次就会从第p2也就是8个字符开始作起点继续匹配.

你可以发现,使用next数组刚好可以将所有有意义的起点都尝试一遍

再总结下S1和S2的匹配过程

S1总长L1,S2总长L2,假如现在已经走到了选择p1处作为起点匹配,到p2处失配,失配之后就是要找黄色部分(设为串SP)的所有相等真前后缀,只有这些能产生有意义的起点。这些真前后缀就可以通过next数组得到,设len1=p2-p1 表示黄色部分的长度,串S2的next[]数组我们之前求过了,所以next[len]是知道的假如是x1,标注为红色,则红色部分相同,

所以下次起点可以选择为p2-x1,

如果L2得到了完全匹配,程序就完成任务了,否则,要尝试串SP的其他相等真前后缀,而这可由next[x1]得到,如next[x1]=x2;标注为深蓝色,

再由此计算出新的起点p2-x2

如果匹配一直不成功,就不停使用next[]数组转移,最后当next[i]=0时说明SP(原来黄色部分)的所有真前后缀都尝试完了,起点就不再从p1到p2之间选择,下次起点直接从p2开始

写成代码如下

judge函数求从S1串的p1处作起点去和长为L2的串匹配

int judge(char *S1, char *S2, int p1, int len)
{
	int i;
	for (i = 0;i<len;i++)
	{
		if (S1[p1 + i] != S2[i])
		{
			break;
		}
	}
	return i;//返回的i如果等于len说明匹配成功,否则说明完全匹配成功的有i个字符
}

match函数是改进后的匹配算法,函数中的p1作为起点,在一次失配后不是把p1的值简单加1,通过next[]数组来找有意义的起点

int match(char *S1, char *S2)
{
	int len1 = strlen(S1);
	int len2 = strlen(S2);
	int p1 = 0;
	while (p1 <= len1 - len2)
	{
		int tp = judge(S1, S2, p1, len2);
		if (tp == len2)
		{
			break;//说明p1就是要找的起点
		}
		else
		{
			int k = Next[tp];
			p1 = p1 + tp - k;
		}
	}
	if (p1>len1 - len2)return -1;//表示匹配失败
	else return p1;//否则返回起点
}

   

但还可以再改进,我们选择了一个新的起点之后,从这个起点开始到p2是一定完全匹配的,而再程序里judge函数重复计算了这些匹配。

例如

p1失配后选择p2-x1作为起点,judge函数开始从这个起点对S2从头开始匹配,但红色部分是一定相同的,judge函数还是不够高效。所以,KMP算法不仅避免了选择无效的起点,而且在选择一个新的起点后,对S2的匹配也不需要从S2的第一个字符开始匹配,而是直接从S1中上次失配的位置开始,如上图所示,从p2开始和S2中对应的位置(深蓝色)开始匹配。避免掉的这部分对应了刚开始举的例A

 再次改进

int judge(char *S1, char *S2, int p1, int len, int k)
{
	int i;
	for (i = max(k, 0);i<len;i++)  //直接跳过前K个字符的比较,因为前后缀相同重合
	{
		if (S1[p1 + i] != S2[i])
		{
			return i;
		}
	}
	return i;//返回的i如果等于len说明匹配成功,否则说明完全匹配成功的有i个字符
}

int kmp(char *S1, char *S2)
{
	int len1 = strlen(S1);
	int len2 = strlen(S2);
	int p1 = 0;
	int k = 0;
	while (p1 <= len1 - len2)
	{
		cout << p1 << "   sf" << endl;
		int tp = judge(S1, S2, p1, len2, k);
		if (tp == len2)
		{
			break;//说明p1就是要找的起点
		}
		else
		{
			k = Next[tp];
			p1 = p1 + tp - k;
		}
	}
	if (p1>len1 - len2)return -1;//表示匹配失败
	else return p1;//否则返回起点
}

但是,代码可以更简洁,judge函数优化掉

int kmp(char *S1, char *S2)
{
	int i = 0;
	int j = 0;
	int len1 = strlen(S1);
	int len2 = strlen(S2);
	while (i < len1 && j < len2)
	{
		if (j == -1 || S1[i] == S2[j])
		{
			i++;
			j++;
		}
		else
		{
			j = Next[j];
		}
	}
	if (j == len2)return i - j;
	else return -1;
}

模板题HDU1711

这个题目中是把匹配字符串换成了匹配数列。

比如

1 2 1 2 3 1 2 3 1 3 2 1 2

1 2 3 1 3

第二个数列出现在第一个数列的第6到10这几个位置。之前代码只要稍稍修改一下,把字符数组改为整数数组即可。

参考代码

#include <bits/stdc++.h>
using namespace std;
int N, M;
int Next[10010];
int shu1[1000010];
int shu2[10010];
void get_next(int next[])
{
	int len = M;
	next[0] = -1;
	next[1] = 0;
	for (int i = 2;i <= len;i++)
	{
		int k = next[i - 1];
		while (k >= 0 && shu2[k + 1 - 1] != shu2[i - 1])
		{
			k = next[k];
		}
		next[i] = k + 1;
	}
}
int judge(int p1, int len, int k)
{
	int i;
	for (i = max(k, 0);i<len;i++)
	{
		if (shu1[p1 + i] != shu2[i])
		{
			return i;
		}
	}
	return i;//返回的i如果等于len说明匹配成功,否则说明完全匹配成功的有i个字符
}
int kmp()
{
	int len1 = N;
	int len2 = M;
	int p1 = 0;
	int k = 0;
	while (p1 <= len1 - len2)
	{
		int tp = judge(p1, len2, k);
		if (tp == len2)
		{
			break;//说明p1就是要找的起点
		}
		else
		{
			k = Next[tp];
			p1 = p1 + tp - k;
		}
	}
	if (p1>len1 - len2)return -1;//表示匹配失败
	else return p1 + 1;//否则返回起点
}

int main()
{
	int t;
	scanf("%d", &t);
	while (t--)
	{
		scanf("%d%d", &N, &M);
		for (int i = 0;i<N;i++)scanf("%d", &shu1[i]);
		for (int i = 0;i<M;i++)scanf("%d", &shu2[i]);
		get_next(Next);
		printf("%d\n", kmp());
	}
	return 0;
}

简洁写法

#include <bits/stdc++.h>
using namespace std;
#define MAXS 10005
int N, M, T;
int Next[MAXS];
int ar1[MAXS * 100];
int ar2[MAXS];

void getnext(int next[])
{
	int len = M;
	next[0] = -1;
	next[1] = 0;
	for (int i = 2;i <= len;i++)
	{
		int k = next[i - 1];
		while (k >= 0 && ar2[k + 1 - 1] != ar2[i - 1])
		{
			k = next[k];
		}
		next[i] = k + 1;
	}
}

int KMP()
{
	int len1 = N;
	int len2 = M;
	int j = 0;
	int i = 0;
	while (i < len1&&j < len2)
	{
		if (ar1[i] == ar2[j] || j == -1)
		{
			j++;
			i++;
		}
		else
		{
			j = Next[j];
		}
	}
	if (j == len2)return i - j + 1;
	else return -1;
}

int main()
{
	scanf("%d", &T);
	for (int i = 0;i < T;i++)
	{
		scanf("%d%d", &N, &M);
		for (int i = 0;i < N;i++)scanf("%d", &ar1[i]);
		for (int j = 0;j < M;j++) scanf("%d", &ar2[j]);
		getnext(Next);
		printf("%d\n", KMP());
	}
	return 0;
}

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值