【数据结构】串及串的“朴素的模式匹配算法”与KMP“模式匹配算法”


前言

最近因提前放假笔者放假,摆了很大一段时间,心里负罪感十分沉重,因此决定洗心革面,重新做人。从本周开始恢复每周的周报,拒绝摆烂。

本周将从串开始引入,重点介绍有关串的KMP算法。


提示:以下是本篇文章正文内容,下面案例可供参考

一、串

1.串的定义

串是由零个或多个字符组成的有限序列,又名叫字符串

2.串的一些概念

在我们先前的学习中,我们对串实质上已经十分了解,本文对此不再赘述。我们重点介绍一些概念

①空格串

只包含空格的串,要注意它与空串的区别,空格串是由内容与长度的。而且可以不止一个空格。
这里注意一个串中也是可以包含多个空格的,例如:

I like CaiXukun

②空串

零个字符的串称为空串

③子串与主串

串中任意个数的连续字符组成的子序列称为该串的子串,相应地,包含子串的串称为主串。
子串在主串中的位置就是子串的第一个字符在主串中的符号
例如Xukun便是CaiXukun的子串

3.串的抽象数据类型

串的逻辑结构与线性表很相似,不同之处在于串针对的是字符集,也就是串中的元素都是字符。

因此,对于串中的基本操作和线性表是有很大差别的。线性表更关注的是单个元素的操作。 比如查找一个元素,插入或删除一个元素,但串中更多的是查找子串位置,得到指定位置子串、替换子串等操作。

在下笔者将会贴出一些与串有关的基本操作。在此不逐一分析,仅供参考。
在这里插入图片描述

二、例题

我们将以以下这一道例题展开我们重点展开我们对于与串有关的算法的学习。

找出字符串中第一个匹配项的下标
在这里插入图片描述

三、模式匹配算法

在我们介绍有关例题的两种方法,我们先来介绍有关串的模式匹配。

串的模式匹配就类似于我们在一篇文章中寻找一个单词(相当于一个大字符串中找所需要的小字符串)的定位问题。这种子串的定位操作通常称作串的模式匹配。

1.暴力匹配算法

暴力算法也叫BF算法
在此笔者简单介绍暴力匹配的基本思想:

就是将主串中的每一个字符都作为子串的开头,与要匹配的字符串进行匹配。
另外这之中的代码实现操作便是对主串大循环每个字符开头做T的长度的小循环。直到匹配成功或全部遍历完成为止。

下面笔者给出例题的暴力匹配算法的代码:

#include<stdio.h>
int BF(char *s,char *t)//s为主串,t为模式串 
{
	int i = 0,j = 0;
	while(s[i]!='\0'&&t[j]!='\0')//两个串都没有扫描完时循环 
	{
		if(s[i]==t[j])//当前两个字符相同 
		{
			i++;j++;//比较后续字符 
		}
		else//当前两个字符不同 
		{
			i=i-j+1;//扫描主串的i回退 
			j=0;//模式串回溯为0,从头匹配 
		}
	}
	if(t[j]=='\0')//如果j越界,表示模式串遍历完,t是s的字串 
		return i-j;//返回目标串中子串的起始位置 
	else
		return 0;
}
int main()
{
	char s[5]={'a','c','a','b','a'};
	char t[2]={'a','b'};
	printf("%d",BF(s,t));
}

2.关于暴力匹配的思考

暴力匹配也许是大多数人首先能想到的方法。但是他的时间复杂度过于高了,我们分析一下最好的情况时间复杂度可能是O(1),同时最坏的情况便是O(n+m)。

在此,我们要明白字符串暴力匹配的实质:
通过对主串中i值的不断回溯来实现

如何理解这句话呢?

因为暴力匹配中主串的每一个字母都需要与模板串的首字母匹配依次。那么对于主串的指针i会不断前进,当当前主串字母与模板串当前字母不符合时,那么让i值回溯当前扫描的字母的下一个字母。

例如:
主串:Cai Xuku Xukun (加上空格是为了方便阅读)
模板串:Xukun

我们使用暴力匹配扫描时,当扫描到主串的第四个字母X时,主串与模板串匹配成功,i 与 j 都会向后移动。
直到 i 扫描到第8个字母X时,我们发现后续的匹配失败了,那么i便会回溯到第5个字母u重新开始匹配。

那么这里就成为了我们优化算法的关键。我们有没有一种办法可以使 i 不去回溯,而是一直前进,但仍然实现我们目的的算法呢?
由此我们的KMP算法出现了。

3.KMP模式匹配算法

我们在后续将会学到许多算法,为避免大家混淆,笔者对KMP算法起了一个比较下流中文名——看毛片算法。
此名仅供参考记忆,如有雷同纯属偶然。

①KMP的由来

说到KMP,先说一下KMP这个名字是怎么来的,为什么叫做KMP呢。

因为是由这三位学者发明的:Knuth,Morris和Pratt,所以取了三位学者名字的首字母。所以叫做KMP

②KMP的用处

KMP的经典思想就是:当出现字符串不匹配时,可以记录一部分之前已经匹配的文本内容,利用这些信息避免从头再去做匹配。

我们以此来优化算法的时间复杂度。

③KMP算法的引入

为了更好地理解KMP算法,笔者在此先给出KMP算法的实现步骤。。当你看到以下内容时你可能会觉得摸不着头脑,这是很正常的。但是希望你能坚持看完,笔者在下文会一步步讲解每一步做法的原因。

假设现在文本串S匹配到 i 位置,模式串P匹配到 j 位置 如果j = -1,或者当前字符匹配成功(即S[i] == P[j]),都令i++,j++,继续匹配下一个字符; 如果j != -1,且当前字符匹配失败(即S[i] != P[j]),则令 i
不变,j = next[j]。此举意味着失配时,模式串P相对于文本串S向右移动了j - next [j] 位。
换言之,当匹配失败时,模式串向右移动的位数为:失配字符所在位置 - 失配字符对应的next 值(next 数组的求解会在下文的3.3.3节中详细阐述),即移动的实际位数为:j - next[j],且此值大于等于1。

细心的你在这里一定会发现在笔者引用的描述中出现了一个从来没见过的next数组。那么这个next数组就是我们实现KMP的关键。

在这里我们回忆一下我们用KMP算法是如何通过优化取代BF算法的:
通过避免出现不必要的回溯来优化算法的复杂度

在BF算法中,i 与 j 的值都会不断回溯,但是对于KMP算法,我们不对 i 的值进行回溯。那么出现变化的只有 j 了。(这里需要着重理解一下)

在KMP算法中,j 值的变化其实与主串(文本串)并无关系,j值的多少取决模板串中的结构是否由重复的问题。

④前缀表

在解释KMP算法中的next数组前,我们必须引入一个概念:前缀表

那么什么是前缀表:记录下标i之前(包括i)的字符串中,有多大长度的相同前缀后缀。

next数组就是一个前缀表(prefix table)。

前缀表有什么作用呢?

前缀表是用来回退的,它记录了模式串与主串(文本串)不匹配的时候,模式串应该从哪里开始重新匹配。

为了更好地解释前缀表,我们在这里举一个例子:

要在文本串:aabaabaafa 中查找是否出现过一个模式串:aabaaf。

这里我们一定要懂得文本串模式串的区别,我们把文本串看作一篇文章,那么模式串就是这篇文章中出现的单词。

我们可以发现文本串中第六个字符b 和 模式串的第六个字符f,不匹配了。如果暴力匹配,发现不匹配,此时就要从头匹配了。

但如果使用前缀表,就不会从头匹配,而是从上次已经匹配的内容开始匹配,找到了模式串中第三个字符b继续开始匹配。

⑤最长公共前后缀

我们在前缀表中定义中出现了相同前后缀这个名词,我们在以下对此展开讨论。
对于这个名词我们需要将其拆分成最长,前缀,后缀三个词来分别理解。

前缀是指不包含最后一个字符的所有以第一个字符开头的连续子串。
后缀是指不包含第一个字符的所有以最后一个字符结尾的连续子串。

例如:aabaa, aab可以说是这一个字符串的一个前缀,baa是这一个字符串的一个后缀。

有的博主将公共修改为相等,我认为这是大同小异的,只是每个人对于同一个词的理解不同。

例如:字符串a的最长公共前后缀为0。 字符串aa的最长公共前后缀为1。 字符串aaa的最长公共前后缀为2。 等等…

在这里笔者通过aaa的最长公共前后缀为2来展开讨论。我们在上文已经提到:
前缀是指不包含最后一个字符的所有以第一个字符开头的连续子串。
后缀是指不包含第一个字符的所有以最后一个字符结尾的连续子串。
那么aaa的最长后缀便是aa,最长前缀也是aa。

在这里我们便可以通过我们上文对于前缀表的定义:记录下标i之前(包括i)的字符串中,有多大长度的相同前缀后缀。 从而求得前缀表的值了。

⑥使用前缀表的原因

我们在上文知道了前缀表可以用来告诉我们当匹配失败时要回退的下一个位置。这是我们已经明白的结果。但是究其原因,我们为什么需要这样做呢,为什么这样做就可以避免 i 的回溯从而减少时间复杂度呢?在这里笔者将给出自己粗略的见解,如有错误,请不吝指出。

在这里,笔者通过给出求前缀表的代码加以解释

在看代码之前请笔者将给出代码中各个变量的含义:
j 指向前缀的末尾位置, 同时也代表i之前的串的最长公共前后缀长度
而 i 指向后缀的开头位置

void Getnext(char* P, int* next)
{
	int j = 0;
	next[0] = j;
	int len = strlen(P);
	for (int i = 1; i < len; ++i)
	{
		while (j > 0 && P[i] != P[j])
		{
			j = next[j - 1];//自我匹配递归,要好好理解
		}
		if (P[i] == P[j])
			j++;
		next[i] = j;
	}
}

在此处需要声明一点,那就是next数组是通过前缀表的变化而得到的,next数组有多种变化方法,但始终离不开前缀表,也就是说next数组的不同只是表达方式不一样,并不会影响前缀表,因此用前缀表原型来表达next数组也是成立的。

我们以下面这张图片来讲解笔者上述代码中注释的自我匹配递归的意思,这里是笔者认为的比较难以理解的地方。
在这里插入图片描述

一、我们已知此时 i 指针已经移动到模式串中的 f 的位置, j 移动到了模式串中的 b 的位置。
(读者可自行根据上述代码推导,笔者这里重点在于帮助读者理解前缀表是如何作用的) 二、那么根据我们的代码我们的 f 与 b
进行匹配,发现了匹配失败, 那么通过代码 j = next[j-1], 我们知道 j 需要回退到下标为1的a的位置,
此时下标为1的a的最长公共前缀和为1,那么 a 再次与 f 重新匹配, 依然匹配失败,那么继续回退, 直至 j = 0。

另外,许多博主在此讲述的方法是通过递归理解,笔者在此给出一种用对称性理解的思路,之所以可以使用这种思路是因为next数组中的前缀与后缀的长度相同。

我们同样使用 f 进行举例,此时如果 f 与 b 配对成功, 那么 f 位置的最长公共前后缀是3,但很显然是失败的。
同时我们需要记住一点那就是前缀的长度与后缀相同那么我们就需要退而求其次,我们需要去探究当最长公共前后缀是2时是否成立(在这里其实就是45位置的af又与01位置的aa匹配,只不过因为我们通过前面的过程已经完成了0下标位置的a与4位置的a的匹配,实际上只需要对1位置的a与5位置的f进行匹配)
那么 j 指针就移动到了下标为1的 a 的位置与 f 进行对比。我们如此逐步缩短最长公共前后缀的长度,来探究当前位置指针 i 的最长公共前后缀是多少。

在理解这一步骤时笔者也参考了多方的资料,笔者的讲解能力也是依托答辩,在如下粘贴两篇大牛的文章,供读者学习与参考(当然如若能够理解笔者的稀碎文笔笔者不尽感激)
从头到尾彻底理解KMP(2014年8月22日版)
代码随想录kmp 28. 实现 strStr()

⑦前缀表与next数组

我们在前文用了大量的篇章描述前缀表,就是为了引出此刻的next数组。若是您对上文有仔细地阅读,会发现其实在上文我们已经使用了next数组。

next数组就可以是前缀表,但是很多实现都是把前缀表统一减一(右移一位,初始位置为-1)之后作为next数组。
其实这并不涉及到KMP的原理,而是具体实现,next数组既可以就是前缀表,也可以是前缀表统一减一(右移一位,初始位置为-1)。

注意:这里的-1与右移的操作,其实都是为了方便代码的实现而设计出来的,如果有兴趣的读者可以自行了解,笔者在此处仅讲述不通过变化直接应用前缀表的next数组的用法。

⑧构造next数组

在我们构造next数组之前,笔者在此给出next数组的伪代码,依次来展开我们对于next数组代码的实现

void Getnext(char* P, int* next)
{
	初始化;
	处理前后缀不相同的情况;
	前后缀相同;
	更新;
}

声明代码中的变量: j 指向前缀的末尾位置, 同时也代表i之前的串的最长公共前后缀长度 而 i 指向后缀的开头位置

1、初始化:

int j = 0;//因为j指向前缀的末尾位置, 前缀最开始在0的位置
next[0] = 0;//前缀表刚开始时只有一个字母,既无前缀也无后缀

因为j指向前缀的末尾位置, 前缀最开始在0的位置
前缀表刚开始时只有一个字母,既无前缀也无后缀

2、处理前后缀不相同的情况:

for(int i =1; i<next_len; ++i)

i 作为后缀的开头从1下标的位置开始于j进行匹配

如果匹配失败

while (j > 0 && P[i] != P[j])
		{
			j = next[j - 1];//自我匹配递归,要好好理解
		}

此时这种情况其实就是前后缀不相同,那么就需要向前回退

3、处理前后缀相同的情况

if (P[i] == P[j])
			j++;
		next[i] = j;

匹配成功,那么 i 与 j 都会加1。

⑨使用next数组来做匹配

在前文我们讲到,KMP与BF算法之间的差别,是KMP不需要对 i 进行回溯。那么我们沿着这个思路,展开对KMP匹配的具体过程的讲解

一、定义两个下标 j 指向模式串起始位置,i 指向文本串起始位置。
那么j初始值依然为0,为什么呢? 依然因为next数组里记录的起始位置为0。
i就从0开始,遍历文本串,代码如下:

for (int i = 0; i < S_len; ++i)

二、 接下来就是 s[i] 与 p[j] (因为j从0开始的) 进行比较。

如果 s[i] 与 p[j] 不相同,j就要从next数组里寻找下一个匹配的位置。

代码如下:

while (j > 0 && S[i] != P[j])
		{
			j = next[j - 1];
		}

三、如果相同,i 与 j 都会向后移动1:

	if (S[i] == P[j])
		{
			j++;
		}

四、当 j 的长度等于模式串长度,说明匹配成功结束:

if (j == P_len)
		{
			printf("%d\n", (i - len2 + 1));//输出成功的文本串中的首位置
			return 0;
		}

⑩KMP的时间复杂度

时间复杂度分析
其中n为文本串长度,m为模式串长度,因为在匹配的过程中,根据前缀表不断调整匹配的位置,可以看出匹配的过程是O(n),之前还要单独生成next数组,时间复杂度是O(m)。所以整个KMP算法的时间复杂度是O(n+m)的。

暴力的解法显而易见是O(n × m),所以KMP在字符串匹配中极大地提高了搜索的效率。

⑩①例题完整KMP算法代码

void Getnext(char* P, int* next)
{
	int j = 0;
	next[0] = j;
	int len = strlen(P);
	for (int i = 1; i < len; ++i)
	{
		while (j > 0 && P[i] != P[j])
		{
			j = next[j - 1];//自我匹配递归,要好好理解
		}
		if (P[i] == P[j])
			j++;
		next[i] = j;
	}
}


int main()
{
	char S[100];
	char P[100];
	gets_s(S);
	gets_s(P);
	int len1 = strlen(S);
	int len2 = strlen(P);
	int* next = (int*)malloc(sizeof(int) * len2);
	Getnext(P, next);
	if (len2 == 0) {
		return 0;
	}
	int j = 0;
	for (int i = 0; i < len1; ++i)
	{
		while (j > 0 && S[i] != P[j])
		{
			j = next[j - 1];
		}
		if (S[i] == P[j])
		{
			j++;
		}
		if (j == len2)
		{
			printf("%d\n", (i - len2 + 1));
			return 0;
		}
	}
	printf("-1");

	return 0;
}

总结

总结1

KMP算法还有许多衍生出来的其它算法,同时此时的KMP算法并非最优,我们还可以对KMP算法进行进一步的优化。若是笔者以后有机会,会继续对此方面进行深挖。

总结2

这几天摆的笔者怀疑人生,在此周以后开始恢复写周报的计划

总结3

在此笔者放出在笔者学习KMP算法时点醒笔者的几句话供读者参考

1、

综上,KMP的next 数组相当于告诉我们:当模式串中的某个字符跟文本串中的某个字符匹配失配时,模式串下一步应该跳到哪个位置。

2、

next 数组各值的含义:代表当前字符之前的字符串中,有多大长度的相同前缀后缀。例如如果next [j] = k,代表j
之前的字符串中有最大长度为k 的相同前缀后缀。

3、
若是你使用的是整体向右移的方法构建next数组,希望以下这句话能对你有些许帮助:

把next 数组跟之前求得的最大长度表对比后,不难发现,next 数组相当于“最大长度值”
整体向右移动一位,然后初始值赋为-1。意识到了这一点, 你会惊呼原来next
数组的求解竟然如此简单:就是找最大对称长度的前缀后缀,然后整体右移一位,初值赋为-1
(当然,你也可以直接计算某个字符对应的next值,就是看这个字符之前的字符串中有多大长度的相同前缀后缀)。

  • 3
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值