详述KMP算法的另一种理解——动态规划法解决字符串匹配问题

开篇

这篇文章拖了超级久,中间连续了更新了几篇零零散散的题目,但其实都是为了给这道题目打掩护,因为讲实话,我是真的不愿意写这篇文章,因为感觉内容太长了而且我容易说着说着就把自己绕进去。今天终于有时间了,为了给大家带来更好的体验,今天又重新看了一遍这个解题方法,防止说不清道不明,到最后耽误大家时间。

吐槽KMP

既然文章很长,那么我们就不急,先聊聊KMP吧。要聊KMP那可有的说了,这是我自学计算机的第一个最大的阴影。我现在大三,本科学的是一个完全和计算机不相关的专业——生物。大二的时候自学计算机,刚学了不久就被KMP算法"勒令退坑"。这个算法实在是太抽象了,从解题方法到口头描述,大多数教科书和学校老师也都是一笔带过(我严重怀疑他们也不会)。所以这个算法真的让很多自学者半途而废或者未起步就已经报废的一个大坎。我当时也没怎么弄懂,都是后来再去看书,再去做题才弄懂全貌。
大概什么意思呢,我们有两个字符串,一个模式串pat,长度为M;一个文本串txt,长度为N。KMP算法是在txt中查找pat,如果找到了就返回其起始索引,反正返回-1。
如果我们用暴力法来解决,显然我们要设立两个指针,在pat和txt上不断游走。如果使用暴力法的话,txt上的指针将会不断往回走(因为需要不停查找之前的位置)。
这么做无疑是很耗费时间的,KMP算法怎么做呢?KMP算法开辟一段数组next,这个数组存储了当前位置的下一匹配位置,这样的话,遇到了txt与pat的字符不匹配时,我们就将txt上的指针移动到next数组中标记好的下一匹配位置,重新与pat进行匹配,从而节省了很多匹配步骤与时间开销。时间复杂度为O(N),空间复杂度为O(M)。
算法题永远是,懂的人觉得就是个有趣的数学题,不懂的人就好像在看天书。讲实话,这道题的解法我当时就和看天书一样,甚至企图用背代码的方式来解决。为了不让初学者或者自学计算机的人放弃,今天我们根据前面讲过的动态规划,来让大家对这道题目形成一个全新的认识。

动态规划法解决字符串匹配问题(改良KMP)

我们首先定义一个二维的dp数组(但是空间复杂度还是O(M)),重新定义其中元素的含义,使得代码长度大大减少,可解释性大大提高。(本文参考了labuladong算法小抄和<<算法4>>)
我们讨论的是字符串匹配问题,例如txt=“abdca”,pat = “bdc”,显然这两个字符串是可以匹配的,一定会有同学说了"这么简单的问题,我用暴力法都可以解决"。OK,那我们就先来说说暴力法:

int search(string pat,string txt)
{
	int M = pat.length();
	int N = txt.length();
	for(int i = 0;i <= N - M;i++)
	{
		for(int j = 0;j < M;j++)
			if(pat[j] != txt[i+j])
				break;
	//pat全部匹配了
	if(j==M)return i;
	}
	//txt中不存在pat子串
	return -1;
}	

暴力法绝对可以解决问题,但是时间复杂度为O(MN),空间复杂度为O(1)。
而且字符串中如果重复字符太多的话,这个方法就显得很蠢。
比如txt=“aaacaaab”,pat = “aaab”
pat中根本就没有c这个字符,我们就应该直接跳到c后面那个a的位置开始匹配,但是暴力法还傻傻地匹配,回退,匹配,回退…
这个时候KMP算法就显得很聪明了,它直到字符b之前的字符a都是匹配的,所以每次只需要比较字符b是否被匹配就行了。
KMP有一个最大的特点:永不回退txt的指针i,不走回头路(不会重复扫描txt),而是借助dp数组中储存的信息把pat移到正确的位置继续匹配。但这样的话,我们的空间复杂度就变成了O(M),但是时间复杂度是O(N),是一种以时间换空间的方法。
KMP的难点在于,如何计算dp数组中的信息?如何根据这些信息正确地移动pat的指针?这个就需要确定有限状态自动机来辅助了,不要被这个词语吓到了,起始他就是dp数组。
计算这个dp数组,只和字符串pat有关。
例如我们刚才说到的txt=“aaacaaab”,pat=“aaab”,按照KMP算法应该匹配呢?
在这里插入图片描述

dp数组指示pat应该进一步匹配这个位置:
在这里插入图片描述
这里的j大家不要理解为索引,要理解为状态,所以他才会出现在这个奇怪的位置,后文我们具体解释一下。
现在我们明白了dp数组只和pat有关,那我们可以这样设计一下KMP算法

class KMP{    
	private:        
		int dp[][];        
		string pat;    
	public:        
		KMP(string pat)        
		{            
			this.pat = pat;            
			//通过pat构建dp数组            
			//需要O(M)时间        
		}        
		int search(string txt)        
		{            
			//借助dp数组去匹配txt            
			//需要O(N)的时间        
		}
}

这样,当我们用同一个pat匹配不同的txt时,就不需要浪费时间构造dp数组了。
状态机介绍
为什么说KMP数组和状态机有关呢?因为我们认为pat的匹配就是状态的转移。比如pat=“ABABC”:
在这里插入图片描述如上图圈内的数字就是状态,状态0是起始状态,状态5是终止状态。开始匹配时,pat处于起始状态,一旦转移到了终止状态,就说明在txt中找到了pat。比如说当前处于状态2,就说明字符“AB”已经被匹配了。
在这里插入图片描述另外,处于不同状态时,pat状态转移的行为也不同。比如说假设现在匹配到了状态4,如果遇到了字符A就应该回到状态3(至于为什么,我等下会说明,但是这里先大致说一下,因为要找最近的一个由A得来的状态,即状态3),遇到字符C就应该转移到状态5,如果遇到了B就回到状态0:
在这里插入图片描述
用变量j表示指向当前状态的指针,当前pat匹配到了状态4:

在这里插入图片描述
如果得到了字符“A”,根据箭头指示,转移到状态3是最聪明的:
在这里插入图片描述
如果遇到了字符"B",根据箭头指示,只能转移到状态0
在这里插入图片描述
如果遇到了字符“C",根据箭头所示,应该转移到终止状态5,也就意味着匹配完成了:
在这里插入图片描述
当然了,有可能还会出现在pat中未出现的字符:
在这里插入图片描述
为了清晰可见,我们画状态图时就把其他字符转移到状态0的箭头省略,只画pat中出现的字符的状态转移。
在这里插入图片描述
KMP算法最关键的步骤就是构造这个状态转移图。要确定状态转移的行为得明确两个变量,一个是当前的匹配状态,另一个是遇到的字符;确定了这两个变量之后,就可以知道这个情况下应该转移到哪个状态了。

为了描述状态转移图,我们定义一个二维dp数组,它的含义如下:

dp[j][c]=next
0<=j<M,代表当前状态
0<=c<256,代表遇到的字符(ASCII码)
0<=next<=M,代表下一个状态
dp[4]['A]=3表示:
当前是状态4,如果遇到了字符'A',pat应该转移到状态3

dp[1]['B']=2表示:
当前是状态1,如果遇到字符B,
pat应该转移到状态2.

但是我们应该如何通过pat构建这个dp数组呢?
dp数组的构建
dp数组的构建其实就是状态转移图的构建。要明确状态转移的行为,必须明确两个变量,一个是当前的匹配状态,另一个是遇到的字符。所以dp数组的框架应该是:

for 0 <= j < M:#状态
	for 0 <= c < 256:#字符
		dp[j][c]=next

那状态具体是怎么转移的呢?
显然如果碰到了字符c与pat[j]匹配的话,那么next=j+1,我们称作状态推进:
在这里插入图片描述
如果字符c和pat[j]不匹配的话,状态就要回退(或者原地不动),我们不妨称这种情况叫做状态重启:
在这里插入图片描述
关键问题来了,我们如何得知在哪个状态重启呢?解决这个问题之前,我们再定义一个名字:影子状态(参照labuladong大佬),用变量X表示。所谓影子状态,就适合当前状态具有相同的前缀。比如下面的情况:
在这里插入图片描述
当前状态j=4,其影子状态是X=2,因为他们都具有相同的前缀”AB“。因为状态X和状态j存在相同前缀,所以当状态j准备进行状态重启的时候(遇到的字符c和pat[j]不匹配),可以通过X的状态转移图来获得最近的重启位置。
比如说刚才的情况,如果状态j遇到了一个字符A,应该转移到哪里呢?首先只有遇到C才能推进状态,遇到A需要状态重启。状态j会把这个字符委托给X处理,也就是dp[j][‘A’] = dp[X][‘A’]
在这里插入图片描述为什么可以这样呢?因为既然j这边已经确定了字符A无法推进状态,只能回退,而且KMP就是要尽可能少得回退,以免多余的计算。那么j就可以去找到和自己有相同前缀的X是否后面有一个A,如果有那我们就可以直接转移过去了。
如果碰到了B,状态X不能状态推荐,只能回退,j只要跟着X指引的方向回退就行了:
在这里插入图片描述
X如何转移我们在前面就已经算出了,所以当我们询问到X的时候就可以直接转移了。
我们来再丰富一下代码框架:

int X//影子状态
for 0<=j<M
	for 0<=c<256
		if c== pat[j]
			dp[j][c] = j + 1
		else
			//状态重启
			//询问X
			dp[j][c] = dp[X][c]

代码实现

class KMP
{
    private:
        int dp[][];
        string pat;
    public:
        KMP(string pat)
        {
            this.pat = pat;
            int M = pat.length();
            //dp[状态][字符]=下一个状态
            dp = new int[M][256];
            //base case
            dp[0][pat[0]] = 1;
            //影子状态X初始化为0
            int X = 0;
            //构建状态转移图
            for(int j = 0;j < M;j++)
            {
                for(int c = 0;c < 256;c++)
                    //预先全部找到影子状态
                    dp[j][c] = dp[X][c];
                //单独对和pat[j]相等的字符进行状态推进
                dp[j][pat[j]] = j + 1;
                //更新影子状态
                //现在是状态X,要匹配pat[j],此时的状态是推进或者是重启,都会得到一个明确的结果,赋值给X
                X = dp[X][pat[j]];
            }
        }
        int search(string txt)
        {
            int M = pat.length();
            int N = txt.length();
            //pat的初始状态为0
            int j = 0;
            for(int i = 0;i < N;i++)
            {
                j = dp[j][txt[i]];
                //到达终止态
                if(j == M)
                    return i - M + 1;
            }
            //没到达终止态,匹配失败
            return -1;
        }
};

总结

在pat匹配txt的过程中,只要明确了当前处在哪个状态遇到的字符是什么这两个问题,就可以确定应该转移到哪个状态(推进或者重启)
dp数组的含义是dp[j][c]=next表示当前是状态j,遇到了字符c,应该转移到状态next。
影子状态是一个一直在j后面的变量,就像影子一样。每当我们想要往后退的时候,要先回头看看我们的影子,他会告诉我们应该退到哪里,因为前面的路他已经走过了。
KMP算法之所以合理,就是因为我们只是减少了匹配次数而已,对于那些不匹配的字符,我们尽量回退少一些,既然我们的影子和我们相同,我们就直接看影子对于当前要匹配的字符会怎么改变状态即可,这样做可以回退尽量少的步数。
OK,这篇长文终于更完了,确实KMP算法很绕,我也只能边写边看,不然真的被绕进去了,但是这种动态规划的方法鼓励大家掌握,因为更容易理解,代码量也很少。面试的时候会让面试官眼前一亮的。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值