字符串匹配是一个基本且简单的任务,如果字符串 S1 和 S2 ,在 S1 中寻找是否包含 S2 ,用暴力的方法可以是从 S1 的第一个字符开始与 S2 匹配,然后一个字符一个字符的向后挪动再做匹配,但是这样是非常浪费时间的,那我今天我们来看看KMP算法是怎么做的。
本文例子和思路来源于:
http://www.ruanyifeng.com/blog/2013/05/Knuth–Morris–Pratt_algorithm.html
以及Jake Boxer的文章
假设原字符串 S1 为:
说明:在字符串比较中,一般是要求原始文本( S1 长度要大于 S2 的,所以本例也是这样);后续的过程展示中,黄色填充的区域代表所对比的字符不同,粉色则代表相同。
首先,将 S1 与 S2 对齐,比较二者第一个字符,是 B 与 A ,显然不同,那么将 S2 向后移动一位继续比较;
由于第一次比较发现两个字符串第一个字符不同,那么 S2 后移一位,将 S2 的第1个字符 A 与 S1 的第二个字符 B 做比较,发现仍然不同,所以继续后移动并继续比较;
继续后移,发现 S2 的第一个字符与 S1 的前四个字符( B、B、C 和空格)均不相同,那么继续后移一位。
再次后移一位后,发现 S2 的第一个字符 A 与 S1 的第5个字符 A 相同,如下图所示。也就是 S1[4]=S2[0] (这里索引我用从0开始记),然后就开始比较两个字符串的下一位,即 S1[5]与S2[1] 是否相同,发现也相同,如再下面一张图所示。那么继续比较两个字符串的下一位。
不断比较 S1和S2 的后面的字符,发现 S1[4]到S1[9] 与 S2[0]到S1[5] 完全相同,但 S2[6] 是 D ,与 S1[10] (空字符)不同,因此此时没有在 S1 中匹配到 S2 。
常规思路下,应当将 S2 后移一位,比较 S2[0]与S1[5] 是否相同,然后重复前面的步骤,但是这是一个冗余、浪费的过程,因为对于 S1[5]到S1[9] 实际上已经参与过比较过程了,因此重新比较一次是浪费的。那么应该怎么做呢?
请看图6,当发现 S2 的字符 D 与对应位置的 S1 的字符(空字符)不匹配时,其实隐含的信息是: S2 前面的字符串是 ABCDAB ,即为 Sfind ,且 Sfind 已经在 S1 中找到了对应的字符串,这样,不要想图7一样把字符串 S2 移动回到和 S1[5] 对应的位置去比较,而是移动到更后面的位置,比如将 S2 向后移动到 S2[0] 与 S1[8] 对齐的位置,那么这个需要移动到的位置应该怎么确定?根据部分匹配值去确定。部分匹配值表如图8所示
介绍部分匹配值之前,先要介绍“前缀”和“后缀”的概念。
“前缀”指除了最后一个字符以外,一个字符串的全部头部组合;”后缀”指除了第一个字符以外,一个字符串的全部尾部组合。
举个例子:字符串’sharlock’
前缀为:s , sh , sha , shar , sharl , sharlo , sharloc 共7个;
后缀为:harlock , arlock , rlock , lock , ock , ck , k 共7个。
部分匹配值就是字符串的每个子串的”前缀”和”后缀”共有元素中长度最长的这个长度值。
以 S2 (ABCDABD)为例:
-”A”的前缀和后缀都为空集,共有元素的长度为0;
-”AB”的前缀为[A],后缀为[B],共有元素的长度为0;
-”ABC”的前缀为[A, AB],后缀为[BC, C],共有元素的长度0;
-”ABCD”的前缀为[A, AB, ABC],后缀为[BCD, CD, D],共有元素的长度为0;
-”ABCDA”的前缀为[A, AB, ABC, ABCD],后缀为[BCDA, CDA, DA, A],共有元素为”A”,长度为1;
-“ABCDAB”的前缀为[A, AB, ABC, ABCD, ABCDA],后缀为[BCDAB, CDAB, DAB, AB, B],共有元素为”AB”,长度为2;
-”ABCDABD”的前缀为[A, AB, ABC, ABCD, ABCDA, ABCDAB],后缀为[BCDABD, CDABD, DABD, ABD, BD, D],共有元素的长度为0。
通过上述分析可以得到图8的部分匹配值表。这里我要说明一点,图8这个表很多人的理解有误区,这个表的形式是每个单一的字符(如 A,B,C 等)对应一个数字(部分匹配值),其实不是的,每个匹配值对应的是它这个位置的字符以及前面所有字符组成的子串所对应的部分匹配值。这个一定要理解好,不然你会觉得这个表很怪,具体的说,图8第五列的A,对应了数字5,其实质是说 S2 的子串之一“ABCDA”对应的部分匹配值是1,回顾部分匹配值的定义,其主语是字符串的“子串”,这点一定要好好理解,不然写代码的时候就会蒙逼了。
刚才说了,我们要确定的是 S2 需要向后移动几位,这个移动的位数计算方式如下:
移动位数 = 已匹配的字符数 - 对应的部分匹配值
已知 S1 的空字符与 S2 的 D 不匹配时,前面六个字符” ABCDAB ”是匹配的。查表可知,最后一个匹配字符 B 对应的”部分匹配值”为2,因此按照下面的公式算出向后移动的位数=6-2=4。因此,在图6的基础上,将 S2 向后移动4位,得到如图9所示的结果。发现黄色位置不匹配,那么 S2 应当继续后移。
因为图9中黄色位置 S1 的空字符与 S2 的 C 不匹配,因此 S2 继续后移。这时,已匹配的字符数为2(”AB”),对应的”部分匹配值”为0。所以,移动位数 = 2 - 0,结果为 2,于是将搜索词向后移2位,得到图10的结果。
图10黄色区域中 S1 为空字符, S2 对应的是 A ,所以说此时 S2[0] 就没有匹配上,那么向后移动一位(这种 S2 的首个字符没有匹配上就直接按照上面步骤2或者步骤3的过程,直接后移一位)。得到图11的结果。
对于图11中的 S1和S2 进行逐位比较,直到发现 C与D 不匹配。于是,移动位数 = 6(ABCDAB的长度) - 2( S2 子串ABCDAB的部分匹配值),继续将搜索词向后移动4位。得到图12的结果。
对于图12,逐位比较,直到搜索词的最后一位,发现完全匹配,于是搜索完成。如果还要继续搜索(即找出全部匹配),移动位数 = 7 - 0,再将搜索词向后移动7位。
以上就是KMP算法的思想和流程。
“部分匹配”的实质是,有时候,字符串头部和尾部会有重复。比如,”ABCDAB”之中有两个”AB”,那么它的”部分匹配值”就是2(”AB”的长度)。搜索词移动的时候,第一个”AB”向后移动4位(字符串长度-部分匹配值),就可以来到第二个”AB”的位置。
# 普通匹配,返回从原字符串的第几位成功匹配
def naive_match(s, p):
m = len(s)
n = len(p)
for i in range(m-n+1):#起始指针i
if s[i:i+n] == p:
print ('match from %d'%i)
return i
return 'No match'
# KMP算法
## KMP
def kmp_match(s, p):
m = len(s); n = len(p)
cur = 0#起始指针cur
table = partial_table(p)
while cur<=m-n:
for i in range(n):
if s[i+cur]!=p[i]:
cur += max(i - table[i-1], 1)#有了部分匹配表,我们不只是单纯的1位1位往右移,可以一次移动多位
break
else:
return True
return False
## 部分匹配表
def partial_table(p):
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