KMP算法原理及Python实现

1,120 阅读7分钟

1 前言

  • 最近学习数据结构,被KMP算法折磨地死去活来,断断续续看了将近一个礼拜,终于完完全全弄懂了其中的原理,感叹算法的巧妙构思之余,也无比佩服写出这个算法的三个大佬~
  • 废话不多说,我接下来将从头开始详细分析KMP算法,自认为本文的详细程度和逻辑思路相比其他同类博文更好,如果从头到尾认真看完,并加以一点思考,相信会有所收获。

2 BF算法

2.1 BF算法原理

  • 要想理解KMP算法,BF算法是绕不过去的。BF算法是一种简单粗暴的字符串匹配算法,模式串逐字匹配目标串,出现字符串失配就要回溯目标串指针,时间复杂度为O(mn),虽然效率低但是胜在容易理解,下图说明了BF算法的匹配过程:

BF算法.jpg

  • 可以看到目标串和模式串分别从头匹配,若字符串匹配,则各往前进一步,遇到字符串失配,模式串和目标串都要回溯,但是回溯规则不同:
  1. 模式串只要失配就必定回溯到起点。
  2. 目标串则回溯到匹配开始位置的后一个结点,例如上图中的第五步,目标串中指向元素a的指针回溯到匹配开始位置的后一个位置,即箭头所指处。
  • 总结一下:
  1. 目标串的指针从总体来看是一格一格向后推移的(虽然中间的匹配步骤会暂时地让目标串的指针向后移动,但是失配后最终还是要回溯的),而模式串指针则是一失配就回溯。
  2. 如果模式串的指针指到了最后的位置,则字符串匹配成功。
  3. 在最坏的情况下,算法一共要执行m*n次(目标串长度*模式串长度),效率很低。

2.2 BF算法的Python实现

  • 理解算法原理后,写代码应该是易如反掌,这里用了两个嵌套的for循环来实现的,效率堪忧:
def bruteforce(target, pattern):
    t_len = len(target)
    p_len = len(pattern)
    for i in range(t_len-p_len+1):
        count = 0
        for j in range(p_len):
            if pattern[j] == target[i+j]:
                count += 1
            else:
                break
        if count == p_len: 
            return i         # 返回匹配字符串的第一个索引
    return -1           # 没有匹配的字符串则返回-1
  • 测试一下功能,返回索引位置
target = "helhehelloworldt"
pattern = "hello"
bruteforce(target, pattern)
> 5

2.3 BF算法可以不回溯吗?

  • 由上面的分析可以看出,之所以BF算法效率很低,很大程度上是因为模式串的指针需要不断回溯。那么可以让BF算法的模式串指针不回溯么?举个例子试一试就知道了:

BF算法不回溯.jpg

  • 可以看到根据BF算法,强制让模式串指针一路向前的话,会漏掉本应该匹配上的字符串aab,那怎么样的模式串会导致“不回溯的BF算法”失效呢?再举个例子看看:

BF算法不回溯2.jpg

  • 同样的,在这个例子中,如果使用“不回溯的BF算法”,会漏掉原本的匹配项,但是我们的重点在于分析失效的原因。
  • 把失配前的字符串单独拿出来,从中间劈开来,把前面一段称为前缀,后面一段称为后缀,然后你会发现前后缀是相同的,如下图所示。看到这里,大家应该隐隐约约有点感觉,我再把这个例子再抽象一点:

BF算法不回溯2 (1).jpg

  • 把相同的前后缀都抽象为k,x为任意字符。虽然模式串在箭头处失配,但是实质上将失配前的字符串中的后缀当作下一个匹配项的前缀,依旧有机会匹配得上目标字符串。这句话建议多读几遍,非常重要!!!

BF算法不回溯2 (3).jpg

  • 如果能看懂上面的图和那段话,那么就能得到一个重要的结论:若失配前的字符串存在前后缀对称的关系(不限于上述例子中的样子,后文会详细说明),就有可能会导致“不回溯的BF算法”的失效。
  • 为了能够解决这个问题,同时又保证目标串指针不回溯,三位大佬写出了KMP算法,利用next数组来滚动匹配目标串,解决了这个问题。 这里说句题外话,多数博客在算法原理上都写得不清楚,很多直接就开始讲next数组、介绍前后缀、分析代码,看得我一头雾水,直到我重新开始研究BF算法,才渐渐get到原理。从逻辑上来说,正是为了不回溯目标串的指针,才不得不用next数组,而不是用了next数组,就提升了匹配算法的效率,我一开始在这里卡了很久就是因为我始终弄不明白为什么要关注模式串的前后缀。不过看到这里,大家的疑惑应该也能得到解答了。

3 KMP算法

3.1 模式串的最大前后缀

  • 如果前面的内容都看懂了的话,其实就很好理解为什么要算模式串的最大前后缀,就是为了能够在字符串失配后,能让模式串的指针移动到最大前缀的后一位来继续比较。
  • 另外由于模式串的失配可以发生在任意位置,因此需要计算模式串每个部分的最大前后缀。
  • 以模式串abaabab举个例子:
失配前的字符串最大前后缀最大前后缀长度
/-1
a/0
ab/0
abaa1
abaaa1
abaabab2
abaabaaba3
  • 至于如何看最大前后缀,通俗一点讲就是把失配前的字符串从中间劈开,看左右两边最多能对称到几个数,那几个对称的数就是最大前后缀(注意这里的对称不是指中心对称,而是指左右两边能对应相等的数)。
  • 需要注意的是如果第一个字符串就失配的话,默认把最大前后缀长度设置为-1,为了方便后续的计算,其实写成0也行的,不用纠结。
  • 这边把最大前后缀和失配前的字符串对应起来就形成了next数组,如下图所示:

layout.jpg

  • 解释一下其代表的含义:假设模式串在标红处失配,则可以把指针移动到模式串索引为2的位置即模式串的第三个字符a,这边在留意观察一下,标红处失配的字符串的最大前后缀为ab,移动到第三个字符正好是在ab的后边,完全合理!其他位置的失配情况也以此类推。
  • 值得一提的是,这个过程在代码实现中正是对应了j=next[j]这行代码,让next数组的索引j在失配后回退到最大前缀的后边,这行代码也是让初学者脑壳疼的一个点~ 但是看懂上面的过程应该就非常好理解了。
  • 还需要说明的一点就是j=next[j]的回退不一定只有一次,如果回退后的位置再次失配,则继续j=next[j]回退,直至-1这个位置为止。

3.2 计算next数组

  • 这部分的内容大部分的博客都有详细的说明,所以不特别展开去说,主要逻辑就是分别在模式串设置两个指针i和j,一前一后,开始匹配,字符匹配成功则i和j同时向后移动并添加索引j到next数组中,失配则j回退,直到i过完模式串(最后一个位置不需要,因为最后一个位置就代表了完全匹配,不存在失配)
def getNext(pattern):
    n = len(pattern)
    nextlist = [-1]
    i = 0
    j = -1
    while i < n-1:
        if j == -1 or pattern[i] == pattern[j]:
            i += 1
            j += 1
            nextlist.append(j)
        else:
            j = nextlist[j]
    return nextlist

3.3 优化next数组

  • 上面贴出来的next数组实际上还有可以优化的地方,依旧以上面的那个模式串abaabab为例:

layout (2).jpg

  • 我们可以发现,假设在标红处与a失配的字符为x,也就意味着x一定不等于a,那么经过j=next[j]回退,依旧指向了一个a,所以理所当然这个a也会失配,还得继续回退,再j=next[j]回退,还是a,依旧失配,最终会回退到-1的位置,但是每一步操作就显得多余了,还不如一步到位,直接把第一次失配位置a的next数组的2改成-1
  • 分析到这里,其实我们就知道该如何改进next数组了:若回退前后位置的字符一致,可以直接将回退前next数组的对应值改为回退后的next数组的对应值
  • 顺带一提,这个过程在代码中则对应了next[i] = next[j],这还是一个让初学者头痛的点,可以多看几遍好好理解一下~

3.4 计算nextval数组

  • nextval数组就是优化后的next数组,具体的分析在上一节详细说明过了,这里还是只贴出代码,实质上只是在求next数组代码的基础上添加了一个条件判断,应该比较好理解:
def getNextVal(pattern):
    n = len(pattern)
    nextlist = [-1]
    i = 0
    j = -1
    while i < n-1:
        if j == -1 or pattern[i] == pattern[j]:
            i += 1
            j += 1
            nextlist.append(j)
            if pattern[i] == pattern[j]:
                nextlist[i] = nextlist[j]    
        else:
            j = nextlist[j]
    return nextlist

3.5 KMP算法的Python实现

  • 能认真看到这里的话,其实写出最后的KMP算法应该是小菜一碟了,主要逻辑就是分别在目标串和模式串设置两个指针i和j,从头开始匹配,匹配成功则i和j同时向后移动,失配则j回退,当模式串的指针j能过完最后一个字符后,则成功找到匹配的字符串,实现代码如下:
def getNextVal(pattern):
    n = len(pattern)
    nextlist = [-1]
    i = 0
    j = -1
    while i < n-1:
        if j == -1 or pattern[i] == pattern[j]:
            i += 1
            j += 1
            nextlist.append(j)
            if pattern[i] == pattern[j]:
                nextlist[i] = nextlist[j]    
        else:
            j = nextlist[j]
    return nextlist
    
def KMP(target, pattern):
    t_len = len(target)
    p_len = len(pattern)
    i = 0
    j = 0
    nextlist = getNextVal(pattern)
    while i < t_len:
        if j == -1 or target[i] == pattern[j]: 
            i += 1
            j += 1
        else:
            j = nextlist[j]
        if j == p_len:
            return i-p_len    # 返回匹配的字符串的第一个索引
    return -1               # 未匹配到则返回-1

最后还是测试一下,能够正确显示索引位置,收工~

KMP('xxxxgdagexxxx','gdage')
> 4