KMP 超清晰的详解(附图)

KMP算法是一种用于字符串匹配的算法,由D.E.Knuth、J.H.Morris和V.R.Pratt提出。它避免了传统匹配方法在失配时从头开始的低效,通过构造kmp数组记录模式串的最长公共前后缀,使得匹配失败后能快速定位到下一个可能匹配的位置,减少了冗余计算。文章详细解释了KMP算法的工作原理,并给出了初始化kmp数组和匹配过程的代码示例。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

Upd on 2023.5.14:重述本篇文章部分内容,使得文章更加易懂。

前言

在我们今天的学习之前,常见的字符串算法除了 STL 之外就是哈希。但哈希有一定的错误概率,如果想要 100%100\%100% 正确,就要双哈希,非常麻烦。那么,有没有一些算法,保证正确性的同时还拥有优秀的复杂度呢

这就是今天的主角:KMP 算法。

本文默认字符串下标从 111 开始。

算法讲解

KMP 是由三位大佬:D.E.Knuth\mathcal{D.E.Knuth}D.E.KnuthJ.H.Morris\mathcal{J.H.Morris}J.H.MorrisV.R.Pratt\mathcal{V.R.Pratt}V.R.Pratt,取他们名字首字母,就是 K,M,PK,M,PK,M,P

原理

KMP 常用于解决单串匹配问题。即给定两个字符串 sssttt,问 tttsss 中的出现情况(出现次数或出现位置等)。

我们将 sss 称为文本串,ttt 称为模式串。

一般的解法是对于 sss 的每个位置都放一个 ttt 上去一位一位匹配。这种算法效率底下的原因是因为某些位置可能判断过很多次。

而 KMP 的优越性在于,每次匹配失败后,不会重头再来,而是根据之前匹配的“经验”跳到下一个可能可以成功匹配的地方,从而减少冗余信息的计算。

我们以下面的例子介绍:

现在文本串的第 555 位失配(匹配失败)了。KMP 会将模式串对齐至文本串的第 333 位。继续匹配,变成这样:

为什么他会跳到第 333 位?

与很多人理解的不同,它跳到第 333 位的原因是因为模式串本身的字符结构,而不是文本串。因为现在是第 555 位失配,证明前 444 位都已匹配成功。

而模式串的一二位与三四位是相等的,文本串的三四位又与模式串三四位是匹配的,所以直接挪到文本串第三位,可以保证一二位相等。

换言之,如果我们能知道 t1∼it_{1\sim i}t1i 的最后若干位与前面若干位相同,那么在 iii 失配之后就可以直接跳过这么多位,从而大大增加效率。

用公式表示,我们对每一个 iii,求一个 kmpikmp_ikmpi,使得 t1∼kmpit_{1\sim kmp_i}t1kmpiti−kmpi+1∼it_{i-kmp_i+1\sim i}tikmpi+1i 这两段是相同的,即前后缀相同的长度。

但是这个方法不够完美。比如在 mmmmmmmmmmmmmmmmh 中查找 mmmmmms,你会发现每一次都是到最后一位才失配,又要跳回第一位重新匹配,比较恶心人。

所以我们要求的是最长长度而不是任意长度。

那么,问题是,如何求 kmpkmpkmp 数组?

考虑使用一种类似递推的方式。对于一个位置 iii,如果 ti=tkmpi−1+1t_{i}=t_{kmp_{i-1}+1}ti=tkmpi1+1,那么我们发现一定有 kmpi=kmpi−1+1kmp_i=kmp_{i-1}+1kmpi=kmpi1+1,如图:

其实就是在 kmpi−1kmp_{i-1}kmpi1 的基础上加上了上图中两个蓝色的部分。

那如果不相同呢?我们还要往前跳,那又要跳到哪里呢?

不能随便跳,因为 ti=tkmpi−1+1t_{i}=t_{kmp_{i-1}+1}ti=tkmpi1+1 的前提是 ti−1=tkmpi−1t_{i-1}=t_{kmp_{i-1}}ti1=tkmpi1。所以我们考虑再次将这个递推式进化,变成:ti−1=tkmpi−1=tkmpkmpi−1t_{i-1}=t_{kmp_{i-1}}=t_{kmp_{kmp_{i-1}}}ti1=tkmpi1=tkmpkmpi1。那我们就可以重复上述的过程,不停的判断是否相等,如果不相等就继续嵌套。

如图,匹配失败后,红色框往它的 kmpkmpkmp 值走了。走完发现这回相等了,于是 kmpi=kmpkmpi−1+1kmp_i=kmp_{kmp_{i-1}}+1kmpi=kmpkmpi1+1,即蓝色部分。

这样的时间复杂度是对的,至于怎么分析,要用到均摊,作者比较菜,故省略。

那么边界是多少呢?显然 kmp0=0kmp_0=0kmp0=0。问题是 kmp1kmp_1kmp1 是多少?按定义来说,前后缀长度相同的长度应该是 111 呀!

但是这样就有问题了。比如有一个 iii,它的 kmpkmpkmp 跳着跳着来到了 111。此时应该比较的是 tit_ititkmp1+1=t2t_{kmp_1+1}=t_{2}tkmp1+1=t2。如果不相等,又跳回到 kmpkmp1=kmp1=1kmp_{kmp_1}=kmp_{1}=1kmpkmp1=kmp1=1,你就会发现它出不来了。

有人说,加个特判不行吗?不好意思,不行。你并不知道你是不是第一次进 kmp1kmp_1kmp1。如果你一到 111break 那将无法匹配第一位,从而导致 kmpikmp_ikmpi 全部变成 000

所以,为了方便,我们将 kmp1=0kmp_1=0kmp1=0,这样一到 000 我们就停手,可以保证能够正确地求出 kmpkmpkmp 数组。

void init(){
	kmp[0] = kmp[1] = 0;
	for(int i=2;i<=m;i++){
		int j = kmp[i - 1];
		while(j > 0 && t[i] != t[j + 1])
			j = kmp[j];
		if(t[i] == t[j + 1])
			kmp[i] = j + 1;
	}
}

那么,现在就是用 ttt 匹配 sss 了。其实这个过程也是相当相似的,只是我们把 tttttt 自己匹配换成了 tttsss 匹配,所以代码也很容易:

	int now = 0;//当前匹配到什么位置
	for(int i=1;i<=n;i++){
		while(now > 0 && s[i] != t[now + 1])
			now = kmp[now];
		if(s[i] == t[now + 1])
			++now;
		if(now == m)//匹配成功了
			printf("%d\n", i - m + 1);
	}

组合起来再输出 kmpkmpkmp 数组就可以过掉 P3375 啦!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值