持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第30天,点击查看活动详情
1、写在前面
大家好,我是翼同学。今天文章的内容是:
- KMP算法
在学完算法与数据结构中串的课程后,对KMP算法有种似懂非懂的感觉。今天就系统的再对KMP算法进行梳理和归纳,
2、串的模式匹配
在数据结构中,串属于线性结构,是一种数据元素为字符的特殊线性表。而串的特殊性在于,操作的对象不再是单个元素,而是一组元素。比如在线性表中查找某个元素,比如在线性表中,我们通常会查找、插入或删除某个元素,而在串中,我们还能在某个位置查找、插入或删除某段子串。即以“一段子串”为操作对象。
举个例子,假设我们现在有一段主串S
(目标串)和子串P
(模式串),此时的需求是在主串S
中找到一个与子串P
相等的子串,如果找到了就返回子串P
在主串S
中第一次出现的位置。
像这种子串的定位操作,通常被我们称为模式匹配(或模型匹配)。
3、BF算法
3.1、算法思路
对于子串的模式匹配,如果采用朴素的模式匹配(Brute-Force算法,简称BF算法
)思路,则算法思想如下:
- 假设现在目标串
S
匹配到i
位置,模式串P
匹配到j
位置 - 如果此时
S[i] == P[j]
(字符匹配成功),则继续匹配下一个字符,即i++, j++
- 如果此时
S[i]! = P[j]
(字符匹配失败),则使i
回溯,j
则重置为0
,即i = i - (j - 1)
,j = 0
- 不断进行朴素的模式匹配
- 如果最后匹配成功,则返回模式串的第一个字符在目标串中第一次出现的位置
- 如果匹配失败,则返回
-1
。
3.2、参考代码
根据朴素的模式匹配算法思路,我们很容易写出代码。
参考代码如下:
// 设定s是目标串,p是模式串
int bfFind(char* s, char* p) {
int sLen = strlen(s); // 目标串的长度
int pLen = strlen(p); // 模式串的长度
int i = 0; // 指向目标串的下标
int j = 0; // 指向模式串的下标
while (i < sLen && j < pLen) {
// 当字符相等时,指针后移,匹配下一个字符
if (s[i] == p[j]) {
i++;
j++;
}
// 如果字符匹配失败,则指针i回溯,j则重置为0
else {
i = i - j + 1;
j = 0;
}
}
// 如果匹配成功,则返回模式串 p 的第一个字符在目标串中出现的位置
if (j == pLen) {
return i - j;
}
// 如果匹配失败,则返回-1
else{
return -1;
}
}
3.3、示意图
首先我们设定:
- 目标串为:
PFABCDR GABCDE
- 模式串为:
ABCDE
- 最后通过模式匹配后,结果返回
9
- 也就是说,模式串中的第一个字符(
A
)在目标串中出现的位置为s[9]
。
下面是朴素的模式匹配过程示意:
(1) 模式串不断往后移动
可以看到:
s[0]
为p
p[0]
为A
- 此时
s[0] != p[0]
,很明显字符不匹配,因此: - 令
i
回溯:i = i-(j-1)
,即0-(0-1)
,i
变成了1
- 令
j
重置为零:即j = 0
- 这种效果其实就是将模式串
s
要往后移动一位
可以看到,将模式串往右边移动后:
s[1]
为F
p[0]
为A
- 此时
s[1] != p[0]
,字符不匹配,因此: - 令
i
回溯:i = i-(j-1)
,即1-(0-1)
,此时i
变成了2
- 令
j
重置为零:即j = 0
- 模式串
s
不断往后移动。
(2) 暂时匹配成功
可以看到:
s[2]
为A
p[0]
为A
- 此时
s[2] == p[0]
,对应字符成功匹配,因此: - 两指针同时后移,即
i++; j++;
- 此时
i=3, j=1
,继续往后匹配。
可以看到:
s[3]
为B
p[1]
为B
- 此时
s[3] == p[1]
,对应字符成功匹配,因此: - 两指针同时后移,即
i++; j++;
- 此时
i=4, j=2
,继续往后匹配,不断重复这个过程...
(3) 再次匹配失败
可以看到:
s[6]
为R
p[4]
为E
- 此时
s[6] != p[4]
,对应字符再次不匹配,因此: - 令
i
回溯:i = i-(j-1)
,即6-(4-1)
,i
变成了3
- 令
j
重置为零:即j = 0
- 此时模式串
s
又再次往后移动一位。
(4) 最后匹配成功
可以看到,到最后:
s[13]
为E
p[4]
为E
- 此时
s[14] != p[4]
,对应字符匹配,因此: - 两指针同时后移,即
i++; j++;
- 此时
i=14, j=5
- 因为不满足
i < sLen && j < pLen
,所以退出循环 - 最后
return i - j;
,也就是返回10
(目标串中A
的下标就为10
)
(5) 小结
到这里,我们可以发现,对于朴素的模式匹配算法来说,我们很容易理解,代码简单易懂。但缺点也很大,就是i
需要多次回溯。如果对于数据量较大的文件,朴素的模式匹配其实是效率很低的。为了提高模式匹配的算法效率,我们需要减少字符匹配的次数以及回溯的次数。
比如在前文给出的匹配过程中:
- 虽然模式串和目标串已经成功匹配了四个字符
ABCD
s[6] != p[4]
,即'R' != 'E'
- 所以目标串就回溯到
s[3]
,模式串则被重置为p[0]
- 也就是将模式串向后移动一位,然后继续匹配(让
s[3]
和p[0]
进行字符匹配)
为了提高模式匹配的算法效率,下面我们来学习KMP算法
4、KMP算法
4.1、简介
KMP算法的简介如下:
KMP
算法是一种改进的字符串匹配算法- 由D.E.Knuth,J.H.Morris和V.R.Pratt提出的
- 因此人们称它为
Knuth-Morris-Pratt
算法(简称KMP
算法)KMP
算法的核心是利用匹配失败后的信息- 尽量减少模式串与主串的匹配次数以达到快速匹配的目的
- 具体实现就是通过一个
next()
函数实现next()
函数本身包含了模式串的局部匹配信息KMP
算法的时间复杂度
对于KMP算法来说,主要特点就是主串(目标串)不用回溯,主串指针i
一直往后面移动,只有子串(模式串)的指针j
在回溯。这就大大减少了模式匹配算法的比较次数以及回溯次数。KMP算法可以在的时间复杂度量级上完成串的模式匹配。
4.2、引入
回顾前文的匹配过程:
- 这趟匹配中,在
i=6, j=4
下标处,对应字符不匹配, - 在朴素的模式匹配下,
i, j
都将回溯 - 也就是又从
i=3, j=0
下标处重新开始进行匹配。
如下所示:
但实际上可以发现,i=3
和j=0
、i=4
和j=0
、i=5
和j=0
这三趟字符匹配,完全没必要进行,也就是说可以省略掉这几次字符匹配的过程,将子串向右滑动3个字符,继续从i=6, j=0
下标处开始匹配即可。
如下所示:
也就是说,在KMP
算法下,当目标串下标为i
的字符与目标串中下标为j
的字符不匹配时,目标串指针i
不回溯,模式串指针j
回溯到一个合适的位置,重新进行比较(模式串相对于目标串,是向右移动的)。
明白了这个思路后,我们再来看看,模式串指针j
应该滑动到哪个合适的位置才合适呢?
4.3、KMP算法思想
KMP算法采用以空间换时间的方式,申请一个与子串长度相等的整型数组next
,令next[j] = k
,则next[j]
表示当子串中p[j]
与主串中s[i]
匹配失败时,在子串中需要重新和主串中s[i]
进行匹配的字符的位置k。也就是说,当模式匹配时出现失配时,主串下标i
不变,利用next
数组求出子串下标为j
的位置失配时需要滑动到的新位置k
。
这就是KMP算法的基本思想。
我们先不管next
数组是怎么计算得到的,先看看KMP算法的具体流程。
如下所示:
- 设定主串匹配到
s[i]
处,子串匹配到p[j]
处 - 如果当前
j = -1
或者s[i] == p[j]
,则代表当前字符匹配成功,此时让指针变量都往后移动一位,继续匹配下一个字符 - 如果
j != -1
并且s[i] != p[j]
,则代表当前字符匹配失败,此时令i
不变,而将j
赋值为next[j]
,其实就是子串相对于主串向后移动了j - next[j]
位。
因此在KMP算法中,当模式串的某个字符跟目标串中的某个字符不匹配时,next
数组会告诉模式串下一步匹配时应当在哪个位置,而不是傻乎乎的将模式串往后移一位。
4.4、KMP算法代码
根据KMP算法思路,我们很容易写出代码。
参考代码如下:
// 设定 s 是目标串, p 是模式串
int KMPFind(char* s, char* p) {
int sLen = strlen(s); // 目标串的长度
int pLen = strlen(p); // 模式串的长度
int i = 0; // 指向目标串的下标
int j = 0; // 指向模式串的下标
// 定义next数组
int *next = new int [pLen];
getNext(next, p);
while (i < sLen && j < pLen) {
// 当字符相等时,指针后移,匹配下一个字符
if (j == -1 || s[i] == p[j]) {
i++;
j++;
}
// 如果 j!=-1 并且s[i] != p[j],则表示字符匹配失败
// 此时指针 i 不回溯,j 则重置为next[j]
else {
j = next[j];
}
}
// 如果匹配成功,则返回模式串 p 的第一个字符在目标串中出现的位置
if (j == pLen) {
return i - j;
}
// 如果匹配失败,则返回-1
else{
return -1;
}
}
4.5、next 数组
(1) 求解 k 的值
现在我们假设:
- 主串 = " ... "
- 子串 = " ... "
则在KMP算法中,当 和 匹配失败时,主串不回溯,而子串中的第 个字符 将与主串 继续进行匹配。
示意图如下:
- 主串:
- 子串:
- 如果子串中" ... "已经完成匹配
- 但此时 不等于 时,匹配失败后
- 此时主串指针
i
不用动 - 子串也不用重置为
0
,不用从头开始匹配 - 而是将子串回溯到下标为
k
的位置与主串继续进行匹配: - 示意图如下:
也就是说,当发生字符匹配失败时,仅需将子串向右滑动至子串中下标为k的字符,与主串中下标为i的字符对齐,继续进行匹配即可。这是因为子串前面的k个字符必定与主串中下标为i的字符前长度为k的子串相等。
到这里,你就会发现,KMP算法的关键问题已经变成了如何求解 k 的值。
(2) 最长相等前后缀
首先记录一下什么是前后缀子串:
- 前缀:指的是不包含最后一个字符的所有以第一个字符开头的连续子串
- 后缀:指的是不包含第一个字符的所有以最后一个字符结尾的连续子串
正确理解前后缀子串的含义,对于后续理解next数组
来说有很大的帮助。
前文简述了k值的重要性,可能看起来不太直观。现在我们来看一道例题:
- 主串 为:"abc123abc123abcd"
- 子串 为:"123abcd"
- 在串的模式匹配中,会出现这样的情况,如下图:
可以看到,s[9] != p[9]
,此时如果是朴素的模式匹配算法,则i会回溯到1,j则会重置为0。但是在KMP算法中,主串指针i不动,子串指针j会回溯到k的位置(实际上k
的值就是next[j]
),如下图所示:
因此,为了确定k
的值:
- 我们会在子串
P
中寻找最长相同的 - 前缀子串 " ... "
- 以及后缀子串 " ... "。
- 如果找到了最长的相同的前后缀子串,则k的值就可以确定了。
(3) 什么是next数组?
无论是k值,还是最长相等前后缀子串,都是为理解next
数组而服务。
那么next
数组到底是什么?
其实,next数组
就是用来让模式串指针j
进行合理地回溯,其记录了模式串与目标串不匹配时,模式串应该从哪里开始重新匹配。
而前文提到的k
值,就是next[j]
:
- 在KMP算法中,我们申请一个整型数组
next
- 并且令
next[j] = k
- 则
next[j]
表示当子串中 和主串中 失配时, - 在子串中需要重新和主串中的 进行匹配的字符下标为
k
。
也就是说,next
数组要求的就是最长相同前后缀的长度。
因此,每个字符对应的next
数组值,其实就是该字符之前的子串中包含最大长度的相同前后缀子串。注意,理解这一句话很重要。
(4) 如何获取next数组?
下面给出获取next数组的参考代码:
void getNext(char* p, int next[]) {
int pLen = strlen(p); // 取到子串的长度 pLen
next[0] = -1; // next[0]初始化为-1, 表示子串已经滑动到头
int k = -1; // p[k]表示前缀子串
int j = 0; // p[j]表示后缀子串
// 遍历子串
while (j < pLen - 1) {
if (k == -1 || p[j] == p[k]) {
++k;
++j;
next[j] = k;
}
else {
k = next[k];
}
}
}
备注:
- 提问:为什么
next[0]
要初始化为-1
? - 回答:这是为了表示当子串指针
j
指向下标为0
的元素在匹配失败后进行下一轮匹配时,子串指针j
仍然可以指向下标为0
的元素。
(5) 例子
现在给出几张next数组求解的示意图,帮助理解next
数组的求解过程。
- 假设现在有一段模式串
P
=abc123abcd"
,任务是求出其next
数组:
# 初始化
- 设
k = -1
,j = 0
,并且next[0] = -1
# 第1轮循环
- 初始化工作完成后,就开始第一轮循环:
- 此时
k = -1
刚好满足if
语句的判断条件 - 则直接执行
if
语句块,j
和k
先自增,即k = 0, j = 1
- 接着就是
next
数组元素的赋值:next[j] = k
- 因此
next[1]
就等于0
- 过程如下:
# 第2轮循环
- 进入第二轮循环后,由于
k = 0
并且p[j] = "b"
,p[k] = "a"
,二者并不相等 - 因此不满足
if
判断语句,此时执行else
语句块: k = next[k]
, 即k = next[0]
。因此k
就又变成了-1
- 过程如下:
# 第3轮循环
- 进入第三轮循环后,由于
k = -1
,满足if
语句的判断条件 - 因此执行
if
语句块,j
和k
先自增,即j = 2
,k = 0
- 接着就是
next
数组元素的赋值:next[j] = k
,即next[2] = 0
- 过程如下:
# 第4轮循环
- 进入第四轮循环后
- 由于
j = 2, k = 0
并且p[j] = "c"
,p[k] = "a"
,二者并不相等 - 因此不满足
if
判断语句,此时执行else
语句块: k = next[k]
, 即k = next[0]
。因此k
就又变成了-1
- 过程如下:
# 第5轮循环
- 进入第五轮循环后,由于
k = -1
,满足if
语句的判断条件 - 因此执行
if
语句块,j
和k
先自增,即j = 3
,k = 0
- 接着就是
next
数组元素的赋值:next[j] = k
,即next[3] = 0
- 过程如下:
# 第6轮循环
- 进入第六轮循环后
- 由于
j = 3, k = 0
并且p[j] = "1"
,p[k] = "a"
,二者并不相等 - 因此不满足
if
判断语句,此时执行else
语句块: k = next[k]
, 即k = next[0]
。因此k
就又变成了-1
- 过程如下:
# 第7轮循环
- 进入第七轮循环后,由于
k = -1
,满足if
语句的判断条件 - 因此执行
if
语句块,j
和k
先自增,即j = 4
,k = 0
- 接着就是
next
数组元素的赋值:next[j] = k
,即next[4] = 0
- 过程如下:
# 第8轮循环
- 进入第八轮循环后
- 由于
j = 4, k = 0
并且p[j] = "2"
,p[k] = "a"
,二者并不相等 - 因此不满足
if
判断语句,此时执行else
语句块: k = next[k]
, 即k = next[0]
。因此k
就又变成了-1
- 过程如下:
# 第9轮循环
- 进入第九轮循环后,由于
k = -1
,满足if
语句的判断条件 - 因此执行
if
语句块,j
和k
先自增,即j = 5
,k = 0
- 接着就是
next
数组元素的赋值:next[j] = k
,即next[5] = 0
- 过程如下:
# 第10轮循环
- 进入第十轮循环后
- 由于
j = 5, k = 0
并且p[j] = "3"
,p[k] = "a"
,二者并不相等 - 因此不满足
if
判断语句,此时执行else
语句块: k = next[k]
, 即k = next[0]
。因此k
就又变成了-1
- 过程如下:
# 第11轮循环
- 进入第十一轮循环后,由于
k = -1
,满足if
语句的判断条件 - 因此执行
if
语句块,j
和k
先自增,即j = 6
,k = 0
- 接着就是
next
数组元素的赋值:next[j] = k
,即next[6] = 0
- 过程如下:
# 第12轮循环
- 进入第十二轮循环后
- 由于
j = 6, k = 0
- 并且此时
p[j] = "a"
,p[k] = "a"
:二者相等!字符匹配成功! - 因此满足
if
判断语句,此时执行if
语句块: j
和k
先自增,即j = 7
,k = 1
- 接着就是
next
数组元素的赋值:next[j] = k
,即next[7] = 1
- 过程如下:
# 第13轮循环
- 进入第十三轮循环后
- 由于
j = 7, k = 1
- 并且此时
p[j] = "b"
,p[k] = "b"
:二者相等!字符匹配成功! - 因此满足
if
判断语句,此时执行if
语句块: j
和k
先自增,即j = 8
,k = 2
- 接着就是
next
数组元素的赋值:next[j] = k
,即next[8] = 2
- 过程如下:
# 第14轮循环
- 进入第十四轮循环后
- 由于
j = 8, k = 2
- 并且此时
p[j] = "c"
,p[k] = "c"
:二者相等!字符匹配成功! - 因此满足
if
判断语句,此时执行if
语句块: j
和k
先自增,即j = 9
,k = 3
- 接着就是
next
数组元素的赋值:next[j] = k
,即next[9] = 3
- 过程如下:
# 退出循环
至此,由于j = 9
不满足循环条件while(j < pLen-1)
,因此退出循环。
所以最后,我们也得到了模式串P
的next
数组,如下所示:
5、总结
总结一下,对于串的模式匹配中:
- 在BF算法(朴素的模式匹配,简称暴力检索)中,当主串和子串发生失配时,主串和子串的指针都需要回溯,然后再进行匹配。所以该算法的时间复杂度较高,达到,空间复杂度为。
- 在KMP算法中,我们设计了一个
next
数组,用于达到主串不回溯,让子串指针有规律回溯的目的,这样就减少了回溯和匹配的次数,大大提升了模式匹配的效率。简单来说,KMP的算法思想是当出现对应字符不匹配时,可以先记录一部分之前已经匹配的文本内容,利用这些信息避免从头再去做匹配。但这是建立在牺牲了存储空间的基础上进行的。KMP算法的时间复杂度为,空间复杂度为
6、刷题巩固
6.1、找出字符串中第一个匹配项的下标
学完KMP算法后,可以尝试刷这道题:找出字符串中第一个匹配项的下标,我们可以用KMP算法去解这道题。
(1) 描述
(2) 举例
(3) 提示
6.2、利用KMP算法解题
参考代码(C++版)如下:
class Solution {
public:
int strStr(string haystack, string needle) {
int sLen = haystack.length(); // 获取主串的长度
int pLen = needle.length(); // 获取子串的长度
// 如果模式串的长度为0,则返回0
if(pLen == 0) return 0;
int next[pLen]; // 定义next数组,用模式串的长度去赋值数组的大小
getNext(next, needle); // 为next数组的数据元素赋值
int i = 0; // 主串指针
int j = 0; // 子串指针
// 循环遍历
while(i < sLen && j < pLen) {
// 当字符相等时,指针后移,匹配下一个字符
if (j == -1 || haystack[i] == needle[j]) {
i++;
j++;
}
// 如果 j!=-1 并且s[i] != p[j],则表示字符匹配失败
// 此时指针 i 不回溯,j 则重置为 next[j]
else {
j = next[j];
}
}
// 如果匹配成功,则返回模式串 p 的第一个字符在目标串中出现的位置
if (j == pLen) {
return i - j;
}
// 如果匹配失败,则返回-1
else{
return -1;
}
}
// 自定义函数,用于获取 next 数组
void getNext(int* next, string& p) {
int pLen = p.length();
int k = -1;
int j = 0;
next[0] = -1;
while(j < pLen - 1) {
if(k == -1 || p[j] == p[k]) {
k++;
j++;
next[j] = k;
} else {
k = next[k];
}
}
}
};
7、写在最后
好了,关于串的模式匹配,就先记录到这里,如果文章有出错的地方,请大佬们指出。今天文章的内容就写到这里,感谢观看。