字符串匹配的kmp算法 及 python实现
一:背景
给定一个主串(以 S 代替)和模式串(以 P 代替),要求找出 P 在 S 中出现的位置,此即串的模式匹配问题。
Knuth-Morris-Pratt 算法(简称 KMP)是解决这一问题的常用算法之一,这个算法是由高德纳(Donald Ervin Knuth)和沃恩 · 普拉特在 1974 年构思,同年詹姆斯 ·H· 莫里斯也独立地设计出该算法,最终三人于 1977 年联合发表。
在继续下面的内容之前,有必要在这里介绍下两个概念:真前缀 和 真后缀。
由上图所得, "真前缀" 指除了自身以外,一个字符串的全部头部组合;"真后缀" 指除了自身以外,一个字符串的全部尾部组合。
二:朴素字符串匹配算法
初遇串的模式匹配问题,我们脑海中的第一反应,就是朴素字符串匹配(即所谓的暴力匹配)
暴力匹配的时间复杂度为 O(nm),其中 n 为 S 的长度,m 为 P 的长度。很明显,这样的时间复杂度很难满足我们的需求。
接下来进入正题:时间复杂度为 Θ(n+m) 的 KMP 算法。
三:KMP 字符串匹配算法
3.1 算法流程
以下摘自阮一峰的字符串匹配的 KMP 算法,并作稍微修改。
(1)
首先,主串 "BBC ABCDAB ABCDABCDABDE" 的第一个字符与模式串 "ABCDABD" 的第一个字符,进行比较。因为 B 与 A 不匹配,所以模式串后移一位。
(2)
因为 B 与 A 又不匹配,模式串再往后移。
(3)
就这样,直到主串有一个字符,与模式串的第一个字符相同为止。
(4)
接着比较主串和模式串的下一个字符,还是相同。
(5)
直到主串有一个字符,与模式串对应的字符不相同为止。
(6)
这时,最自然的反应是,将模式串整个后移一位,再从头逐个比较。这样做虽然可行,但是效率很差,因为你要把 "搜索位置" 移到已经比较过的位置,重比一遍。
(7)
一个基本事实是,当空格与 D 不匹配时,你其实是已经知道前面六个字符是 "ABCDAB"。KMP 算法的想法是,设法利用这个已知信息,不要把 "搜索位置" 移回已经比较过的位置,而是继续把它向后移,这样就提高了效率。
(8)
i | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
---|---|---|---|---|---|---|---|---|
模式串 | A | B | C | D | A | B | D | '\0' |
next[i] | -1 | 0 | 0 | 0 | 0 | 1 | 2 | 0 |
怎么做到这一点呢?可以针对模式串,设置一个跳转数组int next[]
,这个数组是怎么计算出来的,后面再介绍,这里只要会用就可以了。
(9)
已知空格与 D 不匹配时,前面六个字符 "ABCDAB" 是匹配的。根据跳转数组可知,不匹配处 D 的 next 值为 2,因此接下来从模式串下标为 2 的位置开始匹配。
(10)
因为空格与C不匹配,C 处的 next 值为 0,因此接下来模式串从下标为 0 处开始匹配。
(11)
因为空格与 A 不匹配,此处 next 值为 - 1,表示模式串的第一个字符就不匹配,那么直接往后移一位。
(12)
逐位比较,直到发现 C 与 D 不匹配。于是,下一步从下标为 2 的地方开始匹配。
(13)
逐位比较,直到模式串的最后一位,发现完全匹配,于是搜索完成。
3.2 next 数组是如何求出的展开目录
next 数组的求解基于 “真前缀” 和 “真后缀”,即next[i]
等于P[0]...P[i - 1]
最长的相同真前后缀的长度(请暂时忽视 i 等于 0 时的情况,下面会有解释)。我们依旧以上述的表格为例,为了方便阅读,我复制在下方了。
i | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
---|---|---|---|---|---|---|---|---|
模式串 | A | B | C | D | A | B | D | '\0' |
next[i] | -1 | 0 | 0 | 0 | 0 | 1 | 2 | 0 |
- i = 0,对于模式串的首字符,我们统一为
next[0] = -1
; - i = 1,前面的字符串为
A
,其最长相同真前后缀长度为 0,即next[1] = 0
; - i = 2,前面的字符串为
AB
,其最长相同真前后缀长度为 0,即next[2] = 0
; - i = 3,前面的字符串为
ABC
,其最长相同真前后缀长度为 0,即next[3] = 0
; - i = 4,前面的字符串为
ABCD
,其最长相同真前后缀长度为 0,即next[4] = 0
; - i = 5,前面的字符串为
ABCDA
,其最长相同真前后缀为A
,即next[5] = 1
; - i = 6,前面的字符串为
ABCDAB
,其最长相同真前后缀为AB
,即next[6] = 2
; - i = 7,前面的字符串为
ABCDABD
,其最长相同真前后缀长度为 0,即next[7] = 0
。
那么,为什么根据最长相同真前后缀的长度就可以实现在不匹配情况下的跳转呢?举个代表性的例子:假如i = 6
时不匹配,此时我们是知道其位置前的字符串为ABCDAB
,仔细观察这个字符串,首尾都有一个AB
,既然在i = 6
处的 D 不匹配,我们为何不直接把i = 2
处的 C 拿过来继续比较呢,因为都有一个AB
啊,而这个AB
就是ABCDAB
的最长相同真前后缀,其长度 2 正好是跳转的下标位置。
python实现,如下:
def partial_table(p): '''''partial_table("ABCDABD") -> [0, 0, 0, 0, 1, 2, 0]''' prefix = set() postfix = set() ret = [0] for i in range(1, len(p)): prefix.add(p[:i]) postfix = {p[j:i + 1] for j in range(1, i + 1)} ret.append(len((prefix & postfix or {''}).pop())) return ret print partial_table("ABCDABD") #[0, 0, 0, 0, 1, 2, 0]
全部代码:
#coding=utf-8 def kmp_match(s, p): m = len(s); n = len(p) cur = 0 # 起始指针cur table = partial_table(p) while cur <= m - n: #只去匹配前m-n个 for i in range(n): if s[i + cur] != p[i]: cur += max(i - table[i - 1], 1) # 有了部分匹配表,我们不只是单纯的1位1位往右移,可以一次移动多位 break else: #for 循环中,如果没有从任何一个 break 中退出,则会执行和 for 对应的 else #只要从 break 中退出了,则 else 部分不执行。 return True return False # 部分匹配表 def partial_table(p): '''''partial_table("ABCDABD") -> [0, 0, 0, 0, 1, 2, 0]''' prefix = set() postfix = set() ret = [0] for i in range(1, len(p)): prefix.add(p[:i]) postfix = {p[j:i + 1] for j in range(1, i + 1)} ret.append(len((prefix & postfix or {''}).pop())) return ret print partial_table1("ABCDABD") print kmp_match("BBC ABCDAB ABCDABCDABDE", "ABCDABD")
参考 如何理解 KMP