字符串的模式匹配算法(KMP)

引子

今天主要想总结一下字符串中的一个经常出现于教材的一个经典算法,算法的要求很简单,就是给出两个字符串,判断一个字符串是否是另一个字符串的子串.子串的定位操作通常也叫做模式匹配.在算法教材中,我们通常把这两个字符串分别叫做模式串和主串,模式串是较短的那个字符串,而主串就是较长的那个,所以问题的核心就是判断模式串是否是主串的子串.
比如说,给一个主串"abcdcbaa"和模式串"cdcba",那么凭借肉眼的比较,可以得知这个模式串"cdcba"就是主串"abcdcbaa"的一个子串,专业术语也叫做模式串在主串中匹配成功.这个问题非常的简单易懂,举个例子就能让很多人明白.那么接下来就是对于这样一个问题探讨如何通过编程实现.

分析

我们将这个问题抽象化,这里存在两个对象,一个是主串,一个是模式串,一般而言,主串是比模式串要长的,当然,长度相等也可以,但不可能出现主串短于模式串的情况.设主串为s,因为它是由若干个字符组成的,不妨写作
‘ ‘ s 1 s 2 . . . s m " ``s_{1}s_{2}...s_{m}" s1s2...sm",
模式串一般记为p,不妨写作
‘ ‘ p 1 p 2 . . . p n " ``p_{1}p_{2}...p_{n}" p1p2...pn"
并且
m ⩾ n m\geqslant n mn
现在要判断p是否是s的子串,只要把p中元素跟s中元素逐个比较即可.为了方便,一定是按照从左到右的顺序,具体的过程可以简述如下:
首先拿p1跟s1对齐,判断是否相等,若相等,继续比较p2跟s2,判断是否相等,若相等,继续向下比较,否则,p模式串右移一位,跟s2对齐并继续比较.这里将s和p分别具体化,我做了一个图,读者可以从中体会p移位比较的过程(s不动)
字符串的模式匹配算法(KMP)_第1张图片
假设主串是"ababcabcabcacbab",模式串是"abcac",固定主串,移动模式串,首先让模式串的第一个字符跟主串的第一个字符对齐并比较,若相等,再比较两者的下一个字符,通过1可以看出模式串的第三个字符c和主串的第三个字符a不匹配,因此将模式串向右移动一位,也就是2这样的情况.由于2中p的第一个字符a跟它对齐的s的字符b不匹配,因而继续右移.因为主串s的长度是有限的,所以,如果模式串p右移到末尾字符与主串的末尾字符对齐时,应是理论上的最后一次比较,如果这一次比较过程中模式串的某一位跟它对齐的主串的那一位不匹配(不相等)的话,就说明这个模式串不是主串的子串,因为模式串已经走到底了,但并没有出现完全匹配的情况.何谓完全匹配?就是说模式串在某个时刻,某个位置,从头到尾跟主串的对应的字符子串完全相同,正如这里第9步,我们可以看到模式串在这个时刻,跟它对齐的主串的相应字符串和它是完全相同的.也就是我标红的部分.这个时候因为找到了完全匹配的情况,就不用再右移模式串了.而要确定模式串不是主串的子串,则要使模式串一直移动到末尾与主串对齐的时刻.这就是我们第这个算法的一个基本分析.

KMP算法

刚才的这种朴素思想的确可以用来解决判断串的模式匹配算法问题.假设主串s长m,模式串p长n,这个思想的算法可以大致写成这样:

def isSubString(s, p):
	slen = len(s)
	plen = len(p)
	#i标记比较的主串的下标,j标记比较的模式串的下标,k标记模式串右移的位数
	i = 0
	j = 0
	k = 0
	while i < len(s) and j < len(p):
		if s[i] == p[j]:
			i += 1
			j += 1
		else:
			j = 0
			k += 1
			i = k
			#如果i=len(s)-len(p),这是最后一次比较,如果完全匹配,if语句不执行,否则说明这最后一次比较有
			#不匹配的情况发生,这个时候不用再右移模式串了,因为那会使得模式串的尾部超过主串的尾部,这样做
			#可以节省一点工作,同时也为了保证打印出来的k(右移次数)不会超过len(s)-len(p)
			if i > len(s) - len(p):
				k -= 1
				break
			
			
	if j >= len(p):
		print("模式串p是主串s的子串,此时模式串移动的位数为", k)
	else:
		print("模式串p不是主串s的子串,此时模式串移动的位数为", k)

if __name__ == "__main__":
	s = "ababcabcacbab"
	p = "abcac"
	isSubString(s, p)
	p = "abcad"
	isSubString(s, p)

代码1
这里用k来记录模式串移动的位数,可以想象,要想确定模式串不是主串的子串,那么模式串一定是右移了len(s)-len§位,这个时候是最后一次比较,这个k也可以看作是模式串跟主串完全匹配时模式串的第一个字符对应的主串的索引.
最坏的情况下,需要遍历到i==len(s),至于每一次比较,其比较次数不会超过n,所以这个算法的时间复杂度为O(m*n)
这种朴素的思想有很大的改进空间,在朴素的思想看来,一旦出现模式串的某个字符跟主串的某个字符不匹配,那么将模式串向右移动一位,这未免过于缓慢,如果可以让模式串移动更多位,同时不必担心会有什么可能导致模式串与主串完全匹配的的情况被遗漏,那么这样的改进无疑会大大减少算法的时间复杂度.我们把模式串的某个字符因为与主串的某个字符不匹配,而将模式串右移,此时应该与那个原来不匹配的主串的那个字符所对应的模式串的字符在模式串的位置(索引)记录下来,整理成一个next数组.
比如说 n e x t [ j ] = k next[j] = k next[j]=k
,这表示的是模式串中第j个字符与主串中的相应字符"失配"时,在模式中需要重新和主串中的该字符进行比较的字符的位置是模式串的第k位.
那么整理next数组的意义在何处呢,就是为了解决朴素算法中模式串每次只能向右缓慢移动一位的缺陷,因为在很多情况下,完全可以加速模式串的移动.next数组建立的原理如下:
设模式串为 ′ p 1 p 2 . . . p n ′ 'p_{1}p_{2}...p_{n}' p1p2...pn
如果这个模式串满足
′ p 1 p 2 . . . p j ′ = ′ p k − j p k − j + 1 . . . p k − 1 ′ 'p_{1}p_{2}...p_{j}' = 'p_{k-j}p_{k-j+1}...p_{k-1}' p1p2...pj=pkjpkj+1...pk1
而且这个j是满足这个等式条件的最大的数,则next[k] = j.
通俗点说,如果从第k个字符起,模式串与主串失配,那么考虑模式串从头开始的前j个字符,与模式串的第k个字符的前j个字符比较,如果完全匹配,考虑能否延长这个字符串,找到这个完全相等的最大的j.就是我们要求的next[k].
n e x t [ j ] = { 0 当 j = 1 时 m a x { k ∣ 1 < k < j 且 ′ p 1 . . . p k − 1 ′ = ′ p j − k + 1 . . . p j − 1 ′ } 当 此 集 合 不 空 时 1 其 他 情 况 next [j] = \begin{cases} 0\quad当j = 1时\\ max\{k|1next[j]=0j=1max{k1<k<jp1...pk1=pjk+1...pj1}1
式1
这个式子可能不是那么直观,我稍微做一下解释.
假设主串为"abcdcacdef",模式串为"cdcde",那么在第一次匹配时,模式串的第一个元素要跟主串的第一个元素做比较,这个时候因为p1 != s1,所以需要右移模式串,使得原来p1所在位置被p[next[1]]所占据,由上式1可知,next[1] = 0,它表示当匹配失败时,原来模式串第0个元素要移动到之前p1所在的位置,所谓"第0个元素"事实上是不存在的,但为了方便理解,可以主动在第一个元素之前添一个p0,在对齐后再删除掉,这个效果实际上就是p向右移动了一位,如下图所示.
字符串的模式匹配算法(KMP)_第2张图片
接下来继续从模式串的第一个元素起向右与主串匹配.发现p1 != s2,也是从第一个元素起就不匹配了,跟第一次情况一样,让模式串p0移动到之前p1所在的位置.即继续向右移动一位.
字符串的模式匹配算法(KMP)_第3张图片
这个时候再匹配发现前面三个元素都是能匹配的,但第四个元素不匹配了,这个时候就不会是式1中j=1的情况了.这个时候要看是否有p1p2…pk-1 = pj-k+1pj-k+2…pj-1存在,也就是一个从前往后看,一个从匹配失效的前一个元素往前看,我们可以看到有p1 = p3,所以这里满足上面等式条件的k最大为2,也就是p2要和原来p4所在的位置对齐,即模式串向右移动2位.

字符串的模式匹配算法(KMP)_第4张图片接下来因为从第二个元素起不匹配了,这并不是j=1的情况,也不满足p1 = pj-k+1,因为这个时候j=2,k>=1,k 这个例子给出了全部三种情况下模式串应该如何移动,以及进一步阐述了next[j]的意义.希望读者好好体会.完整的图放上.

字符串的模式匹配算法(KMP)_第5张图片继续强调一下,next[j]表明当模式串中第j个字符与主串中相应字符失配时,在模式中需要重新和主串中该字符进行比较的字符的位置.其中如果从第一个位置就匹配失效,模式串向右移动一位,相当于传统的匹配算法.如果这个模式串满足从匹配失效处向前k个位置元素跟p的前k个元素完全相等,那么模式串右移到第k个元素在原来第j个元素所在的位置(很绕),运气好的话,会向右移动若干步,这主要看模式串本身是否存在两个完全相同的子串(而且其中一个子串是字符串的一个前缀),且这个子串越长越好.最后一种情况移动地最为彻底,就是在哪里匹配失效,就把p0移动到之前匹配失效的位置.如果模式串几乎不存在两个相同的子串,那么就不要犹豫,大胆地右移.

现在我们知道了,相对于朴素法,KMP的算法针对匹配失效的位置以及模式串本身的结构,可以分作三种情况,除了j=1的情况外,其余两种情况都很有可能会减少比较的次数,增大右移的步伐.因为模式串右移的步伐跟主串没有关系,完全取决于模式串本身,也就是说,可以对模式串的每个元素提前知道它的next值,我们把这样得到的数组称为next数组,只要知道在哪个位置匹配失效以及该位置的next数组,就可以确定要如何右移了.
假设我们已经得到模式串的每个元素的next数组,那我们实际上已经可以编写KMP算法了.

def KMP_Method(s, p, nexts):
	#检查参数的合理性,s的长度一定不会小于p的长度
	if s == None or p == None:
		print("参数不合理!")
		return -1
	slen = len(s)
	plen = len(p)
	#p肯定不是s的子串
	if slen < plen:
		return -1
	i = 0
	j = 0
	while i < slen and j < plen:
		#匹配的过程,如果失配,原来j的位置要被nexts[j]代替
		if j == -1 or list(s)[i] == list(p)[j]:
			i += 1
			j += 1
		else:
			j = nexts[j]
	#如果模式串匹配到最后一个元素+1,说明p是s的子串
	if j >= plen:
		return i-plen
	#如果i先跳出循环,说明主串都遍历完了还没有匹配完p,p一定不是s的子串
	return -1

代码2

对比代码一,唯一的不同仅在于j不会直接置0了.

这个代码只是KMP的一部分,因为关于模式串的next数组我们还没有得到,所以接下来我们就要得到next数组.

鉴于数组的第一个位置通常为0,为了照顾编程的习惯我们对式1做出如下改动(编程以此为准):
n e x t [ j ] = { − 1 当 j = 0 时 m a x { k ∣ 1 < k < j 且 ′ p 0 . . . p k ′ = ′ p j − k − 1 . . . p j − 1 ′ } 当 此 集 合 不 空 时 0 其 他 情 况 next [j] = \begin{cases} -1\quad当j = 0时\\ max\{k|1next[j]=1j=0max{k1<k<jp0...pk=pjk1...pj1}0
式2

接下来的求next数组的方法来自于经典教材数据结构与算法(严蔚敏),我觉得写得很好.非常类似于数学归纳法的思想.
在这本书中,她首先给出了next[0] = -1,这是公认的.接下来,她假设next[j] = k,然后由此推断next[j+1]的值.
因为next[j] = k, 所以在模式串中存在如下关系
p0…pk-1 = pj-k…pj-1.
其中k是满足上述等式的最大的值.此时next[j+1]的值分两种情况.
第一种,如果pj = pk,那么也就是p0…pk-1pk = pj-k…pj-1pj成立,这个时候显然next[j+1] = k+1 = next[j] + 1.
第二种,如果pj != pk, 可以把这时的pj当做主串中的元素,而把pk当做模式串中的元素,也就是说这个时候就是一个模式串匹配问题,需要找到这个时候的next[k],然后p的第next[k]位置移动到原来p的第k个位置中去,设next[k] = k’, 再比较pj 是否与pk’相等,若相等,则回到第一种,next[k+1] = k’ + 1 = next[k] + 1 = next[next[j]] + 1.
若不相等,则找next[k’] = k’’, 他一定满足p0…pk’’-1 = pj-k’’…pj-1,此时再比较pj和pk’‘是否匹配,…
一直这样做下去,直到出现某个k’满足p0…pk’ = pj-k’…pj.或者不存在任何的k’(1 根据这个思想得到的求next数组的方法参考如下:

def getNext(p, nexts):
	i = 0
	j = -1
	nexts[0] = -1
	while i < len(p):
		#当我们已知nexts[i]的值时,就要比较p[i]与p[nexts[i]]是否匹配了,若匹配,nexts[i+1] = nexts[i] + 1, 否则令j = nexts[i],比较p[j]是否与p[i]匹配.以此类推
		if j == -1 or list(p)[i] == list(p)[j]:
			i += 1
			j += 1
			nexts[i] = j
		else:
			j = nexts[j]

就这样吧!
这一块表述比较粗糙,可能自己又遗忘了些什么了吧.

def getNext(p, nexts):
	i = 0
	j = -1
	#如果p从第一个元素就与Si不匹配,那么p[0]与Si的下一个元素S(i+1)比较
	nexts[0] = -1
	#
	while i < len(p):
		if j == -1 or list(p)[i] == list(p)[j]:
			i += 1
			j += 1
			nexts[i] = j
		else:
			j = nexts[j]
			
def match(s, p, nexts):
	#检查参数的合理性,s的长度一定不会小于p的长度
	if s == None or p == None:
		print("参数不合理!")
		return -1
	slen = len(s)
	plen = len(p)
	#p肯定不是s的子串
	if slen < plen:
		return -1
	i = 0
	j = 0
	while i < slen and j < plen:
		print("i=", str(i), ",", "j=", str(j))
		if j == -1 or list(s)[i] == list(p)[j]:
			i += 1
			j += 1
		else:
			j = nexts[j]
	if j >= plen:
		return i-plen
	return -1
	
if __name__ == "__main__":
	s = "waterbottlewaterbottle"
	p = "ebottlewat"
	p = "erbottlewat"
	lens = len(p)
	nexts = [0] * (lens+1)
	getNext(p, nexts)
	print("next数组为:", str(nexts[0]), end=",")
	i = 1
	while i < lens - 1:
		print(str(nexts[i]), end=",")
		i += 1
	print("\n")
	print("匹配结果为: ", str(match(s, p, nexts)))

字符串的模式匹配算法(KMP)_第6张图片
希望我能坚持写下去.

你可能感兴趣的:(数据结构与算法)