本期博客来介绍字符串匹配算法:KMP算法
在开始KMP算法讲解之前,我们先来看看KMP算法的基础BF算法
一、BF算法
BF算法,即暴力(Brute Force)算法,是普通的模式匹配算法,BF算法的思想就是将目标串S的第一个字符与模式串T的第一个字符进行匹配,若相等,则继续比较S的第二个字符和T的第二个字符;若不相等,则比较S的第二个字符和T的第一个字符,依次比较下去,直到得出最后的匹配结果:
该算法比较简单,如果设目标串长度为N,模式串长度为M,其时间复杂度为O(N*M),并不理想
二、KMP算法
2.1 KMP算法的思路
KMP(Knuth-Morris-Pratt)算法是一种字符串匹配算法,用于在一个主文本串中查找一个模式串的出现位置。其核心思想是利用已经匹配过的信息,尽量减少不必要的比较次数,从而提高匹配效率。
KMP算法的关键在于构建一个部分匹配表(Partial Match Table),也称为失配函数(Failure Function)或Next数组,用于指导匹配过程中的跳转操作。部分匹配表记录了模式串中每个位置的最长公共前后缀的长度。
2.1.1 next数组的引出
我们先来看一个例子:
我们先按BF算法的思路来匹配两个字符串:
发现匹配到i和j的下标到5时出现了失配的情况,但是下面我们不将i下标进行回退,而是将j下标回退到一个合理的位置继续和i下标进行匹配
那j回退到那个下标合理呢?我们下面来分析一下:
我们可以看到在匹配过的模式串序列中有两个子串是相同的:T[0]-T[4]和T[3]-T[4]:
那既然匹配过的模式串序列中有两个子串是相同的,那匹配过的目标串中必然也有子串和模式串中的子串是相同的:
那我们不如让j回退到2下标来继续和i指向的位置继续比较:
可以发现,j回退的位置就是相同子串的长度!
由上面的例子可以知道,我们可以找到模式串匹配过的序列中的最长公共前后缀的长度k,然后让j回退到k位置再继续进行匹配
因此当匹配时出现了失配,模式串中失配位置的下标都对应着一个回退值,所以我们需要构建一个数组,来存储模式串中每个位置发生失配将要回退到的下标值(我们将其称为next数组);例如上述例子中next数组的5号位元素值为2,即模式串中五号位元素发生失配j回退到2位置
2.1.2 next数组的计算
因此我们需要在匹配之前算出next数组中的每个值k
具体计算方法为:
找到匹配成功部分的两个相等的真子串(不包含本身):一个子串是从0下标开始,以j-1下标相同的字符结尾;另一个子从串0下标相同的字符开始,以j-1下标结尾。
找到子串的长度就是我们所要的k值。(在这里默认next[0] = -1;next[1] = 0,有的教材中会默认next[0] = 0;next[1] = 1)
例如下面例子中模式串对应的next数组为:
我们从next数组中发现一个规律:如果next数组中有连续的元素在增长(递增),那后一个元素一定是前一个元素加1;
那为什么会这样呢?下面来分析一下:
假设next数组中第i个元素为k,那么就一定有模式串T中从0到k-1元素之间形成的字符串,与x(未知)到i-1元素之间形成的字符串是相同的;即T[0]—T[k-1] = T[x]—T[i-1]
由于两个相同的字符串元素个数时相同的,我们可以得出k-1-0=i-1-x;即x=i-k
那上面推导出的公式可以写为:T[0]—T[k-1] = T[i-k]—T[i-1]
如果这时T[i]==T[k],显然我们可以得出T[0]—T[k] = T[i-k]—T[i],即next[i+1]=k+1
从上面的推导中我们可以清楚的看到:如果next[i]=k且T[i]=T[k],则next[i+1]=k+1
那如果T[i]!=T[k]呢?我们后面要怎么计算下一个next[i+1]呢?
来看到下面的例子:
这时出现了T[k]!=T[i]的情况,我们将k重新赋值回退一下:k=next[k],这时k的值就变为了0,再来对比T[k]是否与T[i]相等,如果相等就可以确定next[i+1]=k+1,如果还是不相等的话就一直回退,直到T[k]=T[i]或者k的值为-1为止:
2.2 KMP算法的实现
下面是用C++实现的KMP算法:
#include<iostream>
#include<string>
#include<vector>
using namespace std;
void GetNext(const string& T, vector<int>& next)
{
int len = T.size();
if (len >= 1)//防止模式串长度小于等于2
{
next[0] = -1;
if (len >= 2)
{
next[1] = 0;
}
}
int k = 0, i = 1;
while (i < len - 1)//计算剩下的每个位置的next数组
{
if (k == -1 || T[i] == T[k])
{
next[i + 1] = k + 1;
++i;
++k;
}
else
{
k = next[k];
}
}
}
int KMP(const string& S, const string& T, int pos = 0)//S为目标串,T为模式串,pos是S中开始比较的位置
{
int lenS = S.size(), lenT = T.size();
if (lenS == 0 || lenT == 0 || pos < 0 || pos >= lenS)
return -1;
if (lenT > lenS - pos)
return -1;
vector<int> next(lenT);
GetNext(T, next);
int i = pos, j = 0;//i遍历目标串,j遍历模式串
while (i < lenS && j < lenT)
{
if (j == -1 || S[i] == T[j])//当j为-1时表示next数组使其回退到了模式串的首元素,这时直接++就好(从模式串的首元素重新开始匹配)
{
++i;
++j;
}
else
{
j = next[j];
}
}
if (j >= lenT)
{
return i - j;//返回匹配成功的目标串中第一个元素的位置
}
return -1;
}
2.3 next数组的优化
我们下面来看到一个例子:
在这个例子中当‘e’与‘f’不匹配时,我们按照next数组进行回退会发现:每次回退进行匹配的字符都是'x',这么多次的回退是没有意义很浪费时间的,我们何不如让模式串直接回退到首元素再继续比较呢?
下面就有了nextval数组,该数组的计算是这样的:该数组的首元素还是-1,nextval第i个位置的元素要看next[i]的值指向的位置k的字符是否与i位置的字符相同:如果相同nextval第i个位置的元素就为nextval第k个位置的值(即nextval[i]=nextval[next[i]]);如果不相同nextval第i个位置的元素就为next第i个位置的值(即nextval[i]=next[i]):
下面我们来优化一下代码:
2.4 KMP算法的优化
#include<iostream>
#include<string>
#include<vector>
using namespace std;
void GetNextval(const string& T, vector<int>& nextval)
{
int len = T.size();
nextval[0] = -1;
int k = 0, i = 1;
while (i < len - 1)//计算剩下的每个位置的nextval数组
{
if (k == -1 || T[i] == T[k])
{
++i;
++k;
if (T[i] == T[k])
{
nextval[i] = nextval[k];
}
else
{
nextval[i] = k;
}
}
else
{
k = nextval[k];
}
}
}
int KMP(const string& S, const string& T, int pos = 0)//S为目标串,T为模式串,pos是S中开始比较的位置
{
int lenS = S.size(), lenT = T.size();
if (lenS == 0 || lenT == 0 || pos < 0 || pos >= lenS)
return -1;
if (lenT > lenS - pos)
return -1;
vector<int> nextval(lenT);
GetNextval(T, nextval);
int i = pos, j = 0;//i遍历目标串,j遍历模式串
while (i < lenS && j < lenT)
{
if (j == -1 || S[i] == T[j])//当j为-1时表示nextval数组使其回退到了模式串的首元素,这时直接++就好(从模式串的首元素重新开始匹配)
{
++i;
++j;
}
else
{
j = nextval[j];
}
}
if (j >= lenT)
{
return i - j;//返回匹配成功的目标串中第一个元素的位置
}
return -1;
}
2.5 KMP算法的时间复杂度
我们设目标串S长度为N,模式串t的长度为M。求next数组的时间复杂度为O(m),因后面匹配中主串不回溯,比较次数可记为N,所以KMP算法的总时间复杂度为O(M+N),空间复杂度记为O(m)。相比于BF算法时间复杂度O(m*n),KMP算法速度的提升是非常大的