简单理解KMP算法

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

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

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

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

简单理解KMP算法_第1张图片

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

简单理解KMP算法_第2张图片

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

通过时间复杂度分析,可知朴素匹配算法的时间复杂度为 O((nm+1)m) O ( ( n − m + 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”

简单理解KMP算法_第3张图片

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

换句话说,可以通过对模式本身的计算,得出一个数组 π π ,其中 π[index] π [ i n d e x ] 告诉我们,如果模式的第 index i n d e x 位不能和文本匹配时,模式 pattern p a t t e r n 的前 index1 i n d e x − 1 位中后缀中的最大前缀的长度。比如,模式 "ababaca" 相对应的数组 π π 为:

π=[0,0,1,2,3,0,1](79) (79) π = [ 0 , 0 , 1 , 2 , 3 , 0 , 1 ]

因为模式一般比文本短很多,所以,我们计算这个数组消耗的计算量是可以接受的,尤其是模式比文本短很多的情况下。这样,模式就应该向右偏移 indexπ[index] i n d e x − π [ i n d e x ] 位。并且直接从模式的 π[index]+1 π [ i n d e x ] + 1 开始与文本上次没有与模式成功匹配的位做比较。

总结一下上面的思路:

  • 模式 pattern p a t t e r n 的第 i i pattern[i] p a t t e r n [ i ] 与文本 text t e x t 的第 j j text[j] t e x t [ j ] 不匹配了,就查找 π[j] π [ j ]
  • 重新开始比较 pattern[π[j1]] p a t t e r n [ π [ j − 1 ] ] text[j] t e x t [ j ]

上面的思路写成代码如下:

def kmp(pattern, text):
    m, n = len(pattern), len(text)

    # i为模式的下标
    i = 0

    # 遍历文本
    for j in range(n):
        # 要求i > 0的原因是如果模式的第一位都不能匹配,那就直接向右移动一格扫描文本
        while i > 0 and pattern[i] != text[j]:
            i = pi[i - 1]

        # 匹配成功,则继续模式下一位与文本下一位的比对
        if pattern[i] == text[j]:
            i += 1

        # 整个模式匹配成功,输出信息
        if i == m:
            print("the pattern occurs at %d" % (j - i + 1))

            # 一次匹配完成,重新计算偏移量
            i = pi[i - 1]

代码中,我假设数组 pi 已经被提前计算出来了。那现在的问题是怎么计算数组 pi

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

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

# 实际上是模式的前缀与模式本身匹配
def helper(pattern):
    m = len(pattern)

    # pi的第1位是0,意思是如果pattern的第一个字符就不匹配的话,无偏移量
    pi = [0]
    k = 0

    # 遍历模式,从第2位(也就是下标1开始)
    for j in range(1, m):

        # 不匹配,向右偏移,偏移量的计算还是依靠已经计算了部分的数组pi
        # 这种思想有点类似于动态规划,根据之前的计算结果计算新的结果
        # 每次计算的k值其实是当pattern[i]与文本不能匹配时的偏移量
        while k > 0 and pattern[k] != pattern[j]:
            k = pi[k - 1]
        # 匹配成功,k + 1得到最大前缀
        if pattern[k] == pattern[j]:
            k += 1
        pi.append(k)
    return pi

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

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


def helper(pattern):
    m = len(pattern)

    pi = [0]
    k = 0

    for j in range(1, m):
        while k > 0 and pattern[k] != pattern[j]:
            k = pi[k - 1]
        if pattern[k] == pattern[j]:
            k += 1
        pi.append(k)
    return pi

你可能感兴趣的:(数据结构,信息检索,信息检索学习笔记,算法,信息检索)