深入剖析 kmp 算法

大一学过一个字符串子串匹配算法,叫 KMP 算法,汗颜的是,现在都读研一了,再看这个算法居然懵了,看不懂了,于是就有了这篇文章,深入剖析一下,相当于作个备忘。

在这篇文章中,我侧重于讲原理,并不去附会原 KMP 算法,与原 KMP 算法略不同,但本质一样,我将从我认为比较好接受的方式去剖析这个算法,由动机到本质。

1.原理

字符串子串匹配最容易想到的思路就是,固定原串 S ,让模式串(字符串子串)T 逐位去匹配。这种算法叫做 BF 算法。如图 1 所示。


图1 BF 算法

这种算法效率是比较低的,最坏的情况需要重复比较 strlen(S) 次,而每次比较的复杂度为 strlen(T),因此,时间复杂度可以看作是二次方复杂度。

为什么上面的复杂度高,因为有很多不必要的比较。如:

S = abscabscabsiabsfxxxxxxxxxxxxxxxxxxxxxxx

T = abscabscabsg

第一次匹配,拿 T 与 S 匹配T 匹配到 g 的时候,发现不匹配,此时完全没有必要拿 S + 1(注:本文中的字符串加数字指 c/c++ 中的字符串运算,S + 1 的值即为跳过 S 中的第一个 a 的字符串)与 T 比较,因为不可能会匹配。如图2所示。


图2 不需要的匹配


上面的图说明了 BF 算法在匹配失败后会有很多多余的匹配,那么就出现了 KMP 算法,用来消除这些多余的匹配。直接说明某一次匹配是多余的匹配比较困难,但我们换一个角度想想,如果不是用反证法,而是用正面求解的办法,找出一次匹配失败后下一次应该匹配的位置,也就等价于消除了中间的某些多余的匹配。下面,我们开始求解匹配失败后下一次应该从哪里开始匹配。

图 3 给出了由原匹配局势不匹配,将 S 向前移动 k (0 < k < strlen(T))位,形成的新的匹配局势。


图3 新匹配局势

首先,必存在一个 k 使得匹配之后的跳位再匹配,形成上面的匹配局势,如果不存在这个 k,那么意味着不用新的匹配就说明了 S 和 T 不可能匹配。我们得出以下结论:
1.新局势中s1 = t1,而在原来的匹配局势中,t2 = s1,因而有 t1 = t2;
2.如果 t1 之后不存在连续和 T 匹配的字符,则这个匹配局势是没有意义的,因为它还没有原来的匹配局势匹配的字符串长。所以,t1 这个字符串起码要包含到原匹配局势中最后一个匹配的字符,新的匹配局势可能匹配的字符串长度才有可能超过原局势匹配的字符串长度,才有可能实现完全匹配 T。因此,t1 必须自 T 的开始起头,自上一次匹配失败处结束


因此,新一轮匹配局势图应该为:


图4 新匹配局势(改)

注意图 4 对比图 3 而言的细微改变。图 4 说明两点:

1).t1 自 T 的第一个字符开始。(inclusive

2).t1 自 T 在上一轮匹配失败的那个字符结束。(exclusive

我们只需要求出每一次匹配失败后的 t1 即可知道下一次从哪里开始匹配。如图5,展示求 t1 的方法及例子。


图5 求 t1


由于 t1 可以帮我们找到下一次要匹配的地方,所以 t1 就等价于 KMP 算法中的 next !图 6 展示了求得 t1 之后如何构建下一次匹配。


图 6 next 匹配

图解:由上图,T 匹配失败后,下一次的匹配是:将 S 向前移动,跳过”absc“。此时就认为 T 已经匹配了 abscabs ,继续向后匹配即可。

这就引出了算法的重点:每次匹配失败后, T 向前移动一定的位置,S 维持上次的匹配失效点不动,T 更新匹配失败位置,以重新展开匹配。以上图为例,S 与 T 匹配到红点处失败,下一次匹配要将 T 整体向前(S 中字符等匹配的方向,即向右)移动,使上图中的蓝框移动到绿框的位置。这一过程相当于 S 中的红点位置(正待匹配的位置)不变, T 的红点位置变成了 c 字符的位置(如上图)。

现在的问题就是,如何求那个蓝框的大小,因为一旦知道了蓝框的大小,就知道 T 中新的应该匹配的位置是多少。


2.算法

我们只需要给出求 t1 的算法即可。

问题定义及描述,如图 5。

原字符串是 T ,求它限定长度 n 下的 t1。

最容易想到的方法:

1.求出 T 的子字符串(0 ~ n),记为 Q

2.从 Q 的尾部字符 w 开始,往前遍历 Q ,直到遇到一个字符等于 w,记下这个位置 ,再取 w 的前一个字符与那个位置的前一个字符相比,如此往复,一直往前推,直到推到 Q 的首部,就成功了。

如果中间有一次失败,就要重新开始往首部方向匹配。

这个算法的复杂度是 n。

如果我们按这个算法来求 t1,则整个算法的复杂度还是二次方。为什么会有这么高的复杂度 ?我们用 Tx 表示 T 的从 0 到 x 的子字符串,我们是依次以 T1,T2,T3....Tn 去求 t1 的,每次都是在线计算,其实, Tx 和 Tx+1 求 t1 是有联系的,我们的算法没有充分利用这个联系,导致了大量的计算。图 7 示例指示了 n = i 和 n = i+1 之间的联系。


图7 n=11和n=12间的联系

图 8 给出了求 t1 的原理。


图8 t1的求解原理


根据图 8 揭示的原理,发现 t1 实际上与 S 没有关系,只与 T 本身的字符串的值有关。

设 T 的长度为 N ,我们需要用一个数组 A 来保存 n 分别等于 0 ~ N 时,求得的 t1 的长度 x(不需要存储 t1,只需要存长度就可以推知 t1 的值,t1 = T[0,x-1]),还有一个好处,这个长度刚好指出了每一次求出的 t1 的尾字符的位置,它刚好是我们算法里面要用到的。令 A[0] = 0,因为 T[0] 是一个空字符串,所以求得 t1 长度为0;令  A[1] = 1,因为 T[1] 只有一个字母,我们求得 t1 就等于它本身。依照图 8 的原理,我们给出一个实例,分析数组 A 的求法。

设字符串 T 为:abaabcac

依次求 A 如下:

A[0]:0

A[1]:0

A[2]:输入字符串 T2 = "ab" ,先看上一次求得的 t1 长度(A[1])为0,则倒数第二个字符 a 没有对应的 a。再比较 b 与首字符,不相等。所以,t1 为空,即A[2] = 0;
表示 T2 最后一个字符 T[1] 不与任何字符对应。

A[3]:输入字符串 T3 = "aba",查看A[2]为0,直接比较当前字符 a 与首字符,相等,则 t1 = "a";表示 T3 最后一个 a 与首字符对应。

A[4]:输入字符串 T4 = "abaa",查看A[3]为1。
开始链式向上寻找对应:
1.设 T4 倒数第二个字符与 T[0] 对应,由于 T4 最后一个字符 a 与 T[0] 后面一个字符 b 不相等,说明它们不应该被对应。
2.求 T[0] 的对应,把这个对应当成 T4 倒数第二个字符的对应。而A[1]等于0,说明 T[0] 不与任何字符对应。
3.链式对应结束,没有找到对应。比较 T4最后一个字符是否与首字符相等,它们是相等的,所以 A[4] = 1;表示 T4 最后一个 a 与首字符对应。

A[5]:输入字符串 T5 = "abaab",查看A[4]为1,说明 T5 倒数第二个字符与首字符对应,再看首字符后面一个字符 T[1] 是否与倒数第一个字符 b 相等,它们是相等的,
此时 t1 = "ab",则A[5] = 2,T5 最后两个字符分别与首字符和第二个字符对应。

A[6]:输入字符串 T6 = "abaabc",查看A[5]为2。
开始链式向上寻找对应:
1.设 T6 倒数第二个字符与 T[1] 对应,由于 T6 最后一个字符 c 与 T[1] 后面一个字符 a 不相等,说明它们不应该被对应。
2.求 T[1] 的对应,把这个对应当成 T6 倒数第二个字符的对应。而A[2]等于0,说明 T[1]不与任何字符对应。
3.链式对应结束,没有找到对应。比较 T6最后一个字符是否与首字符相等,不相等,则A[6] = 0;说明 T[5]不与任何字符对应。

A[7]:输入字符串 T7 = "abaabca",查看A[6]为0,直接比较 T7 最后一个字符与首字符,相等,说明 A[7]为1。

A[8]:输入字符串 T8 = "abaabcac",查看A[7]为1。
开始链式向上寻找对应:
1.T8 倒数第二个字符与 T[0] 对应,而 T8 最后一个字符与 T[1]不相等。
2.求T[0] 的对应,而 A[1] = 0,说明 T[0] 不与任何字符对应。
3.链式对应结束,没有找到对应。比较 T8 最后一个字符与首字符,不相等,则 A[8] 为 0。

最终的结果为:
n  0  1    2    3    4    5  6    7    8
A  0  0    0    1    1    2  0    1    0


再次申明 A 数组与我们要解决的子字符串搜索的问题的相关性。以图9给出。


图9 T中下次匹配点 j = A[j]


3.总结

至此,KMP 算法的原理我们已经搞清楚了,它的思想只有一句话:“跳过 k 位继续匹配而不是只跳过 1 位”。

如果要继续点破,这里的内涵比较多,如果一个字符匹配失败,那么,肯定是要在 S 的后续中(此处后续是相对于 S 与 T 最近一次连续匹配的第一个匹配点)再匹配。那么,S 后续中肯定要能找到 T 的首字母,否则永远都不会匹配。可是,只找到 T 的首字母不行啊,还必须保证后面有尽量多的匹配才有可能匹配整个 T,那么,这个尽量多,最少应该有多少呢?答案就是,起码到原不匹配的位置。因为只有到了原不匹配的位置,才有可能超过匹配失败的局势中匹配的字符串的长度。依上面的思路求得的就是 t1的长度。


4.实现

讲完了理论,最后拿出点干货。在下面的代码中,我实现了 indexOf 和 lastIndexOf 的功能。

说明一下,indexOf 是从字符串最前面开始向后搜索,lastIndexOf 即是从字符串的最后面开始向前搜索。如: 在“lizhihaoweiwei ”中搜索“wei”,使用 indexOf 的方式搜索,则结果为 8,使用 lastIndexOf 的方式搜索,则结果为 11。

indexOf 的实现就是上面讲过的 KMP 算法的实现。

本质上, lastIndexOf 的实现与 indexOf 差不多,只不过要将原来从左到右的思维转换为从右到左的思维。步骤如下:

1.求 T 从右向左的 A 数组,即,从 T 的最右边开始,往左边依次取 0 个,1个,2个,.... n 个字符作为子串 T0,T1,T2....。设子串为 Tx ,求这个子串的 t1 的长度即是:找出 Tx 的最长的前缀,并且使得它是 Tx 的后缀,t1 便是这个条件下的前缀/后缀。然后把 t1 的长度放进 A[x] 里面。

2.搜索,在 S 中从右往左搜索 T (也是从右往左),不断更新匹配失败后下一次应该的匹配点。


最终列出源码。


// KMP.cpp : Defines the entry point for the console application.
//

#include "stdafx.h"

#define MAX_LENGTH 128
static int t1Length[MAX_LENGTH] = {0};//A[i] 表示字符串 T[0 ~ i-1] 求得的 t1 的长度
static int t1LastLength[MAX_LENGTH] = {0};//A[i] 表示字符串 T[strlen(T) - i ~ strlen(T)-1] 求得的 t1 的长度

//求一个字符串的反向字符串,测试用
void reverseStr(char *str)
{
	char exCh;
	int strLen = strlen(str);
	int maxExPos = strLen / 2;
	for (int i = 0;i < maxExPos;++ i)
	{
		exCh = *(str + i);
		*(str + i) = *(str + strLen - 1 - i);
		*(str + strLen - 1 - i) = exCh;
	}
}

//获得一个随机的字符串,测试用
void randomStr(char *str,int maxLen)
{
	srand((unsigned)time(NULL));  /*随机种子*/
	//97 ~ 122
	for (int i = 0;i < maxLen;++ i)
	{
		*(str + i) = rand() % 26 + 97;
	}
}

//初始化正向的 t1LengthArray
void initT1Length(const char *pStr)
{
	int totalLength = strlen(pStr);
	const char firstChar = *pStr;
	char lastChar;	//最后一个字符
	char beforeLastChar;	//倒数第二个字符
	int lastT1Length;	//上一次求的 t1 的长度
	t1Length[0] = t1Length[1] = 0;
	for (int i = 2;i <= totalLength;++ i)
	{
		lastT1Length = t1Length[i-1];	//前一个,所以是 i - 1
		if(0 == lastT1Length)	//前一个字母没有对应
		{
			t1Length[i] = (firstChar == *(pStr + i - 1));
		}
		else
		{
			lastChar = *(pStr + i - 1);
			beforeLastChar = *(pStr + i - 2);
			while(0 != lastT1Length && *(pStr + lastT1Length) != lastChar)	//对应字符的后面一个字符如果与最后一个字符相等则退出
			{
				lastT1Length = t1Length[lastT1Length];
			}
			if(0 != lastT1Length)	//因为 *(pStr + lastT1Length) == lastChar 跳出的循环
			{
				t1Length[i] = lastT1Length + 1;
			}
			else
			{
				t1Length[i] = (firstChar == *(pStr + i - 1));
			}
		}
	}
	
}


//正向搜索
int indexOf(const char *sourceStr,const char *pStr,int beginPos)
{
	int firstIndex = -1;
	initT1Length(pStr);
	int sLength = strlen(sourceStr);
	int pLength = strlen(pStr);
	for (int i = beginPos,j=0;i < sLength;)
	{
		if(*(sourceStr + i) != *(pStr + j))
		{
			if(0 == j)
			{
				++ i;
				j = 0;//可省略
			}
			else if(1 == j)
			{
				j = 0;
			}
			else 
			{

				//i 不变,j 向前移动
				j = t1Length[j];
			}
			
			
		}
		else
		{
			++ j;
			++ i;
			if(pLength == j)
			{
				firstIndex = i - pLength;
				break;
			}
		}
	}
	return firstIndex;
}




/************************************************************************/
/* 
    初始化反向的 t1LengthArray
	< ---------------------
*/
/************************************************************************/
void initReverseT1Length(const char *pStr)
{
	int totalLength = strlen(pStr);
	const char firstChar = *(pStr + totalLength - 1);
	char lastChar;	//最后一个字符
	char beforeLastChar;	//倒数第二个字符
	int lastT1Length;	//上一次求的 t1 的长度
	t1LastLength[0] = t1LastLength[1] = 0;
	for (int i = 2;i <= totalLength;++ i)
	{
		lastT1Length = t1LastLength[i-1];	//前一个,所以是 i - 1
		if(0 == lastT1Length)	//前一个字母没有对应
		{
			t1LastLength[i] = (firstChar == *(pStr + totalLength - i));
		}
		else
		{
			lastChar = *(pStr + totalLength - i);
			beforeLastChar = *(pStr + totalLength - i + 1);
			while(0 != lastT1Length && *(pStr + totalLength - 1 - lastT1Length) != lastChar)	//对应字符的后面一个字符如果与最后一个字符相等则退出
			{
				lastT1Length = t1LastLength[lastT1Length];
			}
			if(0 != lastT1Length)	//因为 *(pStr + lastT1Length) == lastChar 跳出的循环
			{
				t1LastLength[i] = lastT1Length + 1;
			}
			else
			{
				t1LastLength[i] = (firstChar == lastChar);
			}
		}
	}
}

//从后向前搜索
int lastIndexOf(const char *sourceStr,const char *pStr,int beginPos)
{
	int firstIndex = -1;
	initReverseT1Length(pStr);
	int sLength = strlen(sourceStr);
	int pLength = strlen(pStr);
	for (int i = sLength - 1 -  beginPos,j = pLength - 1;i != -1;)
	{
		if(*(sourceStr + i) != *(pStr + j))
		{
			if(pLength - 1 == j)
			{
				-- i;
			}
			else if(pLength - 2 == j)
			{
				j = pLength - 1;
			}
			else 
			{

				//i 不变,j 向前移动
				j = pLength - 1 - t1Length[j];
			}


		}
		else
		{
			-- j;
			-- i;
			if(-1 == j)
			{
				firstIndex = i+ 1;
				break;
			}
		}
	}
	return firstIndex;
}

//字符串 str 正向的 t1LengthArray 应该于 str 的反向字符串的 reverseT1LengthArray
bool isT1LengthArrayEqual(char *pStr,int aLength)
{
	initT1Length(pStr);
	reverseStr(pStr);
	initReverseT1Length(pStr);
	reverseStr(pStr);
	for (int i = 0;i < aLength;++ i)
	{
		if(t1Length[i] != t1LastLength[i])
		{
			return false;
		}
	}
	return true;
}

//测试反向的 t1Length 是不是正确的
void testT1LengthRigth()
{
	char pStr[MAX_LENGTH];
	while(true)
	{
		randomStr(pStr,12);
		*(pStr + 12) = 0;
		printf("%s : ",pStr);
		printf(isT1LengthArrayEqual(pStr,13) ? "yes\r\n" : "no\r\n");
		Sleep(1500);
	}
}

int _tmain(int argc, _TCHAR* argv[])
{
    //testT1LengthRigth();
    
	const char *pStr = "hao";
	const char *sStr = "lizhihaoweihaoweiwei";
	
	printf("%s : first index of %s is %d\r\n",sStr,pStr,indexOf(sStr,pStr,0));
	printf("%s : last index of %s is %d\r\n",sStr,pStr,lastIndexOf(sStr,pStr,0));
	system("PAUSE");
	return 0;
}


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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值