一、问题提出
我们在使用串这一数据结构时,模式匹配算法是不可避免的,即给定一个小的字符串(称为模式串),返回其在主串第一次出现的位置。例如:模式串“abb”在主串“cacabbabbcb”第一次出现的位置是4,匹配成功;而模式串“abb”在主串“cacabcaabcb”中根本没有出现,因此也称“匹配失败”。
那么,选择一个快速高效的匹配算法就显得尤为重要。
二、常规思想(暴力匹配)
一般来说,我们对于匹配问题的传统思想就是“暴力匹配法”,即一个一个从头开始试直到匹配成功,这是一个很自然的思路,因此也就有对应的“BF(Brute-Force)算法”。描述如下:
i. 将模式串第一个字符与主串第一个字符比较,如果匹配,再比较模式串第二个字符与主串第二个字符,如果匹配,以此类推;
ii. 如果发现模式串有某个字符与主串相应字符不匹配,则将模式串右移一位,然后重新从模式串第一个字符开始继续与主串对应位置比较;
iii. 重复i和ii的过程直到模式串最后一个字符匹配成功,整个字串匹配成功并返回匹配成功的相应位置;
iv. 若到主串最后一个字符都没有匹配成功,则匹配失败。
下面举个例子,主串为“cacacbab”,模式串为“acb”。
第一趟:
位置: 1 2 3 4 5 6 7 8
主串: c a c a c b a b
模式串: a c b
模式串的第一个字符“a”与主串的第一个字符“c”比较,不匹配,将模式串右移一个位,重新比较;
第二趟:
位置: 1 2 3 4 5 6 7 8
主串: c a c a c b a b
模式串: a c b
模式串的第一个字符“a”与主串的第二个字符“a”比较,匹配,继续比较;
位置: 1 2 3 4 5 6 7 8
主串: c a c a c b a b
模式串: a c b
模式串的第二个字符“c”与主串的第三个字符“c”比较,匹配,继续比较;
位置: 1 2 3 4 5 6 7 8
主串: c a c a c b a b
模式串: a c b
模式串的第三个字符“b”与主串的第四个字符“a”比较,不匹配,将模式串右移一个位,重新比较;
第三趟:
位置: 1 2 3 4 5 6 7 8
主串: c a c a c b a b
模式串: a c b
模式串的第一个字符“a”与主串的第三个字符“c”比较,不匹配,将模式串右移一个位,重新比较;
第四趟:
位置: 1 2 3 4 5 6 7 8
主串: c a c a c b a b
模式串: a c b
模式串的第一个字符“a”与主串的第四个字符“a”比较,匹配,继续比较;
位置: 1 2 3 4 5 6 7 8
主串: c a c a c b a b
模式串: a c b
模式串的第二个字符“c”与主串的第五个字符“c”比较,匹配,继续比较;
位置: 1 2 3 4 5 6 7 8
主串: c a c a c b a b
模式串: a c b
模式串的第三个字符“b”与主串的第六个字符“b”比较,匹配,继续比较;
位置: 1 2 3 4 5 6 7 8
主串: c a c a c b a b
模式串: a c b _
这时发现模式串已经结束了,至此,模式串全部字符均对应上了,匹配成功,并返回其在主串的位置4.
注:在以上讲解算法的部分,“将模式串右移一个位”只是为了方便说明。实际上,串的位置肯定是不会动的,动的只是指针(即i、j),在程序中也是这样,是通过指针的移动进而控制元素之间的对比(详见代码部分)。
三、改进的模式匹配算法——KMP算法
1、算法思想
对于BF暴力匹配算法来讲,如果模式串恰好为主串的后缀的话,每趟匹配都需要一一把模式串所有字符与主串比较,会产生很多无用的“回退”,比如,主串为aaaaac,模式串为ac(请读者自行用BF算法测试)。但实际上,中间很多比较都是做的无用功,完全可以省去,下面的KMP算法就解决了这个问题。在BF算法的第二趟
主串: c a c a c b a b
模式串: a c b
比较时,“b”与“a”不匹配,若按照暴力算法,下一步应该是模式串重新将“a”与主串中第三个字符“c”进行比对,即
主串: c a c a c b a b
模式串: a c b
而事实上,只需将模式串中的“a”直接与主串中产生失配的地方“a”比对即可,而无需再回退到主串某个位置,即
主串: c a c a c b a b
模式串: a c b
这样,对于主串来说,比较顺序是一直向前进行的,没有产生任何“回退”,这就是KMP算法的中心思想。
2、算法流程
有了上述思想之后,我们来看下在执行该算法时时应该考虑的流程。
(1) 首先需要介绍前缀和后缀的概念:
对于一个字符串,其前缀是指其所有头部子串(包括本身)构成的集合,而“真前缀”就是不包括其本身的所有头部子串构成的集合,可以参考子集和真子集的比较。同样,后缀是指其所有尾部子串(包括本身)构成的集合,而“真后缀”就是不包括其本身的所有尾部子串构成的集合,注意,不论前缀还是后缀,其字符排列顺序都是从左至右,与原串相同,下面举例说明:
对于串“abacab”,其前缀是{a, ab, aba, abac, abaca, abacab},真前缀是{a, ab, aba, abac, abaca};其后缀是{abacab, bacab, acab, cab, ab, b},真后缀是{bacab, acab, cab, ab, b}.
(2) 接下来,是最长相等真前后缀长度的概念:
最长相等真前后缀长度即某串真前缀与真后缀做交集后,集合中最长的串的长度,以串“ababa”为例:
对于串“ababa”,其真前缀{a, ab, aba, abab}与真后缀{baba, aba, ba, a}的交集为{a, aba},其中“aba”最长,为3,因此串“ababa”的最长相等真前后缀长度为3
(3) KMP算法流程:
这里先不具体解释第“(1)、(2)”步到底在干什么,因为很难理解,等按照以下步骤,再加上后面的实例走一遍,就差不多可以理解了。
i. 计算模式串的所有前缀的最长相等真前后缀长度;
ii. “i.”中所有长度构成部分匹配值(PM)数组,每一个值对应一个字符;
iii. 部分匹配值按位右移,左边用-1补齐,再统一加1,得到Next数组;
iv. 在匹配过程中,如果在模式串的某个字符出现失配,以该字符对应的Next值跳到模式串相应位置,再与主串当前位置进行比较;
iv. 重复以上过程直至完全匹配成功或者匹配失败,结束程序。
听起来很难理解,我们还是看一个具体的例子,假如主串为“bacbacbcacbac”,模式串为“acbcacb”
i. 找到模式串的所有前缀:
“a”、“ac”、“acb”、“acbc”、“acbca”、“acbcac”、“acbcacb”,然后分别计算它们的最长相等真前后缀长度:
“a”的真前缀为∅,后缀为∅,交集为∅,故最长相等真前后缀长度为0;
“ac”的真前缀为{a},真后缀为{b},交集为∅,故最长相等真前后缀长度为0;
“acb”的真前缀为{a, ac},真后缀为{cb, b},交集为∅,故最长相等真前后缀长度为0;
“acbc”的真前缀为{a, ac, acb},真后缀为{cbc, bc, c},交集为∅,故最长相等真前后缀长度为0;
“acbca”的真前缀为{a, ac, acb, acbc},真后缀为{cbca, bca, ca, a},交集为{a},故最长相等真前后缀长度为1;
“acbcac”的真前缀为{a, ac, acb, acbc, acbca},真后缀为{cbcac, bcac, cac, ac, c},交集为{ac},故最长相等真前后缀长度为2;
“acbcacb”的真前缀为{a, ac, acb, acbc, acbca, acbcac},真后缀为{cbcacb, bcacb, cacb, acb, cb, b},交集为{acb},故最长相等真前后缀长度为3;
ii. 部分匹配值即以上全部最长相等真前后缀长度的集合,{0, 0, 0, 0, 1, 2, 3},得到部分匹配值表
iii. 计算Next数组
这里,Next[1]始终等于0,即规定:如果在模式串的第一个位置就发生了失配,直接将模式串后移1位,再跟主串匹配,以此类推;
下面,给出Next数组的计算公式:
若T[j]表示模式串T的第j个位置的字符,则
① j = 1 时,Next[j] = Next[1] = 0;
② Next[j] = Max{k},此时需满足1 < k < j 且模式串的子串“T[1: k-1]” = “T[j-k+1: j-1];
③ Next[j] = 1,其他情况。
iv. 开始模拟匹配过程
第一趟:
位置: 1 2 3 4 5 6 7
主串: b a c b a c b c a c b a c
模式串:a c b c a c b
模式串的第一个字符“a”与主串的第一个字符“b”比较,不匹配,将模式串右移一个位,重新比较;
第二趟:
位置: 1 2 3 4 5 6 7
主串: b a c b a c b c a c b a c
模式串: a c b c a c b
模式串的第一个字符“a”与主串的第二个字符“a”比较,匹配,继续比较;
位置: 1 2 3 4 5 6 7
主串: b a c b a c b c a c b a c
模式串: a c b c a c b
模式串的第二个字符“c”与主串的第三个字符“c”比较,匹配,继续比较;
位置: 1 2 3 4 5 6 7
主串: b a c b a c b c a c b a c
模式串: a c b c a c b
模式串的第三个字符“b”与主串的第四个字符“b”比较,匹配,继续比较;
位置: 1 2 3 4 5 6 7
主串: b a c b a c b c a c b a c
模式串: a c b c a c b
模式串的第四个字符“c”与主串的第五个字符“a”比较,不匹配,这时找到Next[4] = 1,跳到模式串的第一个字符,即“a”处,重新跟主串目前位置比较;
第三趟:
位置: 1 2 3 4 5 6 7
主串: b a c b a c b c a c b a c
模式串: a c b c a c b
模式串的第一个字符“c”与主串的第五个字符“a”继续比较(而不是跟第三个字符“c”比较),匹配,继续比较;
以此类推,模式串的“c”与主串的“c”比较,匹配;
“b”与“b”比较,匹配;
……
最后一个字符“b”与“b”也匹配,模式串已经遍历结束,匹配成功。
可以看到,在KMP算法中,前两趟比较与暴力算法BF一致,但是在第三趟时,暴力算法仍然会将模式串的第一个字符“a”与主串的第三个字符“c:比较,然后继续第四趟、第五趟,而KMP算法直接跳过了这两趟,直接将模式串的“a”与主串第五个字符“a”比较,从而节约了很多不必要的步骤,节约了时间。
四、KMP算法进一步改进
试想,若主串为“aacaaabac”,模式串为“aaaaab”,其Next数组为
当进行到如下步骤时:
位置: 1 2 3 4 5 6 7 8 9
主串: a a c a a a b a c
模式串:a a a a a b
模式串的第三个字符“a”与主串的第三个字符“c”发生失配,此时应当跳到第Next[3] = 2个字符即第二个“a”处开始比较,
位置: 1 2 3 4 5 6 7 8 9
主串: a a c a a a b a c
模式串: a a a a a b
发生失配,然后,再跳到Next[2] = 1即第一个“a”处进行比较,
位置: 1 2 3 4 5 6 7 8 9
主串: a a c a a a b a c
模式串: a a a a a b
但很明显,由于这两次都是模式串中的“a”跟主串中的“c”进行比较,是不必要的,这是因为发生了T[j] = T[Next[j]],即发生失配的字符跟下一个即将比较的字符是相等的字符,因此我们只需令Next[j] = Next[Next[j]]即可,如果依然是同一个字符,以此类推,直至下一个待匹配的字符不是当前失配的字符。
也就是说,改进后的KMP算法,不仅避免了BF算法下的“回退”现象,也能避免模式串中的重复比较,进一步加快了效率。
五、总结
在第“三、”部分结尾时,说道,前几次的比较BF与KMP是一致的,而在第三趟匹配时,KMP跳过了无用功,这是因为,主串与子串中有部分是相同的,即
主串: b a c b a c b c a c b a c
模式串: a c b c a c b
可以看到,在“c”与“a”进行比较时,前面部分三个字符串“acb”是相同的。那么我们可以这样想:既然模式串的前三个字符与主串中三个字符相同,即“部分匹配”,但恰恰在后面的一位字符处发生了失配,那么模式串跟主串完全匹配的地方肯定位于主串这三个“部分匹配”的字符后面的部分,因此我不能再重新将模式串从头开始与主串的下一个字符比较了,而是直接跳过主串的这三个“部分匹配”串,从后面再开始比较,从而节约了很多无用的比较过程。
此外,在第“四、”部分也说道了,如果模式串中发生失配的字符跟按照传统KMP算法“应该”跳转到的下一个字符相同,也应该跳过,即“避免重复比较”,从而,再一次节省了不必要的步骤。
通过以上分析,若主串长度为M,模式串长度为N,可以得到暴力匹配BF算法的时间复杂度近似为 O(M+N) 。在极端情况下,即模式串是主串的后缀的情况,比如“aaaaaaac”和“aac”,这个时候,共需要比较 (M - N + 1)*N 次,当M很大时,就近似于 O(MN) 的时间复杂度。而对于KMP算法,时间复杂度即为 O(M+N) .
这里补充一点,KMP算法中Next数组的计算方法仅仅跟模式串有关,因为只有与主串出现“部分匹配的情况下”KMP算法才奏效,或者说,KMP算法就是基于模式串在匹配中的特点进行设计的。
六、代码实现
*注:此处使用Python编写,因为电脑就只装了Python,懒得安装C++编译环境了,就为了实现算法,大致步骤还是差不多的。
另外,Next数组在实际编程时的计算过程
1、串的简单定义
class String:
def __init__(self):
# 构造函数,并设置私有变量
self.__ch = [] # 字符数组
self.__length = 0 # 串长度
def StrAdd(self, content):
# 添加操作,将字符数组content内容增加给串
for c in content:
self.__ch.append(c)
self.__length += 1
def GetElem(self, i):
# 获取串某位元素
return self.__ch[i - 1] # 串的第1个字符在数组中表现为下标0
def PrintStr(self):
# 输出串
print(self.__ch)
def StrLength(self):
return self.__length
2、BF算法
def StrMatch_BF(S, T):
i = j = 1 # i遍历主串S,j遍历模式串T
while i <= S.StrLength() and j <= T.StrLength():
if S.GetElem(i) == T.GetElem(j):
# 字符相匹配,继续比较下一个字符
i += 1
j += 1
else:
# 字符不匹配,模式串从头开始与主串下一个位置比较,主串的下一个位置是i-j+2
i = i - j + 2
j = 1
if j > T.StrLength():
# 模式串遍历完毕,匹配成功
return i - T.StrLength()
else:
# 匹配失败
return -1
3、传统KMP算法
def GetNext(T):
Next = [0]*(T.StrLength()+1) # 相当于C++中的int Next[T.StrLength()+1],这里Next[0]空出来没用
i = 1
j = 0 # 注意区分串的下标和数组的下标(看串类的定义)
while i < T.StrLength():
if j == 0 or T.GetElem(i+1) == T.GetElem(j+1):
i += 1
j += 1
Next[i] = j
else:
j = Next[j]
return Next
def StrMatch_KMP(S, T, Next):
i = j = 1
while i <= S.StrLength() and j <= T.StrLength():
if j == 0 or S.GetElem(i) == T.GetElem(j):
# 字符相匹配,继续比较下一个字符
i += 1
j += 1
else:
# 否则,跳到对应Next值处进行比较
j = Next[j]
if j > T.StrLength():
return i - T.StrLength()
else:
return -1
4、改进后的KMP算法
def GetNext_improved(T):
Next = [0] * (T.StrLength() + 1)
i = 1
j = 0
while i < T.StrLength():
if j == 0 or T.GetElem(i+1) == T.GetElem(j+1):
i += 1
j += 1
if(T.GetElem(i) != T.GetElem(j)):
Next[i] = j
else:
Next[i] = Next[j] # 避免重复比较
else:
j = Next[j]
return Next
# KMP算法本身没变,只是Next数组的计算方法有些许改动
def StrMatch_KMP(S, T, Next):
i = j = 1
while i <= S.StrLength() and j <= T.StrLength():
if j == 0 or S.GetElem(i) == T.GetElem(j):
# 字符相匹配,继续比较下一个字符
i += 1
j += 1
else:
# 否则,跳到对应Next值处进行比较
j = Next[j]
if j > T.StrLength():
return i - T.StrLength()
else:
return -1
5、代码测试与运行结果
def main():
S, T = String(), String()
S.StrAdd("cacacbab")
T.StrAdd("acb")
S.PrintStr()
T.PrintStr()
Next = GetNext(T)
Next_improved = GetNext_improved(T)
print("BF算法结果:", StrMatch_BF(S, T))
print("传统KMP算法结果:", StrMatch_KMP(S, T, Next))
print("改进KMP算法结果:", StrMatch_KMP(S, T, Next_improved))
if __name__ == "__main__":
main()
以上代码运行结果为:
[‘c’, ‘a’, ‘c’, ‘a’, ‘c’, ‘b’, ‘a’, ‘b’]
[‘a’, ‘c’, ‘b’]
BF算法结果: 4
传统KMP算法结果: 4
改进KMP算法结果: 4