写在前面
由来: 最近在看王争老师的《数据结构与算法之美》,看到了字符串匹配算法,发现里面门道挺多,光是想搞明白点就花了几天功夫,鉴于我付出的精力,必须好好记录一下。当前,我所说的不一定对,如有错误,欢迎在评论区指正。
范围: 因为目前只看了单模式匹配,所以只说BF,RM,BM,KMP四个算法。
重点: 本文不会说代码实现,而是尽量理清思路,从以下几个方面入手:算法解决了什么问题?大致怎么解决的?为什么可以这么做(具体解法)?
题目背景: 在stringA中找stringB,A为主串,B为模式串,假设主串长度为n,模式串长度为m,n>m。
BF
BF暴力匹配,这个大家都清楚,最简单也是最耗时的
算法过程:
- 先从主串中选出对比串(“对比串”是我自己取的名字,是指以i为起始坐标,取长度m的字符串)
- 将对比串和模式串进行对比
- 出错了就“将i后移1位”获取新的对比串,再从头开始对比
时间复杂度为:单次对比耗时O(m),最多进行m-n+1次对比,考虑到一般n都很大于m,所以最终复杂度简化为O(mn)。
缺点:“后移一位”后,又得“从头对比”是该算法的缺点,也是接下来优化的重点。
RM
在BF算法中,对比串与模式串的对比时间复杂度为O(m),相当麻烦,如何快速对比两者呢?
RM算法引入了hash思想解决这一问题(hash思想:不定长的输入得到定长的输出(散列值),两个输入的散列值不同就一定不同,就算散列值相同,输入也不一定相同,需要做额外的确认)。
算法过程:
- 先遍历一次主串,计算各个对比串的hash值
- 然后再遍历主串,比较对比串和模式串的hash值
- hash不一致,就后移一位,操作新的对比串
- hash一致,就进行原值对比
对比环节很简单,算法的关键在于计算hash环节。因为计算一个对比串就可能需要O(m)的时间,不优化算法,RM总的时间复杂度就和BF差不多了。
所以该如何快速的得到随机分布的hash值呢?
这里举个例子,假设主串26572,模式串是657,计算hash的方法如下:
572的hash值为 5×10×10+7×10+2,但我们计算572的hash时,其实可以利用657的计算结果,hash(572)=(hash(657)-6×10×10)×10+2
别看现在两者的计算量相差无几,当模式串一长,这种”递推式“的计算方式的时间复杂度为还能保持在O(1)。因此RM的时间复杂度为O(n)
BM
前面我们说到:后移一位”后,又得“从头对比”是BF算法的两个缺点
BM便引入了两个规则:“坏字符”和“好后缀”,来解决这两个缺点
大致做法为:
- 先从主串中选出对比串
- 将对比串和模式串进行对比(BF时从头部开始对比的,但BM是从尾部开始对比,为什么要这么做后面再说)
- 遇到“坏字符”,就根据“坏字符”规则取得对应的移动数,根据“好后缀”也取得一个数
- 取两个移动数中的大值,移动后选出新的对比串进行对比
看不懂不用急,这算法还挺复杂的,先慢慢看下去
“坏字符”
“坏字符”,顾名思义,便是比对不一致的字符(cd不一致,c便是坏字符)。
BF算法遇到这个问题只能“后移一位”,但其实,根据模式串提供的信息(对比的过程也是收集信息的过程)我们可能可以移动很多步
如上图,假如我们已经知道模式串中不存在c(我称这种字符为“墙字符”),那么我们就可以跳过接下来的两个对比串bca,cac,因为bca的第2位和cac的第1位包含了“墙字符”,对比串必然不成立,只能移动到“墙字符”的右侧,找新的对比串。
这里需要补充一点,无论是BM,还是KMP算法,降低复杂度的重点都在于:根据已有的信息,跳过明显不成立的对比串,实现“多移动几位”的目标。排除的错误答案越多,离终点越近,算法效率就越高。
如果在模式串找到该字符(如果有多个,取最右侧的那个),滑动2格消除“坏字符”。
为什么可以滑动2格呢?
显然,现在对比的是aca[2]和abd[2],不成立,需要后移对比串(坏字符a的匹配项在abd[0])
对比串后移一位,坏字符a下标减1,由2变为1, 可cab[1]和abd[1]不同,显然还是不成立
对比串再后移一位,至少abd[0]和abd[0]成立了,虽然在本例中我们直接找到了最终答案,但“坏字符”的主要功能体现在“因为事先知道cab[1]和abd[1]不同,即“坏字符”不匹配,所以直接跳过了cab”
总结一下规律,假设找到“坏字符”时,模式串在对比下标为j的字符,而和“坏字符”的匹配项位于下标k,为了保证至少“坏字符”处能一致,对比串必须后移j-k位
前文中提到了“有多个匹配项,选最右侧字符”,这是为了防止错漏正确答案,可因为选最右侧字符的关系,所以可能会产生倒退,如下图。
“坏字符”c的匹配项的下标甚至大于a的下标,后移-2位导致倒退,因此如果我们一般不单独使用“坏字符”规则。
“好后缀”
在上图中,bc 便是“好后缀”(其本质是:已核对正确的子字符串),好后缀有什么作用呢?
显然,如果有正确答案,答案必然包含“好后缀”,不包含“好后缀”的必然不是答案,因此我们可以通过在好后缀”的左侧寻找“一致项”来实现”移动多步“
需要注意的是:
- “一致项”可以与“好后缀”部分重复,但不能完全重复(完全重复就没意义了,因为现在被坏字符卡住了)
- 这种移动策略其实跟”可匹配的坏字符”有点像,而且因为“好后缀“本身就是后缀,因此不用担心倒退问题。
- 前文种提到对比串和模式串是从后往前对比的,正是为了配合”好后缀“规则
如图,我们找到了”一致项“,并通过“好后缀”规则滑动了3位。
为什么可以滑动3位?模式串中的“一致项”在“好后缀前三位”,因此“对比项”后移了三位,使得对比串中的bc对上了“一致项”
找不到“好后缀”时,可能会产生“过度滑动”的问题:
好在“好后缀“还有第二规则:好后缀的子后缀如果在模式串中存在且为模式串的前缀(上图中bc中的c),也可以进行滑动。
为什么必须是前缀呢?因为如果不是前缀,“一致项”就必然是完整的,但现在找不到完整的,而只有前缀才能允许不完整的一致。实际上就是想法设法利用了部分“好后缀”
两个规则的配合
上文提到了单独使用“坏字符”会产生“倒退”现象,好在我们有两个规则:“坏字符”和“好后缀,将两个规则取到的滑动值中取大值,便彻底实现了这个算法。
KMP
最后是KMP算法,该算法也挺难的,讲起来也颇为麻烦,鉴于网上已有很多资料,我暂时就不说了。
在这里放个8分钟视频链接,该视频在学习过程中给予我很大的帮助,大家可以看看www.bilibili.com/video/BV1AY…
在BM算法中,我们通过“坏字符”和“好后缀”实现了”移动多步“,KMP则是使用了next数组来实现”移动多步“。
问题来了,什么是next数组?next数组是怎么起作用的?next数组又是怎么获得的呢?
根据我自己的学习感受,前两个问题还是很好理解的,主要是获取next数组,乍一看挺简单,不就一个递推式吗?做起来却不容易,大家最好自己去实现一下。