详细解析字符串模式匹配KMP算法-c语言-基于暴力匹配算法的改良-从部分匹配值到移位数组-两种代码实现方式(约6000字,附图)

前言

KMP算法是什么?
作为一个学习计算机或者从事计算机工作的人来说,数据结构与算法几乎是我们必须要了解甚至精通的学科,而学数据结构与算法时总有几个难点,KMP算法就是其中一个,这里我按照我的思路给大家详细讲一下。
KMP算法是一个性能优秀的字符串模式匹配算法,这里就有一点铺垫的概念要解释一下了:

  1. KMP算法主要用于在一个长字符串中搜索一个短字符串的位置,一般我们把这个要搜索的短字符串称为模式串搜索的过程称为匹配,因此字符串的子串搜索就称为模式匹配
  2. Knuth-Morris-Pratt算法(简称KMP),主要以同时发现这个算法3个人的名字来命名,所以大家只要知道它叫KMP,并且用来做字符串的子串搜索就好了。

1.KMP算法的意义——暴力匹配算法的原理和弊端

1.1暴力匹配算法的原理

在一个字符串中搜索一个子串的位置,最简单的算法就是暴力匹配算法(Brute-Force,BF算法),暴力匹配算法的原理比较简单,就是循环把字符串和模式串进行逐一比较,不过暴力匹配算法的效率十分低下,所以就出现了效率更高的KMP算法,这里先给大家解释一下暴力匹配算法。
附注:暴力匹配算法有很多种实现方式,不过原理都是一样的,许多书本和网上的文章对于暴力匹配算法的解释是在同一个循环中用i和j分别代表长短两个字符串的匹配的位置,当字符失配时,i回退,j归零,这里讲的可能有点抽象,总之你们要知道暴力匹配算法有时候实现方式不同但是原理是一样的就好了,这里给出的是我认为的比较好理解的实现方式,不过这个如果你们懂了的话应该看别的实现方式也是看得懂的。

因为暴力匹配算法比较简单,这里我就直接上代码了,后面再解释:

#include 
//获取字符串长度函数
int length(char *s){
	int i=0;
	if(s!=NULL) while(s[i]) i++;
	return i;
} 
//暴力匹配算法 Brute-Force简称BF算法
int indexOf(char *lstr,char *str){
	int i,j,m,n;
	//1.获取两个字符串的长度
	m = length(lstr);
	n = length(str);
	if(lstr==NULL||str==NULL||m==0||n==0) return -1;//两个字符串都不能为NULL 
	//2.如果字符串短于模式串则不用进行搜索,直接return -1; 
	if(m>=n){
		for(i=0;i<=m-n;i++){//外循环从0到m-n 
			for(j=0;j

暴力匹配算法的执行过程是这样的:
假设在字符串”abdabc”中搜索子串”abc”,用m和n分别代表字符串的长度和子串的长度,这里的m=6,n=3;我们把外循环临时变量i的值从0循环到m-n,每一轮循环都把长字符串中编号为i到i+n-1的n个字符与短字符串中的n个字符进行比较。(内循环的逻辑比较简单就不解释了)。
下面模拟一下循环过程:

  1. 当i=0时,把长字符串的0到2位和短字符串的0到2位进行比较,如果完全匹配的话就提前结束循环,并返回i的值。
    详细解析字符串模式匹配KMP算法-c语言-基于暴力匹配算法的改良-从部分匹配值到移位数组-两种代码实现方式(约6000字,附图)_第1张图片
  2. 很显然,上面i=0时,字符串是不匹配的,所以循环继续,i自增,即i=1,再匹配
    详细解析字符串模式匹配KMP算法-c语言-基于暴力匹配算法的改良-从部分匹配值到移位数组-两种代码实现方式(约6000字,附图)_第2张图片
  3. 上面i=1时字符串也是不匹配的,所以我们就这样把i一位一位的往后移,直到匹配的子串或者i已经走到了m-n就停止循环。
    …(省略i=2的情况)
    详细解析字符串模式匹配KMP算法-c语言-基于暴力匹配算法的改良-从部分匹配值到移位数组-两种代码实现方式(约6000字,附图)_第3张图片
    当匹配到子串时,函数则会返回此时i的值,如果没有匹配到子串,函数返回-1。

1.2暴力匹配算法的弊端

暴力匹配算法之所以效率低下,主要是因为循环过程中进行了很多不必要的匹配,例如:
下面的一个例子,在一个字符串中搜索子串ABCDABD。当有一处外循环i的值能够与子串的前六个都匹配,但唯独在子串的最后一个位D不匹配,那么我们在外循环的下一次移位时,就没有必要再一位一位的移,而是可以利用已经匹配的字符串信息计算出移位数,例如下面这样,在最后一位D位失配之后我们可以直接把比较位置挪成这样:
(下面这张图片来自另外一篇博客kornberg_fresnel KMP算法到底在干什么)
详细解析字符串模式匹配KMP算法-c语言-基于暴力匹配算法的改良-从部分匹配值到移位数组-两种代码实现方式(约6000字,附图)_第4张图片
上面的解释看不懂就算了,随便看看就好了,总是你们要知道,KMP算法就是在字符串的某一位失配时,根据失配的字符前的已匹配信息来计算出下一轮比较的开始位置,从而避免不必要的匹配,提高算法效率。(例如在短字符串的最后一位D失配时,那么说明前面的”ABCDAB”都是完全匹配了的,根据这个信息,计算出下一轮比较的位置),具体怎么做呢,下面我们就来看看。

2.KMP算法的原理

之前我们说过,KMP算法就在在字符串的某一位失配时,根据失配的字符前的已匹配信息计算出下一轮比较的位置。所以KMP算法的核心就在于对已匹配信息的分析利用和失配之后的移位操作。想要计算移位数首先我们得学习几个新概念。

2.1部分匹配表(Partial Match Table,又称PMT表)

部分匹配表即一个字符串中每一个位对应的部分匹配值的集合,例如下面是一个字符串对应的PMT表,大家先大概看一下:

字符(char) a b c d a b c
下标(index) 0 1 2 3 4 5 6
部分匹配值(value) 0 0 0 0 1 2 3

那么这些部分匹配值是怎么来的呢?

2.1.1部分匹配值的计算

概念:部分匹配值是一个字符串中前缀集合和后缀集合交集元素中最长的元素的长度。
铺垫知识:字符串的前缀后缀
定义:把一个字符串分割成非空的两个部分,前面的就叫前缀,后面的就叫后缀。如:字符串”abc”,划分为非空的两个部分可以是前缀”a”和后缀”bc”,也可以是前缀”ab”和后缀”c”。那么前缀集合就是{“a”,”ab”},后缀集合就是{”bc”,”c”}。
上面这个例子中,字符串”abc”的前缀集合和后缀集合没有交集,所以字符串”abc”的部分匹配值就是0。
所以,部分匹配表就是把字符串中的每一位和它前面的字符当成一个新的字符串然后计算出部分匹配值。
例如上面的字符串”abcdabc”的第三个字符c的部分匹配值就是子串”abc”的部分匹配值,也就是0,所以得到字符串中下标为2的部分匹配值为0。
同理,”abcdabc”中第5位a的部分匹配值就是子串”abcda”的部分匹配值,从而下标为4的部分匹配值就是1。

2.2KMP算法对部分匹配表的使用

KMP算法核心就是对部分匹配表的使用,之前我们说过,KMP算法就是在字符失配时根据已匹配的信息计算出下一轮比较的位置。那么这个已匹配的信息和下一轮比较的位置具体都是啥呢?我们就来看看。

2.2.1 已匹配的信息

例如一个长字符串(这里先不管它是啥)中搜索一个短字符串”ABCDABD”,当匹配过程在短字符串的第6位B处失配时,那么已匹配的信息就是前面的5个字符,也就是说”ABCDA”这五个字符是已经被匹配成功的。如图:
详细解析字符串模式匹配KMP算法-c语言-基于暴力匹配算法的改良-从部分匹配值到移位数组-两种代码实现方式(约6000字,附图)_第5张图片

2.2.2移位数的计算

KMP算法中,移位数的计算是和部分匹配表是相关的,字符失配是移位操作具体可以分为:

  1. 当在短字符串的第一位就失配时,让长字符串和下一位和短字符串的第一位开始下一轮匹配。
  2. 当已匹配信息不为空时,让长字符串失配的位置和短字符串已匹配信息的部分匹配值的对应位置开始下一轮匹配。
    如图1.1:
    详细解析字符串模式匹配KMP算法-c语言-基于暴力匹配算法的改良-从部分匹配值到移位数组-两种代码实现方式(约6000字,附图)_第6张图片
    图1.2 在发生图1.1的失配情况时,下一轮比较的开始情况如下:(即当失配时,前面没有已匹配的字符串信息时,外循环的处理和暴力匹配算法一致)
    详细解析字符串模式匹配KMP算法-c语言-基于暴力匹配算法的改良-从部分匹配值到移位数组-两种代码实现方式(约6000字,附图)_第7张图片
    图2.1,如果已匹配信息不为空
    详细解析字符串模式匹配KMP算法-c语言-基于暴力匹配算法的改良-从部分匹配值到移位数组-两种代码实现方式(约6000字,附图)_第8张图片
    图2.2 在发生图2.1的失配情况时,下一轮比较的开始情况如下:
    详细解析字符串模式匹配KMP算法-c语言-基于暴力匹配算法的改良-从部分匹配值到移位数组-两种代码实现方式(约6000字,附图)_第9张图片
    通过总结我们可以发现移位数next和部分匹配表PMT的关系如下:
    详细解析字符串模式匹配KMP算法-c语言-基于暴力匹配算法的改良-从部分匹配值到移位数组-两种代码实现方式(约6000字,附图)_第10张图片
    从上面这个表我们可以看出来,除了字符串下标为0的那一位之外,其他每一位的移位数就是前面字符组成的子串的部分匹配值。而下标为0的位的移位数必须是一个作为标志的数(即一个不可能是部分匹配值的数,如负数或者大于字符串长度的数,这里用-1代表部分匹配值不存在的,好处是-1自增之后就变成了0)
    讲解了这么多之后,那么KMP算法也可以说成是 对移位数next数组的求解 + 匹配过程中根据移位数组next来调整匹配位置 的一种算法。接下来就是代码实现啦

3.KMP算法的代码实现

KMP算法的代码实现主要分为两个部分,即:

  1. 把要搜索的短字符串通过部分匹配计算得出移位数组next,(移位数一般用int型数组存储,数组长度和字符串长度相同,名称习惯性命名为next,也有的人喜欢命名成F或者其他的,这就用next了)
  2. 把暴力匹配算法进行改良,根据next数组来确定失配后下一轮比较的开始位置。

3.1移位数next数组求解的简单代码实现

我们已经知道了,移位数组next的值就是字符位前的所有字符组成的子串的对应的部分匹配值,而部分匹配值就是前缀集合和后缀集合交集中最长元素的长度,那么具体要怎么算呢?有一个笨办法就是循环遍历这个字符串,每一轮都去计算字符位前所有字符的子串的前缀集合和后缀集合交集的最长元素,代码实现如下:

//这是一个笨办法,只是为了简单的实现这个算法逻辑,算法效率非常低
#include 
#include  
//铺垫函数 求字符串长度 
int length(char *s){
	int i=0;
	if(s!=NULL) while(s[i]) i++;
	return i;
}
//铺垫函数 求一个字符串的前缀和后缀交集元素的长度 
int nextNum(char *str,int start,int l){
	int len = length(str),i,j;
	if(l<=0||start<0||start>=len) return -1;//不存在时返回-1
	for(i=start+l-2;i>start;i--){
		for(j=start;j

上面这个的算法逻辑是比较简单的,我把上面代码中的nextNum函数稍微讲解一下:
nextNum函数是求解一个字符串前后缀最长交集元素的长度(部分匹配值)的函数。nextNum函数原型是int nextNum(char *str,int start,int l);
即传入一个字符串str,把start到start+l(不含start+l)位上的字符当成一个新字符串来求解部分匹配值。算法的步骤是:

  1. 如果传入的start或者l不合理则返回-1。(用-1代表部分匹配值不存在是比较常见的作法)
  2. 进行循环比较,图示如下,例如传入一个字符串”abab”,循环的第一轮会把前缀”aba”和后缀”bab”进行比较,如果完全匹配则返回长度3,否则进行下一轮,把前缀”ab”和后缀”ab”进行比较,如果完全匹配则返回长度2,否则进行下一轮,把前缀”a”和后缀”b”进行比较。当前后缀没有交集元素时则返回0.
    (即外层循环的i是假设存在的最大部分匹配值,内层循环对长度为i的前后缀进行比较)

3.2移位数next数组求解的改良(提高效率)

前面我们已经了解了next数组求解的一个笨办法,但是写出来的代码效率非常低的,我们进行了很多重复的比较。例如在求一个字符串”abab”的next数组时,上面getNext函数的操作是:

  1. 先申请一块长度为4的int数组内存(字符串”abab”的长度为4)
  2. 将next[0]赋值为-1(-1是一个标志,标志着部分匹配值不存在)
  3. 求出子串”a”的部分匹配值存入next[1]
  4. 求出子串”ab”的部分匹配值存入next[2]
  5. 求出子串”aba”的部分匹配值存入next[3]
  6. 求出子串”abab”的部分匹配值存入next[4]
  7. 返回next数组
    在上面这些操作步骤中,3到6步骤是由循环衍生出来的,我们调用了nextNum这个函数分别计算了子串”a”、“ab”、“aba”的部分匹配值,然后存入next数组中。
    实际上,上面这些子串之间是存在联系的,例如”a”刚好是”ab”的最长前缀,而”ab”刚好是”aba”的最长前缀,”aba”刚好是”abab”的最长前缀。所以当我们已经计算出子串”aba”的部分匹配值时,那么在计算”abab”的部分匹配值时就可以利用它的最长前缀的部分匹配值来简化算法,如最长前缀”aba”的部分匹配值是1,那么这里只要把子串”abab”的第2位(字符串下标位1,即下标为最长前缀的next值)和最后一位进行比较即可,如果相同的话,则么子串”abab”的部分匹配值就是最长前缀”aba”的部分匹配值再加1,如果不相同的话,则进行回退,将子串”abab”第1位(下标为0)和最后一位进行比较,…。
    写出来的代码是这样子的:
#include 
#include  
//铺垫函数 求字符串长度 
int length(char *s){
	int i=0;
	if(s!=NULL) while(s[i]) i++;
	return i;
}
int *getNext(char * str){
	int len,*next;
	len = length(str);
	if(len<=0) return NULL;
	next = (int *)malloc(sizeof(int)*len);
	next[0] = -1;
	int i = 0, j = -1;
 
	while (i < len)
	{
		if (j == -1 || str[i] == str[j])
		{
			++i;
			++j;
			next[i] = j;
		}	
		else
			j = next[j];
	}
	return next;
} 
int main(){
	int *a,i;
	a = getNext("ababada");
	for(i=0;i<7;i++) printf("%d ",a[i]);
	return 0;
}

上面这个东西逻辑还是比较复杂的,但是算法效率很高,大家尽量看一看。
算法解析,以字符串str=”ababca”为例:

  1. 1.初始化,求的字符串长度len=7.创建一个int next[7]数组。用i表示字符串的下标,用j表示每一位下标的前面的字符串的部分匹配值。令next[0]=-1,因为第0位前没有字符,所以用-1表示部分匹配值不存在。(此时所求字符串下标为0,i的值未初始化,对应求子串“a”的移位数,而”a”没有前缀,前缀的部分匹配值不存在,给i赋值0,j赋值-1,从而得到next[0]=-1).
  2. 第1次循环,只要字符串存在第二位,那么它的部分匹配值一定为0,所以第一次循环执行时i=1; j=0; next[1]=0;(此时所求字符串下标为1,i的值为0,“ab”的最长前缀为”a”,”a”的部分匹配值为0,给i赋值为1,j赋值为0,从而得到next[1]=0).
  3. 3.第2次循环,所求字符串下标为2,i的值为1,对应子串”aba”,”aba”的最长前缀为”ab”,”ab”的最长前缀为”a”,”a”的部分匹配值为0,那就判断字符串的第i位和第j位是否相等,相等的话则”ab”的部分匹配值是”a”的部分匹配值加1,不相等的话则把j回退到next[j]进行比较。经过计算后,i的值最终变成2,j的值变成0,从而得到next[2]=0.
  4. 第3次循环,所求字符串下标为3,i的值为2,对应子串”abab”,”abab”的最长前缀为”aba”,”aba”的最长前缀为”ab”,”ab”的部分匹配值为0,判断第i位和第j位是否相等,相等则”aba”的部分匹配值是”ab”的部分匹配值加1,计算后,i的值变为3,j的值变为1,得到next[3]=1.
  5. 第4次循环,所求字符串下标为4,i的值为3,对应子串”ababc”,”ababc”的最长前缀为”abab”,”abab”的最长前缀为”aba”,”aba”的部分匹配值为1,判断第i位和第j位是否相等,相等则得出计算后i的值变为4,j的值变为2,得到next[4]=2(即算法中的next[i]=j).
  6. 第5次循环,所求字符串下标为5,i的值为4,对应字符串”ababca”,”ababca”的最长前缀为”ababc”,”ababc”的最长前缀为”abab”,”abab”的部分匹配值为2,先判断第i位和第j位是否相等,即判断str[4]是否等于str[2],得到”c”!=”a”,于是j回退到next[j] (即j=next[2]=0),再次判断str[i]是否等于str[j],即str[4]是否等于str[0],得到结果为不等,j回退到next[0],即-1,最后直接让i++,j++,得到i=5,j=0,next[5]=0;计算结束,返回移位数组[-1,0,0,1,2,0].

3.3KMP算法的完整代码实现

这里我就直接上代码啦,如果你们前面的都看懂了的话那么看这个应该也是没问题的。

#include 
#include  
//铺垫函数 求字符串长度 
int length(char *s){
	int i=0;
	if(s!=NULL) while(s[i]) i++;
	return i;
}
int *getNext(char * str){
	int len,*next;
	len = length(str);
	if(len<=0) return NULL;
	next = (int *)malloc(sizeof(int)*len);
	next[0] = -1;
	int i = 0, j = -1;
 
	while (i < len)
	{
		if (j == -1 || str[i] == str[j])
		{
			++i;
			++j;
			next[i] = j;
		}	
		else
			j = next[j];
	}
	return next;
} 
//KMP算法 
int indexOf(char *lstr,char *str){
	int i,j,m,n,*next;
	//1.获取两个字符串的长度
	m = length(lstr);
	n = length(str);
	if(lstr==NULL||str==NULL||m==0||n==0) return -1;//两个字符串都不能为NULL 
	//2.求next数组 
	next = getNext(str);
	//3.如果字符串短于模式串则不用进行搜索,直接return -1; 
	if(m>=n){
		i=0,j=0;
		while(i

4.附注:暴力匹配算法和kmp算法的另外一种实现方式。

暴力匹配算法求解思路:只用一个循环来处理比较的过程,用变量i存储长字符串的下标,用变量j存储短字符串的下标,然后进行比较,当字符位匹配时则进行下一个位的匹配直到短字符串尾,当发生失配情况时,变量j归0,变量i回退j-1个位置。代码如下:

#include 
//获取字符串长度函数
int length(char *s){
	int i=0;
	if(s!=NULL) while(s[i]) i++;
	return i;
} 
//暴力匹配算法 Brute-Force简称BF算法
int indexOf(char *lstr,char *str){
	int i,j,m,n;
	//1.获取两个字符串的长度
	m = length(lstr);
	n = length(str);
	if(lstr==NULL||str==NULL||m==0||n==0) return -1;//两个字符串都不能为NULL 
	//2.如果字符串短于模式串则不用进行搜索,直接return -1; 
	if(m>=n){
		i=0,j=0;
		while(i

KMP算法实现思路:KMP算法是基于暴力匹配算法的改良,所以这一版的kmp算法就是基于上面的暴力匹配算法做了一些改动,即当发生失配情况时,保持变量i不变,而j回退到next[j]的位置,代码如下:

#include 
#include  
//铺垫函数 求字符串长度 
int length(char *s){
	int i=0;
	if(s!=NULL) while(s[i]) i++;
	return i;
}
int *getNext(char * str){
	int len,*next;
	len = length(str);
	if(len<=0) return NULL;
	next = (int *)malloc(sizeof(int)*len);
	next[0] = -1;
	int i = 0, j = -1;
 
	while (i < len)
	{
		if (j == -1 || str[i] == str[j])
		{
			++i;
			++j;
			next[i] = j;
		}	
		else
			j = next[j];
	}
	return next;
} 
//KMP算法 
int indexOf(char *lstr,char *str){
	int i,j,m,n,*next;
	//1.获取两个字符串的长度
	m = length(lstr);
	n = length(str);
	if(lstr==NULL||str==NULL||m==0||n==0) return -1;//两个字符串都不能为NULL 
	//2.求next数组 
	next = getNext(str);
	//3.如果字符串短于模式串则不用进行搜索,直接return -1; 
	if(m>=n){
		for(i=0,j=0;i

至此,关于KMP算法,我的所学所思已经讲解的差不多啦,今天就写到这里吧,本文作者郑伟斌,写于2019/4/11,转载注明出处。

你可能感兴趣的:(数据结构与算法)