字符串的模式匹配算法

模式匹配算法

前言

对于数据结构中串的章节,最为重要的莫过于串的模式匹配算法:即为求给定子串在主串中的出现的位置。从最开始的朴素模式匹配到后来的KMP算法,再到改良的KMP算法,算法逐渐改良,理解难度也逐渐加大。本人曾经多次阅读相关内容,怪于自身理解能力有限仅一知半解,但心中甚为挂念。今再次读之理解更深入之,遂记之以铭后效。

注解:

本文字符串结构皆为顺序存储结构,且使用下标为0的数组分量表示字符串的长度
主串标记为字符数组S,子串或者模式注为字符数组T


朴素模式匹配算法

//返回子串T在主串S中第pos个字符开始之后的位置。不存在返回0
int Index(SString S,SString T,int pos)
{
	int i=pos,j=1;//i是主串的字符指针,j是模式的字符指针
	while(i<=S[0]&&j<=T[0])
	{
		if(S[i]==T[j])//
		{
			i++;
			j++;
		}else{
			i=i-j+2;//对i进行回溯,回溯到pos的后续位置
			j=1;//j回溯到1
		}
	}
	if(j>T[0])
		return i-T[0];//返回子串在pos之后的第一个位置
	else
		return 0;//没有找到子串
}

算法思想步骤

  1. i为主串S的字符指针,j为模式的字符指针
  2. 令i指向pos代表的字符,j指向模式第一个字符
  3. 令S[i]和T[j]进行比较
    • 若相等,则i++,j++
    • 若不相等,i=i-j+2,j=1
  4. 转步骤[3]
  • 匹配成功:
    模式T中的每个字符依次和主串S中的一个连续字符序列相等。函数值为该对应相等连续字符序列中第一个字符在主串S中的位置
  • 匹配不成功:函数值返回0
  • 一般情况下的时间复杂度:O(m+n)
  • 最坏情况下的时间负责度:O(m*n)
  • 缺点:主串中存在多个与模式字符串“部分匹配”的子串,因此导致指针i的不断回溯

KMP模式匹配算法

改进的点:
在每一趟匹配过程中出现字符串比较不相等时,不需要回溯指针i,而是利用已经匹配得到的结果,将模式尽可能向右滑动一段距离,继续比较。

问题解决思路:

  1. 问:当主串和模式匹配过程出现不相等,该怎么办?
    答:避免将指针i回溯,i保持不动,尝试将模式进行右滑
  2. 问:将模式右滑的是指什么?
    答:i指针不变,找到下一个与i指针指向字符进行比较的模式中的字符
  3. 问:右滑的意义在哪?为什么要右滑?
    答:利用上轮匹配得到部分的“匹配结果”,提高查找模式串的效率,避免指针i不断回溯导致的效率低下,只要让j进行回溯就好
  4. 问:假设我们有匹配结果,即失配的j>1,那么上轮匹配得到的“匹配结果”是什么?
    答:假设是S[i]!=T[j]导致发生失配,则之前的匹配结果为:
    p 1 p 2 p i … p j − 1 = s i − j + i … s i − 1 . \begin{aligned}p_{1}p_{2}p_{i}\ldots p_{j-1}=s_{i-j+i}\ldots s_{i-1}\\ .\end{aligned} p1p2pipj1=sij+isi1.
  5. 问:如何利用部分“匹配结果”?
    答:假设存在: p 1 p 2 p i … p k − 1 ∈ p 1 p 2 p i … p j − 1 p_{1}p_{2}p_{i}\ldots p_{k-1}\in p_{1}p_{2}p_{i}\ldots p_{j-1} p1p2pipk1p1p2pipj1且有: S i − k + 1 S i − k + 2 … s i − 1 ∈ s i − j + i … s i − 1 S_{i-k+1}S_{i-k+2}\ldots s_{i-1}\in s_{i-j+i}\ldots s_{i-1} Sik+1Sik+2si1sij+isi1 并且有 p 1 p 2 p i … p k − 1 = S i − k + 1 S i − k + 2 … s i − 1 p_{1}p_{2}p_{i}\ldots p_{k-1}=S_{i-k+1}S_{i-k+2}\ldots s_{i-1} p1p2pipk1=Sik+1Sik+2si1
    则i指针下一个要与之比较的字符为模式中第k个字符
  6. 问:部分匹配结果和第k个字符的现实意义是什么?
    答:假设在模式第j个字符发生不匹配的状况,那么部分匹配结果的现实意义是在匹配了的字符串 p 1 p 2 p i … p j − 1 p_{1}p_{2}p_{i}\ldots p_{j-1} p1p2pipj1中的尽可能长且相等的前缀字符串和后缀字符串。
  7. 问:为什么是尽可长?
    答:尽可能利用已经匹配了的结果,并且减少模式右滑的距离,使i回溯的距离尽可能少。
  8. 问:假设之前已经匹配了的字符串中没有相等的前后缀字符串怎么办?
    答:那么就去令i指针指向的字符和模式字符串第一个字符做比较
  9. 问:为什么现在和模式第一个字符串做比较?
    答:假设有长度为1的前后缀子串,那么根据以上讨论,i指针接下来应该和模式第2个字符相比较,那么假设如果没有相等的前后缀,为了让模式右滑,则j指针只能回溯到1的位置。将i指针和模式第一个字符比较。
  10. 问:假设根本没有匹配部分怎么办?即跟模式串第一个字符比较就不相等,匹配部分都没有,根本谈不上寻找相等的前后缀子串!
    答:这个时候i并不回溯,直接后移1位,即**++i**,和模式串第1个字符开始进行匹配,j本身指向第一个字符,因此j=1保持不变。这里的处理办法跟朴素的模式匹配一样!
  11. 假设模式不右滑,并且还是像朴素的模式匹配算法一样i从pos后继开始,指针i和j重新开始从头依次比较,会发生什么情况?
    答:仍然假设是S[i]!=T[j]导致发生失配,此时让我们标记a=i,b=j,则之前的匹配结果为:
    p 1 p 2 p i … p j − 1 = s i − j + 1 … s i − 1 . \begin{aligned}p_{1}p_{2}p_{i}\ldots p_{j-1}=s_{i-j+1}\ldots s_{i-1}\\ .\end{aligned} p1p2pipj1=sij+1si1.
    则根据朴素的模式匹配算法,i=i-j+2,j=1,假设它们经过多次比较,发生2种情况:
  • i<a时,主串第i个字符和模式第j个字符发生了失配,导致重新执行朴素的模式匹配算法
  • i<a时,主串第i个字符和模式第j个字符都全部相等,当i==a时,主串第i个字符和模式第j(此时j<b)个字符进行比较,这不就是模式串右滑的场景吗?i保持不变,对j进行回溯,直接采用右滑模式串还可以省去从头开始的比较,这就是为什么采用模式右滑的KMP模式匹配算法更好

引出next数组。

若next[j]=k,则next[j]表明当模式串第j个字符和主串中某个字符比较缺“失配”时,在模式串中重新和该主串字符进行比较的字符的位置。
由此我们可以定义: n e x t [ j ] = { 0 ( j = 1 ) max ⁡ { k ∣ 1 < k < j & p 1 … p k − 1 = p j − k + 1 … p 1 } 1 next\left[ j\right] =\begin{cases}0 \left( j=1\right) \\ \max \left\{ k| 1<k <j\& p_{1}\ldots p_{k-1}=p_{j-k+1}\ldots p_{1}\right\} \\ 1\end{cases} next[j]=0(j=1)max{k1<k<j&p1pk1=pjk+1p1}1

则有:

  1. 当i=1时,next[1]=0;
    如果模式串中第1个字符就与主串字符不等。0表示,在i=1之前,没有匹配结果。那么直接将主串字符后移1位,重新和模式字符串第一个字符开始新的比较轮次。
  2. next[i]=1;
    对应模式串中第i个字符与主串字符不等,且之前的匹配结果中不存在相等的前缀和后缀字符串,那么直接将主串字符和模式串第1个字符去比较。
  3. next[i]=k;
    对应模式串中第i个字符与主串字符不等,之前的匹配结果中存在 p 1 p 2 p 3 … p k − 1 = p i − k + 1 … p i − 1 p_{1}p_{2}p_{3}\ldots p_{k-1}=p_{i-k+1}\ldots p_{i-1} p1p2p3pk1=pik+1pi1那么直接将主串字符和模式串第k个字符去比较。

KMP模式匹配算法代码实现

int Index_KMP(SString S,SString T,int pos)
{
    int i=pos,j=1;//定义指向主串的i指针,和指向模式的j指针
    while(i<=S[0]&&j<=T[0])
    {   
        /*当S[i]==T[j],i和j都后移,然后继续比较后续字符
        j=0的情况为在i与j=1进行比较且不等的情况下,j=next[1]=0
        此时对应于与模式串第一个字符比较不等后,通过(i++)后移1位和模式第(++j)=1个字符重新开始比较的情况*/
        if(j==0|S[i]==T[j])
        {
            i++;
            j++;
        }else{
            //模式右滑,在S[i]!=T[j]的情况下,寻找下一个与第i个字符比较的模式中的第j个字符
            j=next[j]
        }
    }
}

那么问题来了!KMP算法的实现是在已知next数组的基础上,那么如何求的模式串的next数组呢!
由以上讨论得知next数组只取决于模式串本身,而与待匹配的主串无关,我们可以从分析其定义出发用递归的方式逐渐求得next数组!!

在此重申next数组的定义:
对于next数组,next[j]表明当模式串第j个字符和主串中某个字符比较却不相等时,在模式串中重新和该主串字符进行比较的字符的位置。

假设模式字符串的为: p 1 p 2 p i … p n p_{1}p_{2}p_{i}\ldots p_{n} p1p2pipn

算法:如何求得next数组

  1. 对于任意模式串的next数组,当i=1时,默认next[1]=0;
    0表示,在i=1之前没有字符串相等的匹配结果。我们默认next[1]=0。
  2. 对于任意的模式串的next数组,当i=2时,默认next[2]=1;
    模式串中第2个字符与主串字符不等时,之前的匹配相等的字符串长度为1,此时长度为1的匹配结果不存在相等前缀和后缀子串的问题,我们默认next[2]=1。
  3. 假设已经存在next[i]=j;这表明在模式串中存在下列关系:
    p 1 … p j − 1 = p i − j + 1 … p i − 1 p_{1}\ldots p_{j-1}=p_{i-j+1}\ldots p_{i-1} p1pj1=pij+1pi1
    现在有next[i]=j,来递推next[i+1]为多少呢?
  • 若T[i]==T[j],则模式串中有: p 1 … p j = p i − j + 1 … p i p_{1}\ldots p_{j}=p_{i-j+1}\ldots p_{i} p1pj=pij+1pi
    则next[++i]=++j,相等的前缀和后缀子串长度扩张1
  • 若T[i]!=T[j],则模式串中有: p 1 … p j ! = p i − j + 1 … p i p_{1}\ldots p_{j}!=p_{i-j+1}\ldots p_{i} p1pj!=pij+1pi
    那么前缀和后缀子串无法扩张,只能收缩!k是衡量前缀长度的标志,k-1为相等的前后缀长度
    那么问题来了,我们应该在收缩的情况下,我们应该收缩多少呢?我们已经知道我们应该尽可能少的去收缩!以便我们提高匹配速度!对此问题,我们进行试探!

假设我们收缩的大小为k,即假设next[i+1]=k(k<j),则必定有: p 1 … p k − 1 = p i − k + 2 … p i p_{1}\ldots p_{k-1}=p_{i-k+2}\ldots p_{i} p1pk1=pik+2pi
已经next[i]=j,所以有: p 1 … p j − 1 = p i − j + 1 … p i − 1 p_{1}\ldots p_{j-1}=p_{i-j+1}\ldots p_{i-1} p1pj1=pij+1pi1
因为k<j,所以会有: p 1 … p k − 1 = p i − k + 1 … p i − 1 p_{1}\ldots p_{k-1}=p_{i-k+1}\ldots p_{i-1} p1pk1=pik+1pi1
因为 p i − k + 1 … p i − 1 ∈ p i − j + 1 … p i − 1 p_{i-k+1}\ldots p_{i-1}\in p_{i-j+1}\ldots p_{i-1} pik+1pi1pij+1pi1
又因为 p 1 … p j − 1 = p i − j + 1 … p i − 1 p_{1}\ldots p_{j-1}=p_{i-j+1}\ldots p_{i-1} p1pj1=pij+1pi1
所以有 p i − k + 1 … p i − 1 ∈ p 1 … p j − 1 p_{i-k+1}\ldots p_{i-1}\in p_{1}\ldots p_{j-1} pik+1pi1p1pj1
又因为 p 1 … p k − 1 ∈ p 1 … p j − 1 p_{1}\ldots p_{k-1}\in p_{1}\ldots p_{j-1} p1pk1p1pj1
所以 p 1 … p k − 1 p_{1}\ldots p_{k-1} p1pk1 p i − k + 1 … p i − 1 p_{i-k+1}\ldots p_{i-1} pik+1pi1 p 1 … p j − 1 p_{1}\ldots p_{j-1} p1pj1中的相等的前缀和后缀子串,k为其中相等前后缀+1
因为next[j]是 p 1 … p j − 1 p_{1}\ldots p_{j-1} p1pj1中最长的相等前后缀长度+1,所以令k=next[j],即强缀收缩为next[j]

求next数组的代码实现

void get_next(SString T,int next[])
{
    int i=1;
    next[1]=0;
    //默认任意的模式串第1个字符的next值为0
    //同时默认任意的模式串第2个字符的next值为1,
    //为了统一处理,i=1,j=0时,使用next[++i]=++j得到next[2]=1
    int j=0;
    while(i<T[0])
    {   
        //对于T[i]==T[j]的情况,前缀扩张+1,因为next[i]=j,所以next[++i]=++j
        //对于j==0的情况,经过不断的收缩,判断之前的匹配中没有任何相等的前后缀,因此next[++i]=++j=1
        if(j==0||T[i]==T[j])
            next[++i]=++j;
        else
            j=next[j];//j进行回溯,前缀收缩
    }
}

KMP算法优点

  • KMP模式匹配算法当且仅当模式串和主串之间存在许多部分匹配时才会更加具有优势
  • KMP最大特点时主串指针不需要回溯,整个匹配过程仅需从头到尾扫描一遍
  • 对于处理从外设输入的字符串很有效,可以边读入边匹配,无需回头重读

改良的KMP模式匹配算法

缺陷

当主串中第i个字符和模式中第j个字符比较不相等时,即S[i]!=T[j],根据流程next[j]=k,主串第i个字符接下来应该和模式中第k=next[j]个字符进行比较,假如T[k]==T[j],那么S[i]!=T[k],早就知道不相等,这种比较是没有意义的。

改进

因为上述比较没有意义,所以可以省略。按照一般流程,既然S[i]!=T[k],我们令主串第i个字符接下来应该和模式中第next[k]个字符进行比较。所以为何不更加直接点呢!那么当T[k]==T[j],直接令next[j]=next[k],意思为:当S[i]!=T[j],因为next[j]=next[k],所以令令主串第i个字符直接和模式中第next[k]个字符进行比较,省略S[i]和T[k]比较的部分,直接比较S[i]和T[next[k]]。

求取next数组的代码实现(改进后的)

void get_next(SString T,int next[])
{
    int i=1;
    next[1]=0;
    //默认任意的模式串第1个字符的next值为0
    //同时默认任意的模式串第2个字符的next值为1,
    //为了统一处理,i=1,j=0时,使用next[++i]=++j得到next[2]=1
    int j=0;
    while(i<T[0])
    {   
        //对于T[i]==T[j]的情况,前缀扩张+1,因为next[i]=j,所以next[++i]=++j
        //对于j==0的情况,经过不断的收缩,判断之前的匹配中没有任何相等的前后缀,因此next[++i]=++j=1
        if(j==0||T[i]==T[j])
       {
			i++;
			j++;
			//若T[i]==T[j],在主串某字符和模式第i个字符比较不等的情况下,
			//后续主串该字符和模式第j字符比较会比较多余,
			//所以令next[i]=next[j],主串该字符直接和模式第next[j]字符比较。
				next[i]=next[j];
			else
				next[i]=j;
		}
        else
            j=next[j];//j进行回溯,前缀收缩
    }
}

后言

模式匹配算法到此就差不多了,后续如果有学到新的相关更好的算法,本人会继续更新。
文章可能文字过多,图片太少!因为算法过程和推理来自根据已有参考资料自己思考,本人画图能力有限,无法保证美观得体,符合我心,于是便放弃作图。
本文虽然缺乏图片明确阐述算法,但逻辑上能够完整自洽,推理顺畅。总的来说,便于自己回顾和反思。但于其他读者而言,可能需要花点耐心和时间仔细阅读

  • 3
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值