持续创作,加速成长!这是我参与「掘金日新计划 · 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]为pp[0]为A- 此时
s[0] != p[0],很明显字符不匹配,因此: - 令
i回溯:i = i-(j-1),即0-(0-1),i变成了1 - 令
j重置为零:即j = 0 - 这种效果其实就是将模式串
s要往后移动一位
可以看到,将模式串往右边移动后:
s[1]为Fp[0]为A- 此时
s[1] != p[0],字符不匹配,因此: - 令
i回溯:i = i-(j-1),即1-(0-1),此时i变成了2 - 令
j重置为零:即j = 0 - 模式串
s不断往后移动。
(2) 暂时匹配成功
可以看到:
s[2]为Ap[0]为A- 此时
s[2] == p[0],对应字符成功匹配,因此: - 两指针同时后移,即
i++; j++; - 此时
i=3, j=1,继续往后匹配。
可以看到:
s[3]为Bp[1]为B- 此时
s[3] == p[1],对应字符成功匹配,因此: - 两指针同时后移,即
i++; j++; - 此时
i=4, j=2,继续往后匹配,不断重复这个过程...
(3) 再次匹配失败
可以看到:
s[6]为Rp[4]为E- 此时
s[6] != p[4],对应字符再次不匹配,因此: - 令
i回溯:i = i-(j-1),即6-(4-1),i变成了3 - 令
j重置为零:即j = 0 - 此时模式串
s又再次往后移动一位。
(4) 最后匹配成功
可以看到,到最后:
s[13]为Ep[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、写在最后
好了,关于串的模式匹配,就先记录到这里,如果文章有出错的地方,请大佬们指出。今天文章的内容就写到这里,感谢观看。