KMP算法

前言

字符串匹配问题:

已知一个主串 S 和模式串 P ,需要在主串中查找模式串出现的位置。

KMP算法主要是用来解决字符串匹配的问题,其内容比较抽象,在学习之前强烈建议查看一些资料,下面是我个人觉得比较好的文章和视频:

阮一峰老师的KMP算法 (前后缀讲解比较清楚)

B站up主 “正月点灯笼” 的KMP算法讲解
( 有两集)

个人觉得全网最强的KMP算法讲解:
比特大博哥的KMP算法教学

在了解KMP算法之前,我们需要先来了解一下BF算法。


BF算法

BF算法,即暴力(Brute Force)算法,是普通的模式匹配算法,BF算法的思想就是将目标串S的第一个字符与模式串P的第一个字符进行匹配,若相等,则继续比较S的第二个字符和 T的第二个字符;若不相等,则比较S的第二个字符和P的第一个字符,依次比较下去,直到得出最后的匹配结果。BF算法是一种蛮力算法。 —— 来自百度百科

暴力算法相对来说还是比较容易想到的,基本上看到题目的第一反应就是想到暴力算法。

所以我们直接来看代码

字符串S[n], 匹配串P[m]

如果使用朴素的解法,就是对 S 中的每一个字符都开始一轮匹配,每一轮比较都是从前往后匹配,如果有其中一个字符失败了,S 需要回退到下一个字符,开始新的一轮的匹配。

for (int i = 0; i < n; i++)
{
    bool flag = true;
    for (int j = 0; j < m; j++)
    {
        if (S[i + j] != P[j])
        {
            flag = false;
            break;
        }
    }
    check(flag)  // 如果成立就是全部匹配上了
}

但是这样的时间复杂度就是典型的 O(mn) ,效率有点低。

所以从中观察一点规律,可以有效地提高效率。


KMP算法

KMP算法是一种改进的字符串匹配算法,由D.E.Knuth,J.H.Morris和V.R.Pratt提出的,因此人们称它为克努特—莫里斯—普拉特操作(简称KMP算法)。KMP算法的核心是利用匹配失败后的信息,尽量减少模式串与主串的匹配次数以达到快速匹配的目的。具体实现就是通过一个next()函数实现,函数本身包含了模式串的局部匹配信息。KMP算法的时间复杂度
O(m+n) 。 —— 来自百度百科

KMP算法与 BF算法唯一不一样的地方就是,主串的 i 并不会回退,并且模式串的 j 也不一定会移动到 0 号位置。


为什么主串 i 不会回退

我们首先来看一下主串的 i 为什么不会回退:
在这里插入图片描述

S[i]P[j] 不同时,也就是在 2 号位置的时候,S 就算回退到 1 位置,也是没必要的。因为S[1] 位置的字符 bP[0] 位置的字符 a,也不一样。

就算S[1] 位置的字符和 P[0] 位置的字符一样,那 j 回退就行了。这样就可以减少主串和模式串的匹配次数了。


j 回退的位置

我们先通过画图来分析一下:S[] = "abcababcabc"; P[] = "abcabc";

在这里插入图片描述

S[i] != P[j] 时, 那么 ij 前面的就一定有一部分相同的,不然这 ij 就不会走到这个位置。通过画图我们可以发现 j 如果回退到 2号 位置就可以正好继续匹配,因为有一下的规律:

在这里插入图片描述

两个串中 红黄黑三个部分的内容都是相等的,所以当 j 回退到 2号位置的时候可以继续往后面匹配

那么我们怎么知道 j 需要回退到哪个位置呢?

这时我们就需要用到模式串的 next() 函数了,这里说的next()函数我们在实际的实现中是实现成数组的形式 next[]来表示的,里面存放的值就是不匹配时, j 需要回退到的位置。


next[] 数组的规则

KMP的核心就是利用 next[] 数组中的局部匹配信息,在求 next[] 数组之前,我们先来了解两个知识点:

一、前缀和后缀

前缀: 指除了最后一个字符以外,一个字符串的全部头部组合

后缀: 指除了第一个字符以外,一个字符串的全部尾部组合

我们以 “ABCDABD” 为例:

- "A"的前缀和后缀都为空集

- "AB"的前缀为[A],后缀为[B]

- "ABC"的前缀为[A, AB],后缀为[BC, C]

- "ABCD"的前缀为[A, AB, ABC],后缀为[BCD, CD, D]

- "ABCDA"的前缀为[A, AB, ABC, ABCD],后缀为[BCDA, CDA, DA, A]

- "ABCDAB"的前缀为[A, AB, ABC, ABCD, ABCDA],后缀为[BCDAB, CDAB, DAB, AB, B]

- "ABCDABD"的前缀为[A, AB, ABC, ABCD, ABCDA, ABCDAB],后缀为[BCDABD, CDABD, DABD, ABD, BD, D]

二、真子串:

除串本身以外的子串都称为真子串


前面提到的局部匹配信息就是:"前缀"和"后缀"的最长的共有元素的长度

重点细节:

next[] 数组中的下标对应着模式串中的下标,里面存放的数据也就是模式串的 真子串的"前缀"和"后缀"的最长的共有元素的长度,所以会比下标多出来多 1。而我们在处理 next[i] 的时候实际上是在找 P[0] ~ P[i - 1] 的局部匹配信息,所以 next[] 数组会和真实的局部匹配信息错开一个位。

next[] 数组的规则:

1、找到真子串(不包含本身)的前缀和后缀相等的长度。

2、不管什么数据 next[0] = -1;next[1] = 0;

因为这两个位置的字符串本来就是没有真子串的, next[0] = -1 是为了后面能够同一处理,方便求值。


手动推演 next[] 数组(练习)

那么就可以通过上面的规则和知识点来求不用字符串的 next[] 数组了,先来手动推导一下:

练习一:“ababcabcdabcde”

 a   b   a   b   c   a   b   c   d   a   b   c   d   e
-1   0   0   1   2   0   1   2   0   0   1   2   0   0

练习二:“abcabcabcabcdabcde”

 a  b  c  a  b  c  a  b  c  a  b  c  d  a  b  c  d  e
-1  0  0  0  1  2  3  4  5  6  7  8  9  0  1  2  3  0

代码实现 next[] 数组

因为 next[] 数组中存放的是真子串的前后缀相等长度,所以这里会有一个错位。

将问题转换一下,我们设已知 next[i] = k,因为next[0] 和 next[1] 都是已知的, 那么求 next[] 数组就可以转换成通过 next[i] 来求 next[i + 1]

next[i + 1] 主要有两种情况 :

一、 next[i] == next[k]

二、 next[i] != next[k]

我们可以分别来分析一下如何处理。


next[i] == next[k] 的情况

我们可以先推导一下:

在next[i] = k的前提下:
就会有(字符串内容): p[0]~p[k - 1] = p[x]~p[i - 1] 

也就是(长度): k - 1 - 0 = i - 1 - x
所以:
k = i - x
x = i - k

即:
p[0]~p[k - 1] = p[i - k]~p[i - 1] 

所以,如果p[i] == p[k] 
即: 
p[0]~p[k] = p[i - k]~p[i]

得:
next[i + 1] = k + 1

所以当 next[i] == next[k] 时, next[i + 1] = k + 1

看一下实例:
在这里插入图片描述


next[i] != next[k] 的情况

我们先来看一下实例

在这里插入图片描述
next[i] != next[k] 时,我们可以将问题转换为寻找 next[i] = next[k]

因为 next[] 数组存放的是本身的局部匹配信息,所以我们可以让 k 不断回退到 next[k] 的位置,就可以接着进行下一步的匹配,直到找到 next[i] = next[k] 或者 k = -1(回退到 0, 退无可退了)的情况。

在这里插入图片描述

所以 p[i] !=  p[k] 时,next[i + 1] = k_new + 1,

k_new 满足: p[k_new] = p[i];

k_new 的更新条件: k_new = next[k];

代码

// 获取next数组
void GetNext(char p[], int next[])
{
	next[0] = -1;
	next[1] = 0;
	
	// k = next[i - 1],因为k表示的是长度,所以需要比较的刚好是 p[k]
	for (int i = 2, k = 0; i < strlen(p); i++)
	{
		// k == -1 是退到0了,不能再退了
		// 因为存在错位,比较也要错一位,让 p[k] 和 p[i - 1] 比较
		while (k != -1 && p[k] != p[i - 1]) k = next[k];
		next[i] = ++k;
	}
}

// 字符串匹配
int KMP(char s[], char p[], int next[])
{
	if (s == nullptr || p == nullptr)
		return -1;

	int sLen = strlen(s);
	int pLen = strlen(p);

	if (sLen == 0 || pLen == 0)
		return -1;

	int i = 0, j = 0;
	while (i < sLen && j < pLen)	// 要么走到主串结束,要么匹配成功
	{
		if (j == -1 || s[i] == p[j])	// 没有公共的前后缀真子串,或者匹配成功往后走
		{
			i++;
			j++;
		}
		else
		{
			j = next[j];	// j 回退
		}
	}

	if (j == pLen)	// 匹配成功
	{
		return i - j;
	}

	return -1;
}

完结散花🌈🌈🌈

在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

_featherbrain

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值