数据结构——串与KMP算法


一、串

1.串的定义

串(String)是由零个或多个字符组成的有限序列。一般记为:
S = " a   1   a   2   . . . a   n   " ( n > = 0 ) S = "a~1~a~2~...a~n~"(n>=0) S="a 1 a 2 ...a n "(n>=0)
当n=0时的串称为空串
串中任意多个连续的字符组成的子序列称为该串的子串,包含子串的串称为主串
某个字符在串中的序号称为该字符在串中的位置。子串在主串中的位置以子串的第1个字符在主串中的位置来表示。当两个串的长度相等且每个对应位置的字符都相等时,称这两个串是相等的。例如:

A=‘ChinaBeijing’,B=‘Beijing’,C=‘China’

它们的长度分别为13、7和5。B和C是A的子串,B在A中的位置是7,C在A中的位置是1。
需要注意的是,由一个或多个空格(空格是特殊字符)组成的串称为空格串(注意,空格串不是空串),其长度为串中空格字符的个数
串的逻辑结构和线性表极为相似,区别仅在于串的数据对象限定为字符集。在基本操作上,串和线性表有很大差别。线性表的基本操作主要以单个元素作为操作对象,如查找、插入或删除某个元素等;而串的基本操作通常以子串作为操作对象,如查找、插入或删除一个子串等。

2.串的存储结构

1)定长顺序存储表示

typedef struct {
	char ch[Maxsize];
	int length;
}SString;

串的实际长度只能小于或等于Maxsize,超过预定义长度的串值会被舍去,称为截断
串长有两种表示方法:一是如上述定义描述的那样,用一个额外的变量length来存放串的长度;二是在串值后面加一个不计入串长的结束标记字符“\0”,此时的串长为隐含值。

2)堆分配存储表示

typedef struct {
	char *ch;
	int length;
}HString;

在C语言中,存在一个称之为“堆”的自由存储区,并用malloc()和free()函数来完成动态存储管理。
利用malloc()为每个新产生的串分配一块实际串长所需的存储空间,若分配成功,则返回一个指向起始地址的指针,作为串的基地址,这个串由ch指针来指示;若分配失败,则返回NULL。已分配的空间可用free()释放掉。

3)块链存储表示

类似于线性表的链式存储结构,也可采用链表方式存储串值。由于串的特殊性(每个元素只有一个字符),在具体实现时,每个结点既可以存放一个字符,也可以存放多个字符。每个结点称为块,整个链表称为块链结构。

typedef struct LString{
	char ch[Size];
	struct LString *next;
}LString,*LSlist;

二、串的模式匹配

子串的定位操作通常被称为串的模式匹配,它求的是子串(常称模式串,后续都称模式串)在主串中的位置。例如:
已知主串S=‘abababcababab’,模式串T=‘ababc’
匹配T是否存在于S,若存在则给出T在S的位置。

1.暴力匹配算法

int Index(SString S, SString T)	//暴力匹配算法
{
	int i = 1, j = 1;	//定义“指针”,i指向主串,j指向模式串
	while (i <= S.length && j <= T.length)	//满足指针i,j都未超过字符串长度
	{
		if (S.ch[i] == T.ch[j])	//若字符相等
		{
			++i;
			++j;	//两指针同时后移一位
		}
		else	//若字符不相等
		{
			i = i - j + 2;	//指针i回退至“本次主串中参与匹配的子串的第二个字符”
			j = 1;	//指针j回退至模式串的第一个字符
		}
	}
	if (j > T.length)	//匹配通过
		return i - T.length;	//返回对应子串的位置
	else	//匹配不通过
		return 0;	//返回0
}

示意图:
初始
匹配通过,指针后移
后移
… …
至5
匹配失败,指针i回退至i-j+2的位置,即本次主串中参与匹配的子串(‘ababa’)的第二个字符(‘b’),指针j回退至1
回退1
再次匹配失败,指针i回退至i-j+2的位置,即本次主串中参与匹配的子串(‘babab’)的第二个字符(‘a’),指针j回退至1
回退2
… …
匹配完成
匹配通过,指针后移

结束
j > T.length,匹配成功,返回i-T.length,即3
由上可以看出,若主串长m,模式串长n,对于暴力匹配算法,其时间复杂度为O(m*n)

2.KMP算法

暴力匹配算法中,我们会发现这样一个问题,倘若主串为‘0000000000000000000000000000000000000000000001’,而子串为‘0000001’时,直到匹配结束,指针i需要回溯39次,每次回溯都要比较7次,总比较次数达到280次。显然,指针i频繁且不必要的回溯,严重拖慢了算法的执行速度,那么,是否可以使指针i无需回溯,仅移动指针j,而能正常进行匹配的方法呢?
答案自然是肯定的。

1)模式串对齐

回溯

首先,我们知道,当字符匹配通过时,指针i和指针j都会后移,并继续匹配下一个字符,那么,根据上图,我们就可以说,当j >= 2时,主串当前参与匹配的子串(‘ababa’)中已匹配通过的子串(‘abab’)与模式串中指针j(不含当前指针j指向的字符)之前的子串(‘abab’)一定相等。

既然相等,那么指针j该如何移动呢?
由上图我们可以发现,子串中‘abab’含有重复结构,移动指针j进行模式串的对齐时仅需移动一个‘ab’即可:
图
由此我们可以看出,指针j的移动(模式串的对齐)与模式串的结构有关,与主串无关
那么,模式串的对齐与其结构有何关系?,在这里我们先弄清几个概念。

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

前缀除最后一个字符以外,字符串的所有头部子串
例如‘ababa’,其前缀为:

a,ab,aba,abab

后缀除第一个字符以外,字符串的所有尾部子串
例如‘ababa’,其后缀为:

a,ba,aba,baba

部分匹配值(PM)字符串的前缀和后缀的最长相等前后缀的长度
例如‘ababa’,其前缀{a,ab,aba,abab}∩后缀{a,ba,aba,baba}={a,aba},最长相等前后缀是aba,长度为3,其部分匹配值为3。

那么它们有什么用呢?

我们先针对字符串‘ababc’求出其部分匹配值表,即分别求出‘a’,‘ab’,‘aba’,‘abab’,‘ababc’的部分匹配值(分别为0,0,1,2,0),得出下表:
部分匹配值表

然后我们用该PM表来进行字符串的匹配:
图
同样使用上面的例子,如上图,当匹配至模式串的最后一个字符时与主串的字符不相等,此时已匹配了4个字符,成功匹配的字符串为‘abab’,对照PM表,我们发现‘abab’的PM值为2,那么我们将指针j向左移已匹配的字符数(4)- 已匹配的字符串对应PM值(2) = 2个字符,即 j = 5 - 2 = 3:
图
如此,我们便明白,指针j的移动位数 = 已匹配的字符数 - 已匹配的字符串对应PM值

PM值的本质就是对模式串结构的分析(换句话说就是根据模式串中的重复片段,算出第一个片段尾在字符串中的位置,然后将指针j指向该片段尾的下一个字符)

3)KMP算法的分析

<1>next数组的产生

已知移动位数 = 已匹配的字符数 - 已匹配的字符串对应PM值与模式串中指针j的值,
可以写成Move = (j - 1) - PM[j-1]
对该式可做进一步改进:
首先,观察‘ababc’的PM表,我们发现:
在这里插入图片描述
1)匹配失败时,需要看前一项的PM值
2)当匹配至模式串中的最后一个字符时,使用的是‘abab’的PM值,而‘ababc’对应的PM值,我们是用不到的,可以舍去;
3)同样,当匹配至模式串中的第一个字符时,并无可参照的PM值。
由此,我们可以将PM项后移,为了区分第一个字符,其PM值用-1表示
next

这样,原式就变成了Move = (j - 1) - next[ j ]
移动后指针j的值= j - Move = j - ((j - 1) - next[ j ]) = next[ j ] + 1

为了计算方便,我们可以令next数组中的值+1:
再改进
此时移动后指针j的值 = next[ j ],即j = next[ j ]

<2>next数组的求取

既然知道了模式串指针j的变化公式,那么我们该如何求取next数组呢?
首先,倘若模式串中仅有一个字符‘a’时,其next值:
1
注:上面我们在移动PM项前,PM值为 以所对应的字符为末尾,第一个字符为首组成的字符串 的最长相等前后缀的长度;而移动PM项后,我们所要找的 字符串的末尾字符 应为 对应PM值的上一个字符。
那么,如果该模式串存在第二项,由于单独一个字符‘a’没有最长相等前后缀,因此其PM值为0,next值在改进后+1,因此,第二项的next值为1,注:由于上面进行了改进,求出来的PM值要+1。
如图:
2

倘若存在第三项,且第二项字符为‘b’,则可算出第三项的next为1:
3
类推直至下图:
在这里插入图片描述

如图,由next数组及模式串结构我们发现,模式串中存在重复子串‘ab’在这里插入图片描述

如果存在第七项,且第六项字符为‘a’时:
在这里插入图片描述

我们发现,第六项字符为‘a’,恰与第三项字符相等,使得在原先重复子串‘ab’的基础上构成新的重复子串‘aba’,其next值也在其基础上+1。由上面我们得出的j=next[j](指针j的回退本质上是寻找上一个重复结构的下一个字符),可以将其表示为:

if(s[next[6]] == s[6])	//假设模式串名为s
	next[6 + 1] = next[6] + 1

注:为方便表示,数据从下标1开始存储,因此第六项的next值表示为next[6],字符表示为s[6]
如此类比推理,倘若存在第八项字符,且第七项字符为‘a’,因为‘a’(即s[7])与s[next[7]] (对应第四个字符‘a’)相等,则next[8] = 4(next[7]) + 1 =5。
那么,如果s[7]与s[next[7]]不相等,那么next该如何计算?
求next函数值的问题也可以视为一个模式匹配的问题,若s[7]与s[next[7]]不匹配,,则寻找下一个s[next[next[7]]]与s[7]匹配,例如,假设第七项字符为‘b’,‘b’(s[7])与‘a’(s[next[7]])不匹配,继续与‘b’(s[next[next[7]]])匹配,成功,则next[8] = 2(next[next[7]]) + 1 =3;假设第七项字符为‘c’,‘c’(s[7])与‘a’(s[next[7]])不匹配,继续与‘b’(s[next[next[7]]])匹配,失败,则继续与‘a’(s[next[next[next[7]]]])匹配。失败,则next[8] = 1。(无最长前后缀)
由此可以得出next公式:
在这里插入图片描述

<3>代码实现
void Next(SString T, int next[])	//next数组求取
{
	int i = 1, j = 0;	//i:模式串指针,j:next值
	next[1] = 0;	//首字符next值设为0
	while (i < T.length)	//遍历模式串
	{
		if (j == 0 || T.ch[i] == T.ch[j])	//是否满足next值为0或对应字符匹配通过
		{
			++i;	//若满足,指针i+1
			++j;
			next[i] = j;	//next值+1,即next[j+1] = next[j] + 1
		}
		else
			j = next[j];	//若不满足,跳至next[j]位置重新匹配
	}
}

int Index_KMP(SString S,SString T, int next[])	//KMP算法
{
	int i = 1,j = 1;	//定义“指针”,i指向主串,j指向模式串
	while (i < S.length && j <= T.length)	//满足指针i,j都未超过字符串长度
	{
		if (j == 0 || S.ch[i] == T.ch[j])	////是否满足next值为0或对应字符匹配通过
		{
			++i;	//若满足,指针后移,继续匹配后续字符
			++j;
		}
		else
			j = next[j];	//若不满足,模式串指针回退至目标位置
	}
	if (j > T.length)	//匹配成功
		return i - T.length;	//返回主串中的对应位置
	return 0;	//匹配失败
}
<4>KMP匹配过程中比较次数的分析

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

4)KMP算法的再优化

<1>nextval数组

前面所定义的next数组在某些情况下仍存在缺陷,例如模式串s =‘aaaab’与主串p=‘aaabaaaab’进行匹配:

主串paaabaaaab
模式串saaaab
j12345
next[j]01234
nextval[j]00004

如上图,当j = 4,i = 4时,主串与模式串匹配不通过,若用next数组,主串中p[4]仍需与s[3]、s[2]、s[1]进行比较,但s[4]=s[3]=s[2]=s[1]=‘a’,因此这些比较是无意义的,为避免这种情况,可以再引入一个nextval数组来记录模式串中连续相等的字符数。

<2>nextval数组的实现
void Nextval(SString T, int nextval[])	//nextval数组求取
{
	int i = 1, j = 0;	//i:模式串指针,j:nextval值
	nextval[1] = 0;	//nextval第一个值设为0
	while (i < T.length)	//遍历模式串
	{
		if (j == 0 || T.ch[i] == T.ch[j])	//是否满足nextval值为0或对应字符匹配通过
		{
			++i;	//若满足,指针后移,继续比较下一个字符
			++j;	//同时nextval值+1
			if (T.ch[i] != T.ch[j])	//若下一个字符匹配未通过
				nextval[i] = j;	//将nextval值存储至对应位置
			else
				nextval[i] = nextval[j];	//否则将nextval指向的位置的nextval存储至对应位置
		}
		else
			j = nextval[j];	//回退nextval
	}
}
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值