浅谈字符串(模式串)匹配算法(BF与KMP算法)(C++实现)

【前言】

相信不少学过数据结构的同学有过被KMP算法劝退的经历吧,其实我也一样!记得四月份学到这个算法的时候,自己对于字符串的特性了解很浅薄,再加上这个算法的实现确实太过抽象,引入各种各样的变量和辅助空间看得人眼花缭乱,当时自己只能灰溜溜地把这个知识点直接放弃了,直到后来开始看算法大神左程云老师的课程解开了不少当时的困惑,所以本文会采用左程云老师的思路来介绍,这篇文章也算是我自己学习笔记的一个分享(安利一波左神,适合有基础的爱好者去听,会颠覆很多对基础算法的固有认知。)不得不说在目前学到的所有算法中KMP一定算得上难度系数较高的类型了,不少企业也出现过“手撕KMP”之类的笔试题。在这里有点惶恐,因为自己确实曾经被这个算法“劝退”过,鼓起勇气介绍这个算法也确实是对自己的一个挑战。在此我也会尽全力对算法中的细节进行拆解和描述!和我一起多画图多举实例,可能会更有助于对该算法的理解。

【情景引入】

在开始我们的介绍之前,我们先来看一道leetcode题目,虽然这是一道”简单题“,但是其背后的“水”可以说很深,且看分解:

28.找出字符串中第一个匹配项的下标-力扣(LeetCode)

题目描述的意思是给你两个字符串。这里举个实例说明,str1="Ilovealgorithm"(主串),str2="algorithm"(模式串),让你在主串中寻找与模式串中第一个匹配项的下标。在本例中,我们可以看出,str1完全包含了str2的完整内容(即str2是str1的子串)。在我们确定str1已经完全包含了str2,没有出现缺字漏字的情况下(举例:假如str1不变,str2="agorithm"(此时出现了缺字情况)),我们返回主串中所含子串第一次出现的位置。在本例中这个位置便是5(a对应的位置)。其实在一篇文章中,我们也常常利用这个算法来定位关键字。那么我们如何通过设计算法来实现这个过程呢?

一.BF算法-暴力解法

【算法思路】

BF算法的思路是:①从主串的每一个字符开始,依次与模式串中的字符进行匹配

②若成功匹配,则继续比较下一个字符。若不匹配,则需要对主串回溯,继续进行比较。我们对这个回溯过程进行举例说明:

S(主串):abcfddcad

T(模式串):abca

假设由i变量遍历S串,j变量遍历T串。从上面的实例可以看出,如果从主串的第一个字符(即'a',我们将此位置记作k)开始匹配,S与T前三位是匹配成功的,但是第四位上S串是f,T串是a,匹配失败。此时对于模式串,回溯到首元素。对于主串,回溯到当前元素的下一个元素(k+1位置,即‘b’)开始匹配。

③直到主串中一个连续的字符序列与模式串成功匹配(用长度相等来体现),算法结束。

假设n为主串长度,m为模式串长度,则该算法的平均时间复杂度为O(n*m)

【代码实现与注释解析】

由于本算法思路简单,限于篇幅,本文重点讲解KMP算法的思路,因此在此直接给出BF算法的代码实现:

int BF(const string& S,const string& T){
	//我们假定S为主串,T为模式串
    int i=0,j=0;//用i来遍历主串S,j来遍历模式串T
	while(i<S.size()&&j<T.size()){
		if(S[i]==T[j]){//如果成功匹配,则继续向后比较
			++i,++j;
		}else{//匹配失败,回溯
			i=i-j+1;
			j=0;
		}
	}
	if(j==T.size()){//模式串遍历完毕,匹配成功
        return i-T.size();
	}else{//匹配失败
        return -1;
	}
}

二.KMP算法

由前面的BF算法我们知道,对于每一次匹配过程,只要匹配失败便会进行回溯操作,大量的回溯操作无疑是耗时的,所以我们的一个改进思路是:能否减少回溯次数。1977年,三位科学家发现了这个更高效的模式串匹配算法,并分别用他们名字的首字母进行命名,故称为KMP算法。

【思路详解】

在介绍KMP算法之前,我们必须引入一系列概念。别慌!跟住我的节奏,结合图例来理解这些概念,才能更透彻地理解这个算法!

【最大前后缀匹配长度k】

我们首先在单个字符串上探索规律。对于一个字符串,我们将从它的首部出发产生的子串称为“前缀”,将从它的尾部出发产生的子串称为“后缀”前后缀既不能为空,也不能包含所有元素)。我们引入一个整型变量k,来记录最长前后缀的匹配长度这个变量与当前字符本身无关,而与当前字符前面的子串有关。我们结合实例来看:

5483fe3ed3e543bd91d9d2a657e542b0.jpeg对于字符串“abbabb”,结合上面表格来看,当长度等于3时,该串的前缀和后缀匹配,且匹配长度最大。所以此时k=3。而这个k是对谁而言的呢?假设我们在T串后面加一个字符‘c’,即T串变成“abbabbc”,这个k信息便是针对c而言的!,也就是说k信息是用来描述当前字符的前面所有字符构成的子串的。那么很显然,针对这个字符串的每一个字符,我们都能获取到它的“k信息”我们将每个字符的k信息存储在一个数组里,我们将这个数组称为next数组

【next数组的定义】

根据上面的描述,相信大家已经了解了next数组是怎么实现的,里面记录的是什么信息。那么在next[j]数组的实际构建中,会有以下三种情况:

①当j=0时,我们人为规定next[0]=-1。(在0之前根本没有字符串)

②当j=1时,我们人为规定next[1]=0。(前后缀不能包含整体,所以取0)

③其他情况,next[j]=k。

d6c08a4aa37740a38b27d73a32df2a68.jpeg

对应字符串str,我们根据上面给出的法则写出了下面的next数组。为方便理解,我举一个局部的例子来看:注意看红圈的位置,对应位置下标为7,此时next数组记录的是[0,6]范围上的k信息,不难发现对于这个子串k信息的值为3。其他位置的推导与之同理,篇幅有限不再赘述。

这里先建立一个概念:无论是求k信息,还是构建next数组,都是针对模式串进行的!

【BF算法的小加速-KMP算法】

回到我们前面的字符串匹配问题。我们假设S为主串,T为模式串。在前面所讲的BF算法中,只要发现二者匹配失败便会回溯到头,这样是很低效的。我们分别对S从i位置开始(用p1),对T从0位置(用p2)开始遍历(因为T是S的待匹配模式串,结合BF算法的回溯过程进行理解)。我们假设p1来到X位置,p2来到Y位置时,两串开始不匹配,如下图所示:

cc30818d0b8b436999c0c976b6c5ecc7.jpeg

首先先声明:此处为方便理解,我们用下标来代替实际元素!而且S,T两串不止这么长,只是从X,Y位置开始匹配不上了。这里有一个很重要的潜台词,也就是在X,Y对应的位置之前,S,T两串是完全匹配的!

KMP算法的做法是:X保持不动,Y来到“前缀与后缀匹配时的最大前缀”之后的位置(该位置通过访问next数组得到)记作Y',之后从S串的X位置和T串的Y'位置开始继续匹配,如下图所示:

3a9c8b5b145d4a758c42cfffd967a109.jpeg

从图中我们不难看出,实际效果就是将T串整体向后推,再进行匹配。其实我们此时已经可以发现其中的一些小“端倪”,KMP不再和BF一样,当匹配失败就立即回溯到头。但是,为什么可以不再回溯到头呢,理论依据是什么?此时我们需要结合实例来看,会更明朗一些!

49e37d7059ee4825a85fcf61051788c3.jpeg

我们拿到了主串str1(理解成前文的S串)和模式串str2(理解成前文的T串)。这里我们必须先声明一点:在实例中串中的元素是实际内容,而不再是前文所指的下标str1的前面可能还有元素,也就是说这个str1片段的起始元素下标不为0,而str2的起始元素下标为0!,接下来我们来看,显然,当p1=X,p2=Y时,两串匹配失败。此时通过查询next数组,我们发现,在模式串str2中最大前后缀匹配长度为6(本例中这个前后缀为“abbstk”),那么依照前文所讲述的,Y'会来到最大前缀的后一个位置,也就是红圈圈起来的s处。那么我们此时让X与Y’对齐进行后续匹配,这么做的原因是:对于str1,我能确保从i位置到m位置的任意位置(k)出发,配不出完整的str2(i,m见下图)。接下来大家可能会有疑问了,对于str1而言,难道从i位置到m位置出发,能确定配不出完整的str2吗?答案是:能!接下来我们解释:

d1a3de0432b642719265c9aeabf207eb.jpeg

为了方便讲述,此处我们串中元素继续用下标代替。首先,当str1中的X与str2中的Y'对齐时,本质上,匹配的起点是:str1的m位置,str2中的0位置。对于上面的问题,我们采用反证法的思路:假设对于str1,从i位置到m位置的任意位置(k)出发可以配出完整的str2。那么首先需要保证:str1中的[k~X]部分(如图A部分)(假设长度为L)要匹配上str2中长度为L的前缀(如图B部分)。但是实际上,str1和str2在X,Y位置之前一路匹配。也就是说一定能从Y位置出发找到长度为L的后缀(如图C部分),且这个前后缀有重叠,产生矛盾,故假设不成立。那么最后一个问题来了,我们该如何构建next数组呢?

【next数组的构建】

其实next数组的构建过程类似数学归纳法的感觉,我们根据T串来构建next数组以记录k信息。首先next[0]和next[1]的值是我们人为确定的,分别是-1和1。然后next[2]的值是依据T[0]和T[1]来构建的,next[3]是根据据T[0],T[1]和T[3]来构建的,依此类推。此时我们假设来到了i位置:

9eef44b6c45e43d795daa863f1cf4f7f.jpeg

由前面的推导过程我们不难猜测,欲得出i位置的k信息,我们首先要知道i-1位置的k信息,假设i-1位置的k信息为m,即在[0~i-2]区间上最大前后缀匹配长度为m(结合上面图来看)。

①如果m处的元素等于i-1位置的元素,那么在[0~i-1]区间上最大前后缀匹配长度变为m+1,即此时i位置处的k信息为m+1。

②如果m处的元素不等于i-1位置的元素,则在[0,m-1]位置上继续寻找(即使用部分信息进行回退),与①步骤同理。此处有些抽象晦涩,我们举个实例来看:

26fe16607ca84f6f865f45a395f61854.jpeg

由前面给出的结论,我们先判断e是否等于‘?’。如果相等,i位置处的k信息值为最大前缀长度+1=9(即next[i]=9),如果不相等,有点类似前面KMP算法的过程:我们在e元素所在位置之前的子串上面进行前后缀匹配,找到最大前缀之后的元素(即s),判断s是否等于‘?’,如果相等,i位置处的k信息值为最大前缀长度+1=4(即next[i]=4),如果不相等,则继续回退,重复这个过程。直到没有信息可匹配,next[i]便等于0+1=1。

到此,KMP算法的细节我们便介绍完毕。接下来举一个综合性比较强的实例来加深理解:

【综合实例】

d42ac3d0befa42078d7b068512a52d4f.jpeg

首先我们来看第一次匹配:str1和str2在X,Y位置处匹配失败。那么我们通过查询next数组,确认Y位置处的next[Y]值为7,也就是说在Y前面的子串中最大前后缀的匹配长度为7。Y'来到最大前缀之后的位置,即下标为7的位置,接下来将X与Y'对齐进行第二次匹配。

第二次匹配:str1与str2在X,Y'位置处再次匹配失败,同理Y''来到下标为3的位置,接下来将X与Y''对齐进行第三次匹配。

第三次匹配:str1与str2在X,Y''位置处再次匹配失败,对于Y''位置查询next数组,发现前后缀不匹配(最大匹配长度为0),则Y'''来到0位置处,接下来将X与Y'''对齐进行第四次匹配。

第四次匹配:str1与str2在X,Y'''位置处再次匹配失败,此时已经不存在前后缀了。我们只能让str1从E位置开始,对应str2的0位置进行匹配了!

【代码实现与注释解析】

//构建next数组
vector<int> getNextArray(const string& T) {
    int l = T.size();
    vector<int> next(l, -1);  // 初始化 next 数组为 -1
    if (l == 1) {
        // 长度为 1 时,直接返回
        return next;
    }
    next[0] = -1;  // next[0] 不需要用于匹配,通常设为 -1
    next[1] = 0;   // T 的第一个字符和自身匹配,长度为 1 的前缀和后缀
 
    int i = 2, cn = 0;
    while (i < l) {
        if (T[cn] == T[i - 1]) {//使用i-1位置的信息进行比对
            next[i] = cn + 1;  // 更新 next[i]
            cn++;              // 移动 cn 到下一个可能的匹配位置
        } else if (cn > 0) {
            cn = next[cn];     // 使用部分匹配信息回退 cn
        } else {
            next[i] = 1;       // 没有前缀可匹配,next[i] 为 1
        }
        i++;  // 移动到下一个字符
    }
    return next;
}
//KMP算法主体函数
int KMP(const string& S,const string& T){
	int l1=S.size();
	int l2=T.size();
	if(l1==0||l2==0||l2<1||l1<l2){//字符串长度限制条件
       return -1;
	}
	vector<int> next=getNextArray(T);//获取next数组
	int p1,p2=0;//p1遍历S串,p2遍历T串
	while(p1<l1&&p2<l2){//p1,p2均未越界
       if(S[p1]==T[p2]){//匹配成功,指针后移即可
          p1++;
		  p2++;
	   }else if(next[p2]==-1){//T串中比对的位置已经无法往前跳了
          p1++;//只能找S串的下一个位置了
	   }else{
		  p2=next[p2];//这就是前面Y-Y'的过程,通过查询next数组得到位置实现跳转
	   }
	}
    if (p2 == l2) {
        return p1 - p2;  // 如果T串遍历完了,返回匹配开始的位置
    } else {
        return -1;  // 否则返回-1表示匹配失败
    }
}

【经典例题】

经典题--796.旋转字符串-力扣(leetcode)

请大家在看下文的模板代码之前,一定先浏览并理解链接中的题意。在此我对本题的题意再做一个大致的梳理:比如说有字符串“abcde”,那么它的旋转字符串就有“abcde”,“bcdea”,“cdeab”,“deabc”,“eabcd”这五种情况。我们需要判断任意两个字符串是否互为“旋转字符串”。依照上面给出的例子,我们将字符串“abcde”复制并自我拼接,得到字符串str=“abcdeabcde”,我们不难发现,上面的这些“旋转字符串”都是字符串str的子串。于是这个问题就被转化为了字符串匹配问题。接着采用我们上文介绍的KMP算法解决即可。

class Solution {
public:
    bool rotateString(string s, string goal) {
        if(s.size() != goal.size()) return false;

        string res = s + s;
        vector<int> next(goal.size());
        next[0] = -1;
        for(int i = 1, j = -1; i < goal.size(); i ++)
        {
            while(j != -1 && goal[j + 1] != goal[i]) j = next[j];
            if(goal[j + 1] == goal[i]) j ++;
            next[i] = j;
        }

        for(int i = 1, j = -1; i < res.size(); i ++)
        {
            while(j != -1 && goal[j + 1] != res[i]) j = next[j];
            if(goal[j + 1] == res[i]) j ++;
            if(j == goal.size() - 1) return true;
        }

        return false;
    }
};

不得不承认,KMP算法确实实现难度不小,而最有效的方法便是:重复举例理解之后手撕代码。这个过程中我们一定要有耐心,没有什么过不去的坎。我是小高,一名非科班转码的大二学生,水平有限认知浅薄,有不当之处期待批评指正,我们一起成长!

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值