[C++]洛谷:KMP字符串匹配 KMP算法详解

[原题]

给出两个字符串 s1​ 和s2​,若 s1​ 的区间 [l, r][l,r] 子串与 s2​ 完全相同,则称 s2​ 在 s1​ 中出现了,其出现位置为 l。
现在请你求出 s2​ 在 s1​ 中所有出现的位置。

定义一个字符串 s 的 border 为 s 的一个非 s 本身的子串 t,满足 t 既是 s 的前缀,又是 s 的后缀。
对于 s2​,你还需要求出对于其每个前缀 s′ 的最长 border t′ 的长度。

[输入格式]

第一行为一个字符串,即为 s1​。
第二行为一个字符串,即为 s2​。

[输出格式]

首先输出若干行,每行一个整数,按从小到大的顺序输出 s2​ 在 s1​ 中出现的位置。
最后一行输出 ∣s2​∣ 个整数,第 i 个整数表示 s2​ 的长度为 i 的前缀的最长 border 长度。

[输入样例]

ABABABC
ABA

[输出样例]

1
3
0 0 1 

[解题思路]

给定一个主串s1和一个模式字符串s2,要求找出s2在s1中完全相同的子串,并返回他们的位置,同时需要打印s2每个位置前缀子串最大公共前后缀。目前,我们并不能理解这个最大公共前后缀到底是什么,那么我们先将它放在一边,来讨论如何寻找s1中与s2完全相同的子串。

一般的解题思路是很好想到的——我们把s2绑在一个滑块上,不断滑动滑块直到上下完全一致则说明该位置的子串即是我们想要的答案。

不过转化成代码,其实际运行的过程则并没有我们想的那么高效。假设主串长度为m,模式串长度为n,计算机则要对s1的每个位置遍历s2来判断他们是否完全一致。也就是说,整个过程的时间复杂度接近于o(m*n)。因此,我们需要一个更高效的算法来实现这个过程,也就是我们接下来将要介绍的KMP算法


在使用之前,我们需要对KMP算法有一个初步的了解。

为什么要叫KMP算法?原因很简单——因为这种算法由克努特—莫里斯—普拉特三人共同提出,因此取名“KMP”算法。那么他到底是如何实现优化的呢?我们先来看几个例子。

首先,以刚才图为例,我们不难发现模式串“ABA”中,前后各有一个A,同时,如果我们想在s1中找到与s2完全一致的子串,那么这个子串的第一个字母也必然为A。因此,假如我们找到了第一个“ABA”所在的位置,那么我们是否可以直接将s2直接向右滑动两格,进行下一轮搜索呢?这个过程如下图所示:

同样的,假如s2中前后相匹配的部分不止一个字符,而是一串字符,我们也同样可以通过直接滑动s2到对应位置的方法去减少搜索量。

对于s2前后这两个完全匹配的部分,我们就称其为最大公共前后缀。值得注意的是,这个前后匹配并非是回文序的匹配,而是正序的完全匹配

接下来我们再看一种情况:

假设匹配过程中我们第一轮匹配并没能实现s1与s2的完全匹配,而是最终只匹配到了黄色箭头标记处,但是箭头位置之前的子串存在一个最大公共前后缀“AB”,根据上面的理论可以发现,我们仍然可以使s2直接从前一个“AB”直接滑动到后一个“AB”的位置而不造成遗漏。同时,如果将滑动后匹配串中前一个“AB”的位置对应至模板字符串上,得到的也一定是“AB”,所以我们可以直接将刚才的箭头指向字符C处,并从此处开始下一轮的校验。

可以看出,与暴力解法相比,这种算法通过前缀子串的最大公共前后缀实现了指针(即图示指向字符的黄色箭头)的滞留,因而每次比较不用从头开始遍历,从而实现了快速匹配的目的,在数据范围较大的情况下大大提高了程序的运行效率。这种算法的时间复杂度仅为o(m+n)


接下来就让我们来看一下如何获得每一个位置对应的最大公共前后缀。

这里,我们引入一个next[]数组,来存储每一个位置作为检索终点时下一轮检索对应的起点位置。首先让我们来手算一遍匹配串“ABACABAC”每个位置next[i]的值。习惯上kmp的字符串下标都默认从1开始存字符,下面我也将按这个规则存储字符串。

对于每个next[i],我们可以根据当前位置的前缀子串(不包含当前位置)求其最大公共前后缀。next[i]的值就是这个最大公共前后缀的长度+1。如对i=4的情况,它的前缀子串为“ABA”,其最大公共前后缀为“A”,因此next[4]的值即为1+1=2。

按照这个规则我们可以将剩下的位置逐步完善,最终可以得到如下的next数组:

另外,由于代码实现的需要,我们一般将next[1]的值设置为0。


下面我们尝试来用代码实现这个过程:

不难发现,对于next[i]的值,其最大值为next[i-1]+1。以求next[7]为例,上一个状态的最大公共前缀为“A”,分别位于i=1和i=5处,此时由于i=2和i=6位置处的两个字符相等,我们显然可以将最大公共前后缀扩展为“AB”。即当 str[i] == str[j] next[i + 1] = j + 1 其中j指向的就是next[i]的值

下面我们来看一下str[i]与str[j]不相等的情况。为方便演示,我会将无关数据隐藏,如下图。

对于j位置的前缀子串,同样拥有一个最大公共前后缀(在图中我用绿色框出)我们将j移到next[j]对应的部分。同样,由于红框中的部分是相同的,在后一个红框中也一定有对应的两个部分与前面的绿框部分相等,我们同样用绿色标出。这样我们得到了1、2、3、4四个完全相同的部分。而此时由于1和4是完全相同的,我们只要对更新后的str[i]和str[j]判断是否相等即可。若相等,则 next[i + 1] = j + 1

如果不相等呢?同样地,我们可以再次划分绿框部分,使j=next[j],重复上述操作。

不难发现,这个过程与递归类似。执行到最后,如果两个位置仍然不相同,那j一定会等于next[1],也就是0。


于是我们回到了刚刚的问题:为什么next[1]要赋值为0呢?

我们只需要搞明白有且只有两种情况下j==0:

(1)初始情况下j为0

(2)在计算next[i+1]时经过一系列“递归”操作后并没有找到合适的最大公共前后缀,这时next[i+1]应为1,则有j+1==1。此时j是指向next[1]的,那么next[1]必须为0才能使程序逻辑完整。


因此,根据上面的分析,我们可以写出如下的代码:

void InitNext(vector<int>& next)
{
	next[1] = 0;
	int i = 1, j = 0;
	while (i < ls2)
	{
		if (j == 0 || s2[i] == s2[j]) next[++i] = ++j;
		else j = next[j];
	}
}

有了next数组,我们该如何使用呢?

我们只需要分为以下几种情况进行讨论:

(1)当s1[i]==s2[j],说明两字符匹配,i和j同步自增至下一位进行比较。


(2)当s1[i]!=s2[j],说明该位置所得子串不是我们想要的子串,j回到next[j]处进行下一轮匹配(这个过程事实上相当于s2进行了向右的滑动)。


(3)当j来到了s2的最后一格处,并发现s1[i]==s2[j],说明该主串的子串与模式串完全相同,也就是找到了我们需要的答案。

相关代码如下:

for (int i = 0, j = 0; i < ls1 + 1; )
	{
		if (j == 0 || s1[i] == s2[j])
		{
			i++, j++;
			if (j == ls2 + 1)
			{
				ans.push_back(i - ls2);
				j = next[j];
			}
		}
		else
		{
			j = next[j];
		}
	}

最后,附上本题题解:

#include<iostream>
#include<vector>
#include<cstring>
using namespace std;

char s1[1000010], s2[1000010];
int ls1, ls2;

void InitNext(vector<int>& next)
{
	next[1] = 0;
	int i = 1, j = 0;
	while (i <= ls2)//由于题目中要求的最大公共前后缀是包含本格的,我们需要多计算一个next[ls2]的值
	{
		if (j == 0 || s2[i] == s2[j]) next[++i] = ++j;
		else j = next[j];
	}
}

int main()
{
	//数据读取及数组初始化
	scanf("%s%s", s1 + 1, s2 + 1);//读取时空出第一格
	ls1 = strlen(s1 + 1), ls2 = strlen(s2 + 1);
	vector<int> next(ls2 + 2);
	vector<int> ans;

	//计算next数组
	InitNext(next);

	//kmp算法
	for (int i = 0, j = 0; i < ls1 + 1; )
	{
		if (j == 0 || s1[i] == s2[j])
		{
			i++, j++;
			if (j == ls2 + 1)
			{
				ans.push_back(i - ls2);
				j = next[j];
			}
		}
		else
		{
			j = next[j];
		}
	}

	//输出匹配的位置
	for (int i = 0; i < ans.size(); i++)
	{
		cout << ans[i] << endl;
	}

	//输出next数组的值,注意错位输出,同时要减去1
	cout << next[2] - 1;
	for (int i = 3; i <= ls2 + 1; i++)
	{
		cout << ' ' << next[i] - 1;
	}
	return 0;
}
  • 8
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值