KMP算法详解

56 阅读8分钟

本文已参与「新人创作礼」活动,一起开启掘金创作之路。

kmp算法详解(以下标为0开始的字符串举例)

什么是KMP算法呢?

Knuth-Morris-Pratt 字符串查找算法,简称为 KMP算法,常用于在一个文本串 S 内查找一个模式串 P 的出现位置。 这个算法由 Donald Knuth、Vaughan Pratt、James H. Morris 三人于 1977 年联合发表,故取这 3 姓氏命名此算法。 KMP算法就是一种模式匹配的算法,它可以将传统的模式匹配算法的时间复杂度降低至 O(n+m),采用了空间换时间的方法。

接下来我将以传统的模式匹配算法为切入点逐步讲解KMP算法

一:基础知识

  1. 什么是子串?

    中任意个连续的字符组成的子序列称为该串的子串。如abcd是abcde的字串,而abce不是。

  2. 什么是字符串的模式匹配呢?

    给定两个串S=s0s1s2s3sn”和T=t0t1t2t3tn”,在主串S中寻找子串T的过程叫做模式匹配,T称为模式。给定两个串S=“s_0s_1s_2s_3 …s_n”和T=“t_0t_1t_2t_3 …t_n”,在主串S中寻找子串T的过程叫做模式匹配,T称为模式。

二:BF算法(较为暴力的匹配算法)

该方法较为暴力,其核心的思想就是将S和T一个字符的进行匹配,若当前匹配失败,该,T、下标回溯继续匹配,直到在S中找到T,或者超出S和T长度。

具体算法如下:

设字符串T(下标为 j ),S(下表为 i ),初始化时i = 0,j = 0,当S[i] == T[j] 时,i++,j++,若不等于则回溯,j =0,i = i-j+1(S中上一次开始的下一个),具体代码如下:

int BF(string S,string T){
  int len_S = S.length();//S的长度 
  int len_T = T.length();//T的长度 
  int i = 0;//S的下标 
  int j = 0;//T的下标 
  while(i<len_S && j<len_T){//不超出S和T长度且下标从0开始
  	if(S[i]==T[j]){
  		i++;
  		j++;
  	}else{
  		i = i-j+1;
  		j = 0;
  	}
  }
  if(j>=len_T) return i-j;//返回T在S中的第一元素的坐标 
  else return false;//没有找到 
} 
以S = ababce,T = abc举例。

1.S[0] == T[0] ,则 i = 1,j = 1

2.S[1] == T[1],则 i = 2,j = 2

3.S[2] != T[2] ,则 i = i-j+1 = 1, j = 0

4.S[1] != T[0], 则 i = i-j+1 = 2, j = 0

5.S[2] == T[0] ,则 i = 3 ,j = 1

6.S[3] == T[1], 则 i =4 ,j = 2

7.S[4] == T[2],则 i = 5,j = 3

8.此时 j == len_T 结束while() 循环

9.if中判断为真,在S中找到了T字串,返回 i-j = 2

BF算法的分析

BF算法虽然简单,较好理解,但是在一些场合下效率极其低下,如S = “0000000001“,T=“0000000000000000000000000000001”

每趟比较都会在T的最后一个字符‘1’出现不等,所以需要一直重复,O(n*m),其中n时S的长度,m是T的长度。

所以该算法效率较为低下。

1977年Donald Knuth、Vaughan Pratt、James H. Morris 三人联合发表了一种效率及其高效的算法,简称为KMP算法,该算法的时间复杂度为O(n+m)

三:KMP算法

KMP算法的改进之处

KMP算法改进在于:每当从某个起始位置开始一趟比较后,在匹配过程中出现失配,不回溯i,而是利用已经得到的部分匹配结果,将一种假想的位置定位“指针”在模式上向右滑动尽可能远的一段距离到某个位置后,继续按规则进行下一次的比较。从而达到降低时间复杂度的目的。

在介绍KMP算法之前,我们先来认识最大公共前后缀

前缀:除开最后一个字符的所有子串,且必须从第一个字符开始。

后缀:除开第一个字符的所有字串,且必须由最后一个字符结束

最大公共前后缀:一个字符串中所有前后缀的交集。

下面我将以书中T = “abaabcac”举例来说明。

T的子串前缀后缀最大公共前后缀
a0
abab0
abaa,aba,ba1
abaaa,ab,abaa,aa,baa1
abaaba,ab,aba,abaab,ab,aab,baab2
abaabca,ab,aba,abaa,abaabc,bc,abc,aabc,baabc0
abaabcaa,ab,aba,abaa,abaab,abaabca,ca,bca,abca,aabca,baabca1
abaabcaca,ab,aba,abaa,abaab,abaabc,abaabcac,ac,cac,bcac,abcac,aabcac,baabcac0

所以可以得出下表

字符abaabcac
最大公共前后缀00112010

为什么书中的其他情况是1呢?

这是因为在教材中的字符串的下标是从1开始的,而这里是从0为下标开始的。

知道什么是最大公共前后缀后,接下来就是next[j]数组的介绍

若令next[j] = k,则next[j]表明当前模式中第 j+1 (下标从0开始)个字符与主串中相应字符“匹配失败时”,在模式中需要重新和主串中该字符比较的位置。这就是KMP算法的核心。

那我们应该如何得到next[j]呢?

只需要将最大公共前后缀表中的最大公共前后缀向右移动一位即可。因为当此时 j 匹配失败时,需要前一个字符串的最大公共前后缀。

next[j]
字符abaabcac
next[j]-10011201

下面是对KMP过程的模拟

设T = ‘’abaabcac" , S = "acabaabaabcacaabc",当S[i] == T[j]或者 j == -1时,i++,j++。否则 j = next[j]

一: 主串:acabaabaabcacaabc

​ 模式: abaabcac

​ 在 i = 1,j = 1 匹配失败,j = next[1] = 0

二:主串:acabaabaabcacaabc

​ 模式: abaabcac

​ 在 i = 1,j = 0匹配失败,j = 0 , i = 2

三:主串:acabaabaabcacaabc

​ 模式: abaabcac

​ 在i = 7,j = 5匹配失败, i = 7,j = next[5] = 2

四:主串:acabaabaabcacaabc

​ 模式: abaabcac 此时i=5

​ 匹配成功。

代码如下:
int KMP(string S,string T){
	int len_S = S.length();//S的长度 
	int len_T = T.length();//T的长度 
	int i = 0;//S的下标 
	int j = 0;//T的下标 
	while(i<len_S && j<len_T){//不超出S和T长度且下标从0开始
		if(S[i]==T[j]|| j == -1){
			i++;
			j++;
		}else{
			j = next[j]
		}
	}
	if(j>=len_T) return i - len_T;//返回T在S中的第一元素的坐标 
	else return false;//没有找到 
} 
那么为什么可以使用next[j]来进行模式匹配呢?

当前匹配失败时,我们可以查看前一个字符串的公共最大前后缀来确定向右移动的位置,因为在前一个字符串已经与S匹配了,而我们又有相同的前后缀长度,则可以直接将前缀移动到后缀的位置,之后从下一个字符开始匹配,这样就可达到向右尽可能地移动。

比如:[image.png

next[j]的求法

next[j] = k 表明有以下关系:p0p1...pk1=pjk...pj1'p_0p_1...p_{k-1}=p_{j-k}...p_{j-1}'

(1):当pk=pjp_k =p_j时,next[j+1] = k+1

(2):若pkpjp_k\neq p_j时,说明当前字符串中没有长度k+1的最大公共前后缀,这样我们就只有找更短的前后缀了。

​ 此时我们将求next的值看作为一个求模式匹配的问题,整个模式串既是主串又是模式串,在匹配的过程中,已有p0=pjk,...,pk1=pj1p_0=p_{j-k},...,p_{k-1}=p_{j-1},则当pkpjp_k \neq p_j时模式串应移动到next[k]个字符和主串中的第j个字符相比较,若pnext[k]=pjp_{next[k]=p_j},则说明在j+1之前存在一个更短的最大公共前后缀,令 k=next[k]k^{'} = next[k],则p0p1...pk1=pjk...pj1'p_0p_1...p_{k^{'}-1}=p_{j-k^{'}}...p_{j-1}',则next[j+1]=k+1next[j+1] = k^{'}+1,即next[j+1] = next[k] + 1。若找不到,则next[j+1] = 0。

代码如下

void get_next(string T,int next[]){
  int i = 0;
  int j = -1;	//next的值
  next[0] = -1;//初始化next[0] = -1
  while(i<T.length()-1){
  	if(j==-1||T[i]==T[j]){//T[i]表示后缀,T[j]表示前缀	
  		i++;
  		j++;
  		next[i] = j;
  	}else{
  		j = next[j];//不相等时,寻找更短的最大公共前后缀。
  	}
  }
} 
next[j]的改进

当出现S = “a a b a a a a b” , T = " a a a a b" 时,当 i = 3, j = 3 时,匹配失败,但是此时的T中的a匹配,但是当j = next[3] 时,T[j]还是等于 a ,这样就产生了重复比较,这里就是对next的改进的地方。设next[j] = k,如果T[i] == T[j] ,则不在与T[k]比比较,而是直接与T[next[k]]比较,就是说 next[j] = next[k],所以代码如下:

void get_nextval(string T,int next[]){
  int i = 0;
  int j = -1;	//next的值
  nextval[0] = -1;//初始化nextval[0] = -1
  while(i<T.length()-1){
  	if(j==-1||T[i]==T[j]){//T[i]表示后缀,T[j]表示前缀	
  		i++;
  		j++;
  		if(T[i]!=T[j]) nextval[i] = j;
  		else nextval[i] = nextval[j];//不相等时
  	}else{
  		j = next[j];
  	}
  }
} 
以上就是我对KMP算法的理解

完整代码

#include<iostream>
#include<cstring>
using namespace std;
string S = "acabaabaabcacaabc";
string T = "abaabcac";
int nextval[100];
int KMP(string S,string T){
	int len_S = S.length();//S的长度 
	int len_T = T.length();//T的长度 
	int i = 0;//S的下标 
	int j = 0;//T的下标 
	while(i<len_S && j<len_T){//不超出S和T长度且下标从0开始
		if(S[i]==T[j]|| j == -1){
			i++;
			j++;
		}else{
			j = nextval[j];
		}
	}
	if(j>=len_T) return i - len_T;//返回T在S中的第一元素的坐标 
	else return false;//没有找到 
} 
void get_nextval(string T,int next[]){
	int i = 0;
	int j = -1;	//next的值
	nextval[0] = -1;//初始化nextval[0] = -1
	while(i<T.length()-1){
		if(j==-1||T[i]==T[j]){//T[i]表示后缀,T[j]表示前缀	
			i++;
			j++;
			if(T[i]!=T[j]) nextval[i] = j;
			else nextval[i] = nextval[j];
		}else{
			j = next[j];
		}
	}
} 
int main(void){
	get_nextval(T,nextval);
	cout<<"T在S中的第"<<KMP(S,T)+1<<"号位置上";
	return 0;
}