前言
KMP算法是一个高效的串匹配算法。由D.E.Knuth和V.R.Pratt提出,J.H.Morris也几乎同时独立发现了这个算法,因此它被称为KMP算法。这个算法确实比较复杂,不太容易理解。但与朴素的串匹配算法相比,KMP算法的效率有本质的提高。
朴素匹配算法
为了理解KMP算法的想法,首先需要了解朴素匹配算法的缺陷,因此我们先分析朴素匹配算法。
最简单的朴素匹配算法采用最直观可行的策略:
- 从左到右逐个字符匹配;
- 发现不匹配时,转去考虑目标串里的下一个位置是否与模式串匹配。
如下图所示是一个朴素匹配示例:
上面的长串是目标串,下面是模式串。状态(0)和(1)两个串的第一对字符不同,将模式串右移一位。状态(2)顺序比较,第一对字符相同,但第二对字符不同,将模式串再右移一位…
下面是朴素匹配算法的一个实现:
def naive_matching(t, p):
m, n = len(p), len(t)
i, j = 0, 0
while i < m and j < n: # i==m说明找到匹配
if p[i] == t[j]: # 字符相同,考虑下一对字符
i, j = i+1, j+1
else: # 字符不同,考虑t中下一位置
i, j = 0, j-i+1
if i == m: # 找到匹配,返回其开始下表
return j-i
return -1 # 无匹配,返回特殊值
注:本文中p为模式串,t为目标串。
朴素匹配算法非常简单,容易理解,但其效率极低。造成其低效的主要因素是执行中可能出现回溯:匹配中遇到一对字符不同时,模式串p将向右移一个字符的位置,随后的匹配回到模式串的开始(重置 j=0),也回到目标串中前面的下一个位置,重新开始比较字符。
最坏的情况是每一趟比较都在模式串的最后遇到了字符不匹配的情况,在这种匹配中总共需要n-m+1趟比较,总的比较次数为m×(n-m+1),所以这个算法的复杂度为O(m×n)。
下面是出现最坏情况的一个实例:
目标串:0000000000000000000000000000000000000000000001
模式串:00000001
显然,如果能够解决回溯的问题,匹配的效率就能得到极大的提升,KMP算法由此诞生。
无回溯串匹配算法(KMP算法)
算法解析
首先我们需要理解一个概念:一个字符串的最长相等前后缀。
以字符串 abcabc 为例:
- 前缀的集合:{a,ab,abc,abca,abcab}
- 后缀的集合:{c,bc,abc,cabc,bcabc}
- 那么该字符的最长相等前后缀就是abc
了解最长相等前后缀之后,就可以开始着手解决KMP算法了。
先看下面的示例:
在状态(0),目标串和模式串的前七个字符对相同,第八个字符对不同。因此计算前七个字符组成的子串"abccabc"的最长相等前后缀,为"abc"(图中下划线部分分别为此时的前缀和后缀)。接着直接将状态(0)前缀的"abc"右移至后缀"abc"处,即可得到状态(1)。
通过这样查找前后缀的方式,即可解决回溯的问题。
🔔这一步是KMP算法的关键点,这一步弄懂了,KMP算法的精髓就差不多掌握了。
事实上,每一个字符前的字符串都有最长相等前后缀,而且最长相等前后缀的长度是我们移位的关键。所以我们需要构造一个pnext列表存储子串的最长相等前后缀的长度,pnext[i]表示下标为i 的字符前的字符串最长相等前后缀的长度。
接下来逐步完成KMP算法的代码。
pnext列表构造函数
首先,我们需要定义一个pnext列表的构造函数,如下:
def gen_pnext(p):
i, k, m = 0, -1, len(p)
pnext = [-1] * m # 初始列表元素全为-1
while i < m-1: # 生成下一个pnext元素
if k == -1 or p[i] == p[k]:
i, k = i+1, k+1
if p[i] == p[k]:
pnext[i] = pnext[k]
else:
pnext[i] = k
else:
k = pnext[k]
return pnext
该函数需要传入一个模式串,函数开始时建立一个元素值全为-1的列表,循环中为下表0之后的各元素赋值。
KMP算法主函数
现在假设已经根据模式串做出来pnext表,考虑KMP算法的实现。
核心的匹配循环很容易写出,如下:
while j < n and i < m: # i==m说明找到匹配
if i == -1: # 遇到-1,比较下一对字符
j, i = j+1, i+1
elif t[j] == p[i]: # 字符相等,比较下一对字符
j, i = j+1, i+1
else: # 从pnext取得p的下一字符位置
i = pnext[i]
显然前两个if分支可以合并,循环简化为:
while j < n and i < m: # i==m说明找到匹配
if i == -1 or t[j] == p[i]: # 遇到-1,比较下一对字符
j, i = j+1, i+1
else: # 从pnext取得p的下一字符位置
i = pnext[i]
下面是基于上述循环的匹配函数定义:
def matching_KMP(t, p, pnext): # p为模式串,t为目标串
j, i = 0, 0
n, m = len(t), len(p)
while j < n and i < m:
if i == -1 or t[j] == p[i]:
j, i = j+1, i+1
else:
i = pnext[i]
if i == m:
return j-i
return -1
完整KMP代码
def gen_pnext(p):
i, k, m = 0, -1, len(p)
pnext = [-1] * m
while i < m-1: # 生成下一个pnext元素
if k == -1 or p[i] == p[k]:
i, k = i+1, k+1
if p[i] == p[k]:
pnext[i] = pnext[k]
else:
pnext[i] = k
else:
k = pnext[k]
return pnext
def matching_KMP(t, p, pnext): # p为模式串,t为目标串
j, i = 0, 0
n, m = len(t), len(p)
while j < n and i < m:
if i == -1 or t[j] == p[i]:
j, i = j+1, i+1
else:
i = pnext[i]
if i == m:
return j-i
return -1
# 测试
t = "abbcabcaabbcaa" # 目标串
p = "abbcaa" # 模式串
pnext = gen_pnext(p)
print(matching_KMP(t, p, pnext))
输出结果:8
总结
显然,一次KMP算法的完整执行包括构造pnext表和实际匹配,设模式串和目标串长度分别为m和n,KMP算法的时间复杂性是O(m+n)。由于多数情况m<<n,因此可以认为KMP算法的复杂度为O(n),显然优于朴素匹配算法的O(m×n)。
🔔人们还提出了其他的模式匹配算法。另一个经典算法是由R.S.Boyer和J.S.Moore提出的BM算法,效率是KMP算法的3-5倍,是一种效率高,构思巧妙的字符串匹配算法。如果字符集较大而且匹配罕见(如在文章里找单词,在邮件里找垃圾过滤关键字),其速度可能远远高于KMP算法。
参考资料
[1] 裘宗燕. 《数据结构与算法python语言描述》