简单理解KMP算法

来源:互联网 发布:中秋节祝福网页源码 编辑:程序博客网 时间:2024/06/01 09:51

KMP算法是迄今为止最为高效的字符串匹配算法。当然,在KMP算法出现之前,有关字符串的匹配问题当然经过了一个漫长的探索过程。从一开始最简单的朴素字符串匹配算法,到Rabin-Karp算法,再到有限自动机算法等等,可以说任何一个伟大算法的诞生都不可能是一朝一夕之功,在它之前一定有大量的理论及实验的基础。所以,想要彻底理解KMP算法最好是从头开始,对整个字符串的匹配问题有个完整的了解。

但是,我在这篇博文中讲的却是对KMP算法最简单的理解。只能帮助大家了解KMP最基本的思路和应用。若要详细了解,推荐《算法导论》中的“字符串匹配”一节。我没有见过比这一章讲解得更详细的资料了。

所谓字符串匹配,解决的问题就是在一段文本(text)之中寻找我们要匹配的模式(pattern)。文本和模式都是由字符串构成的,模式的长度<=文本的长度。例如,模式为”aba”,文本为”abcbaba”,所谓字符串匹配就是在文本中查找模式出现的位置(一般以文本成功匹配的字段的第一个字符的位置表示),这里应该返回4。

一种比较简单的办法是朴素字符串匹配,就是一个字符一个字符去匹配。比如上面这个例子,一开始对文本和模式都是从头开始匹配,效果如下图:

这里写图片描述

我们发现,第三个字符处文本为”c”,而模式为”a”,于是匹配失败。那么接下来,自然而然就能想到,把整个模式向右平移一位,再次进行匹配:

这里写图片描述

很遗憾,这次模式的第一个字符就没能匹配成功。这样,每次向后移动一位,依次匹配,若出现某一时刻模式的全部字符都能和它当时所对应的文本匹配,则匹配成功一次;继续向后,直到模式的第一个字符对应的是文本的第(n - m + 1)个字符为止(其中,n为文本长度,m为字符串长度),匹配结束。也就是说当模式的最后一个字符对应的是文本的最后一个字符时,就自然没有必要再进行匹配了。

通过时间复杂度分析,可知朴素匹配算法的时间复杂度为O((nm+1)m). 但是这个里面有个问题,就是其实我们没有必要在一次匹配失败(成功)之后,向右移动一位继续。而可以向右移动不止一位。

为什么呢?还是看上面的例子,第一次匹配是,模式的第三个字符没有和文本匹配,那同时也就说明了模式的前两位和对应的文本是匹配的。我们可以确定模式未匹配的那一位所对应的文本的前一位(这里就是文本的第二位)是b。而模式的第一位是a,那么,显然,a与b不同,往后移动一位让a与b匹配就是多余的,没有必要的。

那么应该往后移动几位呢?可以想象,假如模式的第 i 位不能匹配,那么,就需要移动模式,使得模式的前k位成为模式前 i - 1 位的后缀(k在此是个小于 i 的)。

先说明一下字符串的前缀,后缀:比如字符串”abcde”中, “a”, “ab”, “abc”等等都是前缀,而”cde”, “de”, “e” 等等都是后缀。也就是说,从字符串头开始截任意小于等于字符串长度的字符,就是前缀,而从后开始截任意长度就是后缀。

回到刚才的问题,为了能够实现可能的匹配,需要模式向右偏移,使得模式以“最长的头”匹配上刚才已经匹配的文本字段的尾。也就是说寻找模式的前 i -1 项的后缀中能成为模式的最长前缀的部分。而如果后缀中找不到前缀,则将模式偏移 i 位即可。话有点抽象,看看这个例子:文本”ababababc”,模式”ababc”

这里写图片描述

同样的,第一次匹配在模式的第5个字符处失败,但是此时并没有从后面一个字符开始重新匹配,而是向右移动两位,为什么是两位呢,我们可以观察一下红箭头指的两位,因为模式的第5位匹配失败,所以,现在我们看看能否在在模式前4位的后缀中找到模式的前缀,刚好,字符串”ab”可以作为模式前4位的后缀,同时也是模式的前缀(后缀中最长的前缀)。不难发现,只有这样,才能使得这一次匹配是“可能有意义”的。

换句话说,可以通过对模式本身的计算,得出一个数组,这个数组告诉我们,如果模式的某一位不能匹配的时候,应该将模式向有偏移的位数(也就是刚才说的“后缀的最长前缀”)。然后在这个位数的基础上+1就是模式应该向右移动的位数。而因为模式一般比文本短很多,所以,我们计算这个数组消耗的计算量是可以接受的,尤其是模式比文本短很多的情况下。

具体怎么去计算这个数组,我暂且搁置,先假设这个数组已经计算完成了。接下来应该如何进行字符串的匹配。

假设这个数组名字叫pi,pi[index] 表示当模式的前index位的后缀中的最大前缀的长度。比如,模式 "ababaca" 相对应的pi数组为:

pi = [0, 0, 1, 2, 3, 0, 1]

那么,若模式的第 index + 1 位与文本不匹配时,模式需要向右移动的距离就是 pi[index] + 1

可以得到以下代码:

def kmp(pattern, text):    m, n = len(pattern), len(text)    # j为模式的下标    j = 0    # 遍历文本    for i in range(n):        # 当模式与文本不能匹配时,向右pi[j - 1] - 1位        # 也就是说,现在用模式的pi[j]位与文本匹配        # 要求j > 0的原因是如果模式的第一位都不能匹配,那就直接向右移动一格扫描文本,再从模式的第一位开始匹配即可        # 因此j = 0 且 pattern[j] != text[i]时,是什么都不用做的        while j > 0 and pattern[j] != text[i]:            j = pi[j - 1]        # 匹配成功,则继续模式下一位与文本下一位的比对        if pattern[j] == text[i]:            j += 1        # 整个模式匹配成功,输出信息        if j == m:            print("the pattern occurs at %d" % (i - j + 1))            # 一次匹配完成,重新计算偏移量            j = pi[j - 1]

代码中,我假设数组 pi 已经被提前计算出来了。

其他所有的逻辑我通过注释说明了。不再赘述。

那么怎么计算数组 pi 呢?

如果你已经理解了上面的代码,那么计算 pi 就容易了,我们只需要稍微将上面的代码改一下,改成让模式与模式自身匹配(当然是让一个模式从第1位开始与另一个模式从第2位开始匹配),将每次匹配的最多字符的长度记录下来就是所谓“后缀的最大前缀”了。

因此,我的辅助函数 helper() 如下,负责计算偏移量数组。

# 实际上是模式的前缀与模式本身匹配def helper(pattern):    m = len(pattern)    # pi的第1位是0,意思是如果pattern的第一个字符就不匹配的话,无偏移量(直接向右移动1位继续即可)    pi = [0]    k = 0    # 遍历模式,从第2位(也就是下标1开始)    for i in range(1, m):        # 不匹配,向右偏移,偏移量的计算还是依靠已经计算了部分的数组pi        # 这种思想有点类似于动态规划,根据之前的计算结果计算新的结果        # 每次计算的k值其实是当pattern[i]与文本不能匹配时的偏移量        while k > 0 and pattern[k] != pattern[i]:            k = pi[k - 1]        # 匹配成功,k + 1得到最大前缀        if pattern[k] == pattern[i]:            k += 1        pi.append(k)    return pi

把这两段代码合成:
我省去了所有注释,让代码更清楚,就是下面的样子,一共25行

def kmp(pattern, text):    m, n = len(pattern), len(text)    pi = helper(pattern)    j = 0    for i in range(n):        while j > 0 and pattern[j] != text[i]:            j = pi[j - 1]        if pattern[j] == text[i]:            j += 1        if j == m:            print("the pattern occurs at %d" % (i - j + 1))            j = pi[j - 1]def helper(pattern):    m = len(pattern)    pi = [0]    k = 0    for i in range(1, m):        while k > 0 and pattern[k] != pattern[i]:            k = pi[k - 1]        if pattern[k] == pattern[i]:            k += 1        pi.append(k)    return pi

总的来说,思路就是这样。若有什么问题,望批评指正。

0 0
原创粉丝点击