字符串匹配——KMP算法【C语言】

本文详细介绍了KMP算法的原理,包括暴力算法、KMP算法的优化思路、Next数组的计算方法和代码实现,以及NextValue数组的优化。通过KMP算法,可以减少模式串与主串的匹配次数,提高字符串匹配效率。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

KMP算法是一种改进的字符串匹配算法,由D.E.Knuth,J.H.Morris和V.R.Pratt提出的,因此人们称它为克努特—莫里斯—普拉特操作(简称KMP算法)。KMP算法的核心是利用匹配失败后的信息,尽量减少模式串与主串的匹配次数以达到快速匹配的目的。具体实现就是通过一个next()函数实现,函数本身包含了模式串的局部匹配信息。KMP算法的时间复杂度O(m+n)


一、暴力算法

int BF(char *chang,char *duan)
{
    int c_strlen=strlen(chang);
    int d_strlen=strlen(duan);
    int c=0,d=0;
    while(c<c_strlen && d<d_strlen){
        if(chang[c]==duan[d]){
            c++;
            d++;
        }
        else{
            c = c - d + 1; //这里要回到开始处,然后下一个字符继续开始匹配所以+1; 
            d = 0;
        }
    }
    return d<d_strlen?-1:c-d;
}

        其特点,使用两个指针,一个指向主串,一个指向子串。 

        如果匹配成功,两个指针往后走一位,继续匹配。 

         如果匹配失败,主串指针回溯到开始匹配的下一个字符,子串指针回溯到开始的字符。

         

         直到字符串匹配成功,返回开始匹配处的主串下标。

         这种算法,其时间复杂度是 O(m*n)

二、KMP算法

        使用BF算法的时候,会发现如果失配,两个指针都要回溯。KMP算法的思想,就是只回溯一个子串指针,主串指针不变。这样KMP算法的时间复杂度就是O(n+m)。

 子串指针回溯:

        既然知道KMP算法只回溯子串指针,那么如何回溯子串指针呢?这里通过例子讲解。

PS:KMP算法一种子串类型是不足以解释所有的情况,所有下面会使用多种类型的子串进行解析

         

        上图,假设已经匹配到了"流"处,失配进行指针回溯,注意发现。子串部分字符串前面和后面是相同的。  

 

        既然前面部分相同,又和主串相匹配。那么这里使用KMP,就能让子串指针直接跳过,已经匹配过的相同的字符,直接从子串的第三个位置"楼"处匹配。 

        

         

        那么问题就来了,子串如何知道,要回溯到"楼"处匹配呢? 

        这里就需要计算Next[]数组。Next[]数组中存储的就是,子串对应下标位置失配后,指针回溯的位置

计算Next[]数组和原理:  

         计算Next[]数组,主要是计算子串一个下标位置,前面最长相同字符串长度。其也叫真前缀和真后缀。

PS:注意这里判断真前后缀时是否相等时,都要以Str[0]处的字符开始,以Str[i-1]处的字符为结束。这里i是匹配失败的位置,也是Next[]的下标。
          

        首先前两个字符是没有前缀和后缀的,所以Next[]数组,第一位和第二位都为固定为0。

        以上面为例子,也就是说真前缀真后缀,都要以a开始b结束。然后其长度,就是Next[i]的值。那么如果子串在i下标处失配,子串指针就回退到Str[2]处,Str[2]就刚好是c。

        知道其计算方法后,就可以直接计算出子串的Next[]数组。

特点:如果数组成增长趋势,那么后一个必定只比前一个大1。也就是说增长的话是+1。但是下降的话就不一定只减一个。用这个特点也可以帮助快速计算Next[]数组,和判断其计算的正确性。

         

        根据上面两个例子,可以发现计算Next[]数组时,其真前缀和真后缀是可以重合的。  (但是不能完全重合!!)

        介绍到这里起始可以写出KMP的查找子串的代码了,发现跟BF算法非常相似。只不过字符串失配的时候,子串指针回溯到对应下标的Next[]值,这个是KMP算法的核心。

int KMP(char *chang,char *duan) //duan:长串 duan:短串
{
    int c_strlen=strlen(chang);
    int d_strlen=strlen(duan);
    int c=0,d=0;
    while(c<c_strlen && d<d_strlen){
        if(chang[c]==duan[d]){
            c++;
            d++;
        }
        else{
            d = Next[d]; //失配,指针回退到对应Next[]下标元素。
        }
    }
    return d<d_strlen?-1:c-d;
}
//问题代码

PS:代码有个小问题,如果刚开始就比较错误,也就是说 chang[0] != duan[0] 的时候,0下标处的Next[]还是0。会进入死循环。可以写个if()判断,如果刚开始就失配,让主串往后走一位。

int KMP(char *chang,char *duan)
{
    int *next=getNext(duan);
    
    int c_strlen=strlen(chang);
    int d_strlen=strlen(duan);
    int c=0,d=0;
    while(c<c_strlen && d<d_strlen){
        
    	if(c=0 && chang[c]!=duan[d])
            c++;
        if(chang[c]==duan[d]){
            c++;
            d++;
        }
        else{
            d = Next[d]; //失配,指针回退到对应Next[]下标元素,存储的位置。
        }
    }
    return d<d_strlen?-1:c-d;
}
//修正

        但是创建KMP算法的大佬用了更妙的写法。  

        他们把Next[]数组的第0项赋值为 -1。如果子串第一个字符就不匹配,那么d==-1,然后判断如果d是-1,就会进去匹配的分支,这样的话c++,主串指针往后走一个。d++后,子串指针还是指向了0位置。

        会发现这样既保证了0下标位置失配后,下一次匹配,从主串的下一个字符继续匹配,又保证了子串回溯到了最开始的位置。

int KMP(char *chang,char *duan)
{
    int *next=getNext(duan);
    
    int c_strlen=strlen(chang);
    int d_strlen=strlen(duan);
    int c=0,d=0;
    while(c<c_strlen && d<d_strlen){

        if( d==-1 || chang[c]==duan[d] ){ //匹配
            c++;
            d++;
        }
        else{
            d = Next[d]; //失配,指针回退到对应Next[]下标元素,存储的位置。
        }
    }
    return d<d_strlen?-1:c-d;
}
//大佬写法

        所以Next[]数组,第一项固定为-1,第二项固定为0。

计算Next[]数组,代码实现:

        使用口算,会发现计算Next[]数组还是比较简单。但是怎么把它实现为代码呢?其实Next[]也是具有一定规律的。

         观察这个子串,假设 i 下标 处失配,那么其指针回溯到Str[3]处。设其回溯后的下标为k。

         因为Next[]里面存的是指针回溯的下标,那么就有Next[i]==k

        那么既然Next[i]==K,那就说明 i下标 处有个最大真前缀和最大真后缀相等。

        这里就是"望 江 望"。因为这里的Next[i]就是根据"望 江 望"的长度得出来的。前面说过找真前缀真后缀时,都要以Str[0]字符开始,Str[i-1]字符结束。

        那么就说明 Str[0]...Str[k-1]==Str[x]...Str[i-1]。(这里真前缀起始位置固定为0,真后缀位置设为未知数X)

        所以得出,如果 Next[i]==k,那么 Str[0]...Str[k-1]==Str[x]...Str[i-1];

        因为真前缀和真后缀的长度相等,所以 k-1-0==i-1-x (这里使用下标,就能算出来真前缀和真后缀的长度)

        整理一下可以得出 k = i - x 移项得 x = i - k 

        根据 x = i - k 又可以得出 Str[0]...Str[k-1]==Str[i-k]...Str[i-1];(套入上面的公式) 

        再来看子串,因为这里 Str[i]==Str[k] ,所以i+1处的真前缀和真后缀就是 "望 江 望 江",也就是 Str[0]...Str[k]==Str[i-k]...Str[i](这里这里仔细思考会发现,i+1处的Next[]值其实也是i的Next[]值加1。)

观察两个公式:

                 Next[i]==k; Str[0]...Str[k-1] == Str[i-k]...Str[i-1];

                 Str[i]==Str[k]; Str[0]...Str[k] == Str[i-k]...Str[i];(这个公式,比上面多1)

        那么就可以得出,如果Str[i]==Str[k],那么:Next[i+1]=k+1;

那么如果 Str[i] != Str[k] 的情况呢?

       不相等的话就,就让k回溯,回溯到下标位置2

        发现还是,Str[k]!=Str[i];k继续回溯。直到回溯到 Str[i]==Str[k];让 Next[i+1]==k+1成立

 

        当然这里回溯到最开始后,还是没有找到Str[i]==Str[k]; 那么Next[i+1]==0;i下一个Next[]数组元素就是0了

        假设这里k回溯到最开始后,Str[i]==Str[k],Next[i+1]==k+1成立,这里Next[i+1]==1。 

到此步,所有的情况都考虑到了。可以写出计算Next[]数组的代码:

int *getNext(char *str)
{
    int len=strlen(str);
    int *next[]=(int *)calloc(len,sizeof(int));
    next[0]=-1;
    next[1]=0;
    int i=2; //主串指针
    int k=0; //回溯位置
    while( i < len ){
        if( k==-1 || str[i-1]==str[k] ){    //这里有多种写法
            next[i]=k+1;   
            i++;
            k++;
        }
        else{
            k=next[k]; 
        }
    }
    return next;
}

 PS:因为这两Next[]数组里面,前两个数是固定的,所以直接从第三个开始计算。(其实会发现,第三项元素如果前面有真前缀和真后缀,其也是固定为1的,且第一个字符和第二个字符是相等的)

Next[]数组的另一种理解方法:

        求Next[]数组,相当于子串自己匹配自己。

         自己跟自己匹配,i 处匹配,所以说明前面也有一个是"望"。这样匹配,是不是就相当于 i+1找到一个真前缀和真后缀啊。那么i+1处的Next[]数组元素就是1。Next[i+1]==1。

        继续往下匹配。

        这里再次匹配的情况下,也是就是 str[i]==str[k] ,相当于真前缀和真后缀又加了一个相同的字符,真前缀和真后缀+1。因为Next[i] 除了是指针回溯的位置,也是代表的 i 处真前缀和真后缀长度度,所以 Next[i+1]==Next[i]+1 ; 

        但是又因为Next[]数组里面存的值是代表子串指针要回溯的下标位置,也就是Next[i]==k,那么也可以得出。Next[i+1]==k+1(套入上面公式)

        那么一直计算比较,可以得出一部分的Next[]数组。然后如下图

        到这里发生了失配。如果这里 i 匹配的话,Next[i+1]==5。但是这里失配,所以说Next[i+1]首先不可能等于5了。

        因为相当于在做字符串匹配,上面是主串,下面是子串。那么发生失配,指针回溯。回溯到哪里呢,会发现!!这里回溯的就是子串对应下标Next[]存的值,Next[i]==4。回溯到下标位置为 4  的 "楼"处,也就是 k == Next[k];

 

        

        发现还是失配str[i]!=str[k],指针继续回溯 ,k==Next[k];  

        失配进入if() 的失配分支,但是因为指针回溯了初始位置,因为Next[0]==-1,所以k==-1。会发现这里非常妙,k==-1,进入的是if()的匹配分支,然后i++,k++。主串、子串指针都往后走一位,Next[i]=k;k==0,Next[i]==0;

        或者Next[i+1]=k+1;前面 i 和 k,加过了所以不用加。

代码:

int * getNext(char* str)
{
    int s_len = strlen(str);
    int *next = (int *) calloc (s_len,sizeof(int));
    next[0] = -1; 
	int i=0,k=-1;   //k是子串指针,i是主串指针。主串指针赋值-1,是为了从下一个位置开始匹配
    while(i<s_len-1){	//因为都从0开始的话,自己匹配自己就全匹配完了,就计算不了数组元素了
        if(k==-1||str[i]==str[k]){
            i++;
            k++;
            Next[i]=k; //前面加过了不用加了
        }
        else{
            k=Next[k];
        }
    }
    return next;
}

PS:写法跟前面差不多一样。因为是相当于字符串匹配,又因为Next[]第一个元素是固定的不用计算。所以主串指针要比子串指针快一步。

        相当于刚开始如上图这样比较,然后比一个字符,计算一个Next[]的值。当然第一个值为-1,第二个值为0,是固定的。

int * getNext(char* str)
{
    int s_len = strlen(str);
    int *next = (int *) calloc (s_len,sizeof(char));
    next[0] = -1;
	int i=0,k=-1; //k是主串指针,i是主串指针
    while(i<s_len-1){
        if(k==-1||str[i]==str[k]){
        	Next[i+1]=k+1;	//为了方便理解也可以这样写。
            i++;
            k++;     
        }
        else{
            k=Next[k];
        }
    }
    return next;
}

PS: 

三、NextValue

        其实Next[]还可以优化。先来观察一个子串。

 

         假设在 i 处失配,指针回溯。会发现一个问题,回溯会回到下标6处,然后6下标的字符因为也是a,跟原来一样所以肯定也是不匹配的,就会继续回溯,5 4 3 2 1 0,会发现指针一直会回溯到0处。

        然后会发现,前面不管前面哪个a发生失配,指针其实都会回溯到0处。那么其实可以跳过中间一步步回溯的步骤,使指针一步到位,一次回溯到最开始处。就是当Str[i]==Str[k](现在指针指向的字符,与回溯到的字符相等时),Next[i]==Next[k],这样就重新获得了一个NextValue[]数组。

        如果Str[i]!=Str[k]的话,其还是Next[]的值。

        再来看一个子串

如果 i   处失配,且 Str[3]==Str[0];所以Nextval[3]==Nextval[0];  

如果  i  在5下标位置失配,因为Str[5]!=Str[2];所以Nextval[5]==2;

PS:实际计算NextValue[]数组的时候要注意要顺序计算,因为前面的数据覆盖了后面的数据时,再后面也要使用被覆盖的数据。什么意思呢?

        这里顺序计算一个NextValue[], 

        按顺序以次往下计算。

        这里会使用前面被覆盖为0的值,自己也被覆盖为0,而不是原来的1。所以要按照顺序计算。 

 

         使用Nextvalue[]和Next[]数组结果是一样的,Nextvalue[]是优化版本。其本质就是 

 

代码:

#include <iostream>
#include<stdio.h>

int* getNext(char *str)
{
	int len = strlen(str);
	int* next = (int*)calloc(len, sizeof(int));
	next[0] = -1;
	int i = 0, k = -1;

	while ( i < len-1) { //这里注意-1

		if (k==-1||str[i]==str[k]) {
			i++;
			k++;
			next[i] = (str[i] == str[k] ? next[k] : k);
		}
		else {
			k = next[k];
		}
	}

	return next;
}


int KMP(char* chang,char* duan)
{

	int* next = getNext(duan);
	int c_len = strlen(chang);
	int d_len = strlen(duan);
	int i = 0, k = 0;

	while (i<c_len && k<d_len) {

		if (k == -1 || chang[i] == duan[k]) {
			i++;
			k++;
		}
		else {
			k = next[k];
		}
	}

	return k < d_len ? -1 : i - k;
}

int main()
{

	char str1[] = "wjl,wjn,wjlswjn,jlqg,jnqg";
	char str2[] = "wjlswjn";
	printf("在位置数组下标位置 %d 处找到字符串\n", KMP(str1,str2));
	return 0;

}

PS:再次注意,如果next[i]=k,这写在i++,k++后面。如果写在前面用next[i+1]=k+1。

然后i<s_len-1,注意这里是-1。拿这里的代码来说,str2的长度为7,如果用的是i<s_len,计算6位置的时候,i++了一次,就成了next[7],数组过界了。如果这里使用i<s_len,i=5,i++了一次变成了6,next[6]就是计算最后一个元素了,刚好全算完。

测试: 

PS:仔细观察getNext()函数和KMP()函数,会发现非常相似。侧面也说明了获取Next[]数组的过程,相当于子串自己跟自己做串匹配。


总结:

        如果明白了KMP算法的话,其实就知道,其无非失配时,跳过前面已经匹配成功过的真前缀。那么就能知道,如果一个子串的重复字符越多,其算法使用效率越高,不然没有重复字符或者重复字符少,会退化成BF算法。