KMP算法详解

文章目录

  • KMP算法
    • [1. 算法思想](#1. 算法思想)
    • [2. 图解](#2. 图解)
    • [3. KMP的精髓------next数组](#3. KMP的精髓——next数组)
    • [4. 求next数组的练习](#4. 求next数组的练习)
    • [5. 程序中如何计算next数组](#5. 程序中如何计算next数组)
    • [6. 代码实现](#6. 代码实现)
    • [7. next数组的优化](#7. next数组的优化)
    • [8. 源码](#8. 源码)

上一篇文章我们学习了字符串匹配算法中的BF算法,BF算法是一种暴力的匹配算法,思想很简单,但是效率并不是特别可观,因此这篇文章我们再来学习一种比较高效的字符串匹配算法------KMP算法

KMP算法

1. 算法思想

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

单凭这段话,大家肯定不能理解这个算法

2. 图解

那下面我们还是通过一个具体的例子来给大家详细的讲解一下KMP算法:

KMP算法呢可以认为是对BF算法(所以学这篇文章之前建议大家先看一下我的上一篇讲解BF算法的文章)的一个优化,它和BF的算法的区别在于:

KMP 和 BF 唯一不一样的地方在,主串的 i 不会回退,并且子串的 j 也不会每次都回退到 0 号位置

🆗,我们来看一下具体的例子:

首先举例:为什么主串的i不回退?


大家看这个例子,那最开始的话还是ij都从0开始一对一对进行比较嘛,目前是到下标2这个位置匹配失败了。
那按照BF算法的逻辑,此时i就应该回退到b字符位置,然后重新和子串一个个进行匹配。
但是我们发现让i回退到b字符其实是没有用的,因为b和子串的第一个字符a直接就不相等。
所以KMP算法中i不再回退

那下面我们再来讨论一下j回退的位置:

上面说了j不会像BF算法中那样每次都回退到起始位置,那它应该回退到哪呢?

大家来看这个例子:
现在到下标5这个位置匹配失败,此时ij都在这个位置。
那此时i不回退,这是确定的,那大家看j应该回退到哪里比较合适呢?
🆗,那对于这里这个例子的话,我们其实很容易发现让j回退到下标2的位置是合适的

为什么呢?
大家看,原本ij都在下标5的位置,那么i的前面有一个子串ab,j的前面也有一个子串ab,它们两个必然是相等的,因为ij指向的两个字符只有相等的时候才会同步往后走嘛。
那么同时呢,我们发现子串的前面也有一个ab(即前两个字符对应的ab),那么它和主串中i前面的ab也是相等。
那么此时我们让j回退到第一个ab的后面那个位置(即下标2的位置,而ab的长度就是2)就很合适啊。

当然呢,大家可能会说你这个例子刚好凑巧啊,前面还有一个ab和i前面的那个ab相等。
是的,就是凑巧,但是没关系,大家先听我继续往下讲。
当然现在我们发现j回退之后ij指向的两个字符是不相等的,所以这里又匹配失败,那我们就需要再让j往前回退到一个合适的位置,那后面的我们先不管。大家先理解这一步就行了。

那然后呢还有一个关键的问题就是我们到时候写代码的时候走到一个位置匹配失败了如何获取j应该回退到哪个位置呢?

3. KMP的精髓------next数组

那此时我们就引出了KMP算法的精髓------next数组:

next数组是一个与模式串(子串)等长的数组,用来存储子串在某个位置匹配失败时,j要回退的合适位置

就比如我们上面那个例子:


子串长度为6,那对应next数组的长度就是6

按我们上面的分析,在5位置匹配失败的时候j就要回退到下标2的位置,所以next数组中下标5的位置就存的是2

那我们如何求每一个位置匹配失败的时候j应该回退到哪个位置呢?

🆗,它的规则是这样的:

  1. 不管什么数据,前两个位置匹配失败j回退的位置是固定的:next[0] = -1;next[1] = 0
    第一个位置(即下标0位置)匹配失败了,那前面没有位置回退了,所以给一个-1(当然有些资料上给的可能跟我们不一样);第二个位置(下标1位置)匹配失败,那前面只有一个位置可以回退,即下标0位置,所以给0
  2. 然后对于其它位置呢,是这样求的:
    匹配失败的位置是j,那就从j位置前面的这个串的真子串(即除原字符串本身以外的所有子串)中寻找两个相同的子串,并且这两个子串必须是前面那个以下标 0 位置的字符开始,后面那个以 j-1 下标位置的字符结尾 ,这两个子串可以有重叠的部分,但是不能完全重叠(完全重叠就是一个串了)。
    然后j回退的位置就是这两个相同子串的长度
    比如,我们上面那个例子中:

    这里在下标5位置匹配失败,并在该位置前面找到了两个相同的子串(且满足第一个子串以下标0位置的字符开始,第二个串以下标j-1的字符结尾)。
    然后它们的长度为2,所以j就回退到下标2位置。
    那如果找不到两个相同的子串呢?
    找不到那它的长度就是0嘛,所以j回退到0下标位置。

🆗,那了解next数组的求解规则,我们来练习一下,就上面那个例子,我们来把它的next数组求解一下:


答案我就直接先贴在这里了。
然后我们一起来分析一下:
首先前两个位置是确定了,-1和0,就不多说了;
然后下标为2的位置,大家看

j前面能找到两个相同子串吗?
不能,所以是0.
接着下标3位置

找不到两个相同子串,也为0
再下面下标4位置

可以找到两个相同子串,长度为1,所以下标4位置的值是1.
最后一个下标5位置

长度为2,所以给2

4. 求next数组的练习

下面再给大家两个求解next数组的练习,大家做一下,因为有些地方可能会考这样的选择题。

练习 1: 举例对于"ababcabcdabcde", 求其的 next 数组?

大家自己先尝试做一下。
那我这里就不再像上面那样详细的讲解了,按照上面的规则,应该是比较简单求解的,况且上面我们已经给大家讲过一个例子了。
那我就直接给答案了
-1 0 0 1 2 0 1 2 0 0 1 2 0 0
大家自己对照一下

练习 2: 再对"abcabcabcabcdabcde",求其的 next 数组?

答案:
-1 0 0 0 1 2 3 4 5 6 7 8 9 0 1 2 3 0

然后再给大家提一些需要注意的就是:

去找这两个相同子串的时候,必须是第一个以下标0位置的字符开始,第二个以j-1位置的字符结束。
另外就是这两个子串可以有重叠的部分,但是不能完全重叠(完全重叠就是一个串了)。
那其实第二道题里面就有重叠的情况,大家注意一下。

还有呢就是有些题可能下标从1开始,那我们把结果都加个1就行了。

其次呢其实通过上面的练习我们可以发现:

就是这个next数组里面的值,如果是增加的话,一定是每次加1;
但是减小的时候,不一定都是直接减小到0,可能会是其它值,虽然我们上面的例子好像都是直接减到0的,大家注意一下。

5. 程序中如何计算next数组

到这里大家对如何求next数组应该没什么问题了,那么接下来的问题就是:

我们上面计算next数组的值是我们看着图去计算的,但是我们待会写程序的时候如何让我们的程序去计算出next数组的值呢?
或者说我们要想一下如果已知next[i] = k;怎么求next[i+1] = ?

那该怎么做呢?我们来通过一个例子分析一下:


大家看这个图,假设此时i的值是8.
那么我们看到此时next数组里面next[i]=3,为什么是3呢?

因为我们在i位置前面找到两个符合条件的相同的子串,长度为3,所以next[i]=3;
那么k就等于3,然后我们这里假设第二个子串的起始位置是x
因为这两个子串是相同的,即

p[0]...p[k-1]=p[x]...p[i-1],我们可以得到这样一个关系
然后呢,又因为这两个子串的长度是相等的,所以其实x的值就等于i-kx=i-k,没问题吧!
所以,把x替换为i-k,得:
p[0]...p[k-1]=p[i-k]...p[i-1]
当然它成立的前提next[i]=k
那我们继续往下看:
还是这个图

这里next数组里面i+1的位置next[i+1]的值是4,是next[i]+1,为什么是next[i]+1呢?
很好看出来,因为此时p[i]和p[k]是相同的即p[i]==p[k]
那么对于i+1来说,它的前面也有两个符合条件的相同的子串,并且长度是k+1(因为在前面的基础上又多了一个相同的字符)

那所以我们就得出一个结论:

如果有p[i]==p[k]
则:
next[i+1] = k+1

那么: 如果p[k] != p[i] 呢?

我们再来看一个例子:


大家看这个例子中,next[i]的值是2,p[i]和p[k]的值并不相等。
然后next[i+1]的值是1,那这个1是怎么算出来的呢?
🆗,是这样搞的:
此时i的值是5,而next[5]的值是2(i==5,k==2),即5下标位置匹配失败的时候,要回退到2下标位置。
此时p[5]!=p[2],所以我们不能直接利用上面的公式得出next[i+1] 的值。
那这时要从2继续回退,next[2]的值是0,所以此时回退到下标0 的位置。
那当k==0的时候,我们发现p[i]==p[k]就成立了

那我们就可以使用上面的公式了:
此时p[5]==p[0]
所以就有next[5+1] = 0+1next[6]=1

所以总结一下就是:

p[k] != p[i] 的时候,就一直回退(将k的值更新为回退之后的位置),直到p[k] == p[i] 时,就可以直接由next[i+1] = k+1得出next[i+1] 的值
那最坏的情况就是一直回退到-1下标位置(k为-1,那就是没找到两个符合条件的相同子串),那这时同样的next[i+1] = k+1next[i+1]的值就更新为-1+1=0

🆗,那讲清楚了上面这些内容,下面我们就来实现一下代码:

6. 代码实现

我们一起来写一下代码:


首先这里面的逻辑其实并不是太复杂,我就不过多解释了。
要说一下的是j==-1的情况的处理:

比如我们上面用过的这个例子,这里第一次在下标5位置匹配失败,然后j回退到2下标位置,但是j回退到2之后,ij指向的字符直接就不相等,匹配失败,那就要继续回退...
那在有些场景下就可以一直回退到下标-1的位置,比如

我们把子串第一个字符改成g,那这样的话上去第一个字符就不匹配,然后j就回退到了-1的下标位置。
那后面怎么继续匹配?
🆗,应该让主串从第二个字符开始重新匹配子串,因为主串的第一个都根子串第一个不同,所以要从后面开始匹配。
那是不是还是让i++,j++啊,i++走到主串第二个字符,j++走到子串的0下标,重新匹配
所以:

那然后剩下的就是要把next数组给搞出来,要不然j回退的时候不知道往哪回退啊:

那下面我们来实现一下GetNext函数:


搞定

那我们的KMP算法就写好了,来测试一下:


🆗,没什么问题

7. next数组的优化

那么最后再来讲一个东西就是next数组的优化:

next 数组的优化,即如何得到 nextval 数组

举个例子:

有如下串: aaaaaaaab
它的 next 数组是-1,0,1,2,3,4,5,6,7
大家可以自己算一遍

这没什么毛病,但是呢!
这样其实有一些不太好的地方,比如:

我们看到这个例子里面第一次会在i==7的时候匹配失败,那匹配失败怎么办啊?
🆗,i不动,j根据next数组里面的值进行回退
那此时j为7,next[7]为6,所以回退到下标6的位置,但是下标6的位置还是字符a,继续回退到下标5,还是a,继续回退到下标4位置...
我们会发现这里要连续回退,一直到-1的位置

那对于这种情况,我们能不能对它进行一个优化呢?直接让它回退到-1的位置:

当然是可以的!
next数组可以优化得到nextval数组。
而nextval数组的求法也很简单:

  1. 如果回退位置的字符和当前位置的字符是一样的,那么当前位置的值就填它要回退的那个位置的元素值
  2. 如果不一样,就还填自己本身对应的匹配失败时要回退的位置

那上面那个例子中next数组优化为nextval就是这样的:


那这样在下标7位置匹配失败是j就可以直接一步回退到下标-1位置

那请大家来做一个练习:

练习:模式串 t='abcaabbcabcaabdab' ,该模式串的 next 数组的值为( ) , nextval 数组的值为 ()
A. 0 1 1 1 2 2 1 1 1 2 3 4 5 6 7 1 2
B. 0 1 1 1 2 1 2 1 1 2 3 4 5 6 1 1 2
C. 0 1 1 1 0 0 1 3 1 0 1 1 0 0 7 0 1
D. 0 1 1 1 2 2 3 1 1 2 3 4 5 6 7 1 2
E. 0 1 1 0 0 1 1 1 0 1 1 0 0 1 7 0 1
F. 0 1 1 0 2 1 3 1 0 1 1 0 2 1 7 0 1
🆗,那答案呢是D、F
首先这道题按照我们的方法求出来是这样的

但是我们发现跟上面的答案都不匹配,因为题目给的答案是从0开始的,所以我们把我们自己求出来的结果都加个1就行了

那对照一下其实就是D、F

那当然呢我们也可以把这个优化的代码写一下:

我们可以验证一下对不对,就用上面那个例子:


我们来调式观察一下
优化之前是这样的

没问题,那我们看优化之后

🆗,没有问题!

8. 源码

cpp 复制代码
#define _CRT_SECURE_NO_WARNINGS
#include <iostream>
using namespace std;
#include <string>
#include <vector>

void GetNext(const string& sub, vector<int>& next)
{
	//前两个值是确定的
	next[0] = -1;
	next[1] = 0;

	//从第三值(下标2)开始求:i+1=2,i=1
	size_t i = 1;
	int k = next[i];

	size_t size = next.size();
	while (i < size - 1)
	{
		if (k == -1 || sub[i] == sub[k])
		{
			next[i + 1] = k + 1;
			i++;
			k = next[i];
		}
		else
		{
			k = next[k];
		}
	}

	for (size_t i = 1; i < size; i++)
	{
		if (sub[i] == sub[next[i]])
			next[i] = next[next[i]];
	}
}

/*
* str:主串
* sub:子串
* pos:从主串的pos位置开始匹配
*/
int KMP(const string& str, const string& sub, size_t pos)
{
	if (str.empty() || sub.empty())
		return -1;
	if (pos<0 || pos>str.size())
		return -1;

	vector<int> next(sub.size());
	GetNext(sub, next);

	size_t i = pos;//遍历主串
	int j = 0;//遍历子串

	int lenstr = str.length();
	int lensub = sub.length();
	while (i < lenstr && j < lensub)
	{
		if (j == -1 || str[i] == sub[j])
		{
			i++;
			j++;
		}
		else
		{
			j = next[j];
		}
	}

	if (j == lensub)
	{
		return i - j;
	}
	return -1;
}

int main()
{
	cout << KMP("aaaaaaaffaaaaaaaab", "aaaaaaaab", 0) << endl;

	cout << KMP("ababcabcdabcde", "abcd", 0) << endl;
	cout << KMP("ababcabcdabcde", "abcdef", 0) << endl;
	cout << KMP("ababcabcdabcde", "ab", 0) << endl;
	return 0;
}
相关推荐
盼海39 分钟前
排序算法(五)--归并排序
数据结构·算法·排序算法
网易独家音乐人Mike Zhou4 小时前
【卡尔曼滤波】数据预测Prediction观测器的理论推导及应用 C语言、Python实现(Kalman Filter)
c语言·python·单片机·物联网·算法·嵌入式·iot
Swift社区8 小时前
LeetCode - #139 单词拆分
算法·leetcode·职场和发展
Kent_J_Truman8 小时前
greater<>() 、less<>()及运算符 < 重载在排序和堆中的使用
算法
IT 青年9 小时前
数据结构 (1)基本概念和术语
数据结构·算法
Dong雨9 小时前
力扣hot100-->栈/单调栈
算法·leetcode·职场和发展
SoraLuna9 小时前
「Mac玩转仓颉内测版24」基础篇4 - 浮点类型详解
开发语言·算法·macos·cangjie
liujjjiyun10 小时前
小R的随机播放顺序
数据结构·c++·算法
¥ 多多¥10 小时前
c++中mystring运算符重载
开发语言·c++·算法
trueEve11 小时前
SQL,力扣题目1369,获取最近第二次的活动
算法·leetcode·职场和发展