字符串模式匹配--KMP之美

本文深入探讨KMP算法,一种用于快速解决字符串模式匹配问题的算法。通过详细解析KMP算法的原理、优化过程以及核心部分next数组的求解方法,本文旨在帮助读者理解并掌握这一算法的核心思想及应用。

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

字符串模式匹配:

       给定字符串,要求在该字符串(主串)中找到所有匹配一个模式串的子串(一般是返回子串在字符串中的开头位置)。这里把问题简化一下--在该字符串中找到第一个匹配对应模式串的子串即可。要找出剩下的匹配子串,只需往后沿用相同的算法。

       问题进一步精炼:给定主串S、模式串P,主串S中的一个索引pos,要求使用一种算法,找到主串S的从索引pos开始的,第一个匹配模式串P的子串。如果能找到,则返回子串的开始位置在S中的索引。





朴素的模式匹配算法:

      在进入正题之前,先引入最朴素的模式匹配算法--从S的第pos个字符串起,与P的第一个字符进行比较。若相同,则继续往后比较。若碰到不相同的字符,则从S的第pos+1个字符串起,和P的第一个字符进行比较...... 直至有一个S的子串和P匹配成功或找不到这样的子串。算法用C++代码描述:


朴素模式匹配:

int find(string s , string p , int pos) {
	int lenS = s.length(), lenP = p.length();
	if(lenP > lenS-pos)
		return -1;	
	int i = pos, j = 0;
	while(i < lenS && j < lenP)
	{
		if(s[i] == p[j])
		{
			i ++;
			j ++;
		}
		else
		{
			i = i-j+1;
			j = 0;
		}
	}
	if(j == lenP)
		return i-lenP;
	return -1;
}

设串S的长度为n,串P的长度为m,则算法的时间复杂度为O(nm)


仔细观察,不知道你有没有发现这个算法运行过程中,有许多冗余的比较?假设匹配发展到如下情况:


索引:      0 1 2 3 4 5 6 7 8 9 

S:         a b a b c a b c a c 

P:               a b c a c

索引:          0 1 2 3 4

               情况1


这时,我们发现 S[6] != P[4]。按照上面的算法,下一步就是要重新将S[3] 和 P[0] 对准,重新进行比较。S的指针从 i = 6 回溯到 i = 3,如下所示:


索引:      0 1 2 3 4 5 6 7 8 9 

S:         a b a b c a b c a c 

P:                  a b c a c

索引:             0 1 2 3 4

               情况2

       其实,经过我们观察就可以发现,后续的比较 i = 3,j = 0 和 i = 4,j = 0 和 i = 5,j = 0 都是没有必要进行的。遇到情况1,我们直接将模式向右滑动3个字符,跳到情况3进行比较即可。此时,S 的指针仍然为 i = 6,不用回溯。

索引:      0 1 2 3 4 5 6 7 8 9 

S:         a b a b c a b c a c 

P:                        a ba c

索引:             0 1 2 3 4

               情况3


      以上说明,我们可以通过利用前面的比较过的信息,当匹配出现字符不等的时候,向后跳跃,不做无用的比较 ! KMP算法说,"让我们来一起跳跃吧 ~!"



 KMP算法:


前期分析:

       KMP算法,是用于解决模式匹配问题的快速算法,由D.E.Knuth、V.R.Pratt和J.J.Morris同时发现,也因此而得名。这个算法非常直观,推导过程简洁优美。

       讨论一般情况,设主串:,模式串:

       为改进算法,当匹配过程中产生失配()时,设主串S的第i个字符应与模式中的第k(k < j)个字符继续比较,则模式串P中的索引 k 前面长度为k-1的子串必与主串的指针 i 前面长度为 k-1 的子串匹配。则有:


 (1)


       回到失配的情况,虽然,但是主串S的指针 i 前面的长度为 k-1 的子串和模式串P的指针 j 前面的长度为 k-1 的子串必相匹配。则有:

(2)


由(1)、(2)可得:(3)


       至此,我们发现 k 的选择竟只和模式串P相关


       总结一下前面的信息,这个改进的算法中主串S的指针 i 无回溯,模式串P的指针 j 可能需要反复回溯。那么算法快速的关键就在于--i 无回溯,j 尽可能地少回溯当 k(k < j) 越大的时候,它离 j 越近,j 的回溯步长就越短。

因此,我们要找的 k 就是满足式(3)的最大 k,即  (4)


next 数组的定义:

       令next[ j ] = k,k 表示当模式中第 j 个字符与主串中第 i 个字符 “失配” 时,需要滑动模式串(回溯 j 指针),让模式串的第 k 个字符和主串中的第 i 个字符对齐,继续匹配(从这两个字符开始继续匹配,尚不知相不相等)。


       综上,我们已经可以给出KMP算法的框架

//pos从1开始算起, 字符串的索引从0开始算起
int KMP(string s , string p , int pos) {
	int i = pos, j = 1;
	int lenP = p.length(), lenS = s.length();
	while( i <= lenS && j <= lenP )
	{
		if( j == 0 || s[i-1] == p[j-1])
		{
			++ i;
			++ j;
		}
		else
			j = next[j];
	}
	if(j > lenP)
		return i - lenP;
	return -1;
}


求next数组:

       1. 当 j = 1时,令next[ j ] = 0,直观上表示模式串P的第0位和主串的第 i 位比较,相当于让模式串的第1位和主串的第 i+1 位比较。

       2. 当 j 不等于1时,若集合不空,则令 

next[ j ] = 

       3. 其他情况下,令next[ j ] = 1,就是让模式串的第1位和主串的第 i 位进行比较。


综上,next函数的定义为:

 (5)



       现如今,问题的关键在于求 ,我们通过分析发现可以使用递推的方法来求得这个值。


        设next[ 1 ~ j ]已知,要求 next[ j+1 ] = 使得,是满足 的最大值。


        由等式(3)可知,问题被转化为模式串P自身的模式匹配问题当 “第二个串P” 的第 j+1 位与 “第一个串P” 的第 i +1 位失配的时候,应当移动 “第二个串P” 使得其第 next[ j + 1] 位与 “第一个串P”的第 i+1 位对齐,继续匹配。

        令 k =  = next[ j ] k = ,表示第一代k,则


        等式 (1 < k < j)成立,且不存在  (),使得这个等式也成立。(6)



1)  若,则进一步有 (7),且不可能存在 )也满足(7),则next [ j+1 ] =  + 1,即 next [ j+1 ] = next[ j ] + 1。

        用反证法:假设存在  ()满足(7),则有,包含子情况:

 ,与 式(6) 矛盾。故原假设不成立,不存在这样的  ,得证!


2)  若,则必有,不符合next[ j + 1]的定义。

         令  = next[  ,则成立,若,则进一步有,且是满足情况的最大k(证明与之前相仿),next[ j + 1 ] = next[  ] + 1 =  + 1。

        同理,如果,令 = next[  ,若,则令 next[ j + 1 ] = next[  ] + 1=  + 1。若这代的k还不等于,就一路迭代直至第代为止--next[],,next [ j + 1 ] = 

        最底层的情况是 next[] = 0,此时 “第二个串P” 移动,使其第1个位置和“第一个串P”的第 i + 1 个位置对齐,继续匹配。


        以上递推的推导过程,需要一些想像力,一旦 get 到递推时,第二个串沿着第一个串蹭来蹭去的画面,就豁然开朗了。


求next函数的过程图例:



是不是觉得非常简单,非常清晰,非常明了!


使用C++代码来描述求next函数的过程:

//_next数组从1开始数起
//字符串从0开始数起

void Next(string p) {
	int i = 1,j = 0 ; _next[1] = 0;
	while( i < p.length())
	{
		if(j == 0 || p[i-1] == p[j-1])
		{
			++ i;
			++ j;
			_next[i] = j;
		}
		else
			j = _next[j];
	}
}



上面的KMP和Next两个函数的代码就构成了完整的KMP代码了,下面是我实现的代码,附带一个简例:

/******************************
 * author:      ace_yom (Peizhen Zhang)
 * date:        2015-8-17
 * description: KMP
 *
 * copy right reserved.
 ******************************/

#include <iostream>
#include <string>
using namespace std;

//_next数组从1开始数起
//字符串从0开始数起
const int maxn = 101;
int _next[maxn];

//_next数组从1开始数起
//字符串从0开始数起
void Next(string p) {
	int i = 1,j = 0 ; _next[1] = 0;
	while( i < p.length())
	{
		if(j == 0 || p[i-1] == p[j-1])
		{
			++ i;
			++ j;
			_next[i] = j;
		}
		else
			j = _next[j];
	}
}

//这里的pos和函数的返回索引都是从1开始数起的
//字符串的索引是从0开始数起的
int KMP(string s , string p , int pos) {
	int i = pos, j = 1;
	int lenP = p.length(), lenS = s.length();
	while( i <= lenS && j <= lenP )
	{
		if( j == 0 || s[i-1] == p[j-1])
		{
			++ i;
			++ j;
		}
		else
			j = _next[j];
	}
	if(j > lenP)
		return i - lenP;
	return -1;
}

int main() {
	string s = "acbfyacafud";
	string p = "ac";
	Next(p);
	//将返回1
	cout << KMP(s,p,1);
	return 0;
}

设串S的长度为n,串P的长度为m,Next算法的时间复杂度为O(n+m)


       以上,算法之优美简洁莫过于此~

       然而,你以为这样就结束了吗?No ~ ! next数组还可以进一步地优化。不过这就留给读者自己进行探究了吧。实在想不出来,可以参考 [1] 的末尾部分。 



 

Reference:

[1] 数据结构(C 语言版) 严蔚敏 吴伟民 编著--4.3 串的模式匹配算法



评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值