在经典模式匹配问题中,我们经常给出了长度为n的文本字符串T和长度为m的模式字符串P,并希望明确是否P是T的一个子串。如果是,则希望找到P在T中开始位置的最低索引 j,比如 T[ j: j+m] 和P匹配,或者从T中找到所有P的开始位置索引。
模式匹配问题在Python的str类中有许多内在的行为,例如 p in T,T.find(P),T.index(P),以及T.count(P),这些行为是更复杂的行为中的子任务,例如 T.partition(P)、T.split(P) 和T.replace(P,Q)。
接下来讨论常提到的三种模式匹配算法,难度逐渐递减。
1. KMP
KMP算法的主要思想是预先计算模式部分之间的自重叠,从而当不匹配发生在一个位置时,我们继续搜寻之前就能立刻知道移动模式的最大数目,从而避免浪费的匹配。它能达到的运行时间为O(m+n), 这也是渐近最优运行时间。即在最坏情况下,任何模式匹配算法将会对文本所有字符和模式的所有字符检查至少一次。
失败函数
为了实现KMP算法,我们要先计算一个失败函数f,该函数用于表示匹配失败时P对应的位移。具体地,失败函数 f(k) 定义为P的最长前缀长度,它是P[1: k+1] 的后缀。直观讲,如果在字符P[ k+1]中找到不匹配,函数 f(k) 会告诉我们多少紧接的字符可以用来重启模式。接下来实现一下失败函数 kmp_fail :
def kmp_fail(P):
"""
模式字符串P
"""
m = len(P)
fail = [0] * m # 初始化长度为m的数组
j = 1 # 自比较,j从1开始,k从0开始
k = 0
while j < m:
if P[j] == P[k]:
fail[j] = k+1 # 值相同,数组对应位置设为 k+1,可以理解为此处值与自身第 k+1个数相同
j += 1
k += 1
elif k > 0:
k = fail[k-1] # 值不同,则让 k从前一位开始继续匹配
else:
j+=1 # 如果从开始就没有匹配到,则让j继续移动,k不变
return fail
一定要明白失败函数是自身与自身的比较,所以 j 索引从1 开始,从0开始就没有意义了。
k | 0 | 1 | 2 | 3 | 4 | 5 |
P[k] | a | b | a | c | a | b |
f(k) | 0 | 0 | 1 | 0 | 1 | 2 |
KMP模式实现
def find_kmp(T, P):
"""
T 文本字符串
P 模式字符串
"""
n, m = len(T), len(P)
if m == 0:
return 0
fail = kmp_fail(P) # 失败函数返回一个数组
j = 0
k = 0
while j < n: # 对j=0开始遍历整个T
if T[j] == P[k]:
if k == m-1:
return j - (m-1) # 对整个P匹配成功, 返回T中和P匹配的起始位置索引
j += 1
k += 1
elif k > 0:
k = fail[ k-1] # 中途匹配失败,则从失败数组中取出下次要匹配的索引
else:
j += 1 # 从起始位置就没有匹配, 让j移动,k保持为0
return -1
当中 k = fail[k-1] ,为什么传k-1而不是k呢?这点比价难理解但是特别关键。P在k处匹配失败,说明k-1处是匹配成功的,那么从k-1处得到位移索引 k' 也是没有问题的(也就是自身相同字符串的末尾位数, 但是返回的是第几位,而不是索引), 接下来也就是让 T[j] 和 k' 处对应的值继续比较也是没问题的。
举例:
T : a t c a m a l g a m a b a c d e m n g j k h l m n p o p m p i l z x c v b d ...
P: a m a l g a m a t i o n
P: a m a l g a m a t i o n
k | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |
p[k] | a | m | a | l | g | a | m | a | t | i | o | n |
f(k) | 0 | 0 | 1 | 0 | 0 | 1 | 2 | 3 | 0 | 0 | 0 | 0 |
当P中 't' 与T中 'b' 匹配失败时,得到 't' 左边 'a' 的移动值 3,此时T[11]和P[3]重新比较是很合理的。这就是AMP的精华所在,也是提高效率的地方。如果此时T[11]和P[3]匹配失败,那就是重新进入下一步 k = fail[k-1] 了。
性能分析
除去失败函数的计算外,KMP算法的运行时间明显正比于while循环的次数。假设一个长度为m的字符串和自己进行比较,它的运行时间为O(m)。那么长度为n的文本和长度为m的模式字符串的匹配,运行时间是O(m+n)。之所以使用失败函数,就是为了避免对模式中的字符串和文本中的字符串进行重复比较。
2. Boyer-Moore算法
Boyer-Moore算法的主要思想是通过增加两个可能省时的启发式算法来提升穷举算法的运行时间。大致如下:
- 镜像启发式:当测试P相对于T可能的位置时,可以从P的尾部开始比较,然后向前移动到P的头部。
- 字幕跳跃启发式:当测试P在T中可能的位置时,有着相应模式字符P[k]的文本字符T[i] = c的不匹配情况下这样处理。如果P中任意一个位置都不包含c,则将P完全移动到T[i]之后(因为它不能匹配P中的任意一个字符), 否则,直到P中出现字符并与T[i] 一致才移动P。
看起来很头晕,其实代码里体现很简单。所以我喜欢敲代码,不喜欢解释。
def find_boyer_moore(T, P):
"""
文本字符串T
模式字符串P
"""
n, m = len(T), len(P)
if m == 0:
return 0
last = {} # 初始化一个哈希表,保存P中元素最后出现时的索引
for k in range(m):
last[P[k]] = k
i = m - 1
k = m - 1
while i < n:
if T[i] == P[k]: # 从末尾到头部一致匹配
if k == 0:
return i # m个字符串正好匹配完全,返回索引i
else:
i -= 1
k -= 1
else:
j = last.get(T[i], -1) # 查看last中是否存在匹配失败的T[i],不存在返回-1
i += m - min(k, j+1) # 如果last中存在该值就要分析两种情况了
k = m - 1
return -1
文本T:* * * * * * * a * * * * * * *
模式P: * * a * b * * * 第一种
模式P: * * * * b a * * 第二种
第一种,last 中的a在b的左侧,也就是还未匹配的那部分,这时候要两a相对应,i就要移动m-(j+1)的长度, k回到末尾重新开始。
第二种,last 中的a在b的右侧,就是刚刚匹配过,i就要跳过刚匹配的部分,移动m-k的长度,k回到末尾重新开始循环。
如果T[i]在整个P中都不存在,也就是i 需要移动m个位置,直接跳过T[i],m回到P的末尾。
性能分析
如果使用传统的查找表,在最坏情况下运行时间是O(mm+ x), 最后一个功能的实现需要O(m+x),但是在哈希表的支持下,x的时间可以省掉, 但也达到O(mn)的效率,与穷举算法一样。
3. 穷举算法
要搜索穷的功能,穷举算法设计模式是一种强大的技术。至于代码就很easy了,其实刚工作时,不懂搜索原理,写了好多的穷举代码,想想也挺有意思,所以为什么公司都想要招算法功底强的程序员了,不熟悉算法真是灾难啊,哈哈...
def find_brute(T, P):
n, m = len(T), len(P)
for i in range(len(n-m+1)):
k = 0
while k < m and T[i+k] == P[k]:
k += 1
if k == m:
return i
return -1
性能分析
对穷举算法的分析就很简单了,由两个嵌套的循环组成,一个基于文本字符串,一个基于模式字符串,所以此算法正确性就能得到保证了。最坏情况下运行时间是O(mn)。