python3实现KMP算法进行字符串匹配图文详解

最近遇到一个字符串内查找指定子字符串出现位置的算法问题,最后虽然用暴力匹配法解决了问题,但是时间效率非常差。看到网上说可以用KMP模式匹配算法进行优化,搜了很多资料才基本弄懂,这里记录一下自己的理解和实现代码。

本文并没有重复造轮子,是基于结尾处两篇大神的参考文章的一些自我理解。大神的文章深入浅出通俗易懂,建议先行食用。

实现效果

实现的效果类似于python中字符串的find方法

m='this is a great world'
m.find('great')
Out[43]: 10
m.find('nice')
Out[44]: -1

在m字符串中查找子字符串great出现的位置,最后返回的下标是10,也就是g出现的位置。而如果没有找到,返回-1。

暴力匹配的缺点

为了便于表述,将被匹配的字符串称为主字符串,长度设定为m,指针为i,用来匹配的字符串称为子字符串,长度设定为n,指针为j。

查找子串出现的位置,很容易就想到暴力匹配的方法,从主字符串下标0开始,对子字符串的每个元素依次进行匹配,如果不满足就移到下标1,重复匹配。一直到下标m-n+1如果还没找到说明肯定找不到了,这时候重复匹配才结束,所以最坏时间复杂度为O((m-n+1)*n)。

很显然这里的两个指针i和j一直在主字符串和子字符串上反复移动,消耗了大量时间,但是这种来回移动是没有必要的

看下面的例子。

主字符串abacdef,子字符串为abab。当进行第一次匹配的时候,i移动到下标为3的c,j也移动到下标为3的b,此时对应的元素不相等。按照暴力匹配的方式,此时i要回到1,而j要回到0,重新开始匹配

但是i和j真的有必要回去吗?

1-original.png

先看主字符串的指针i,因为已经知道下标为1的元素b肯定和子字符串的第一个元素a不同,那么就没有必要再比较了,而下标为2的元素a肯定和子字符串第一个元素a相同,也没有必要重复比较一次,所以i完全可以不用变。同样的对于子字符串的指针j,因为已经知道前一个字符a和开头的字符a相同,似乎直接回到下标为1的元素继续匹配就行。

所以简单的一分析就发现i根本不需要动,只需要将j从3移动到1就可以了,接下来用m[i]n[j]继续比较。

我们这里只是感性地分析了一下,KMP算法则是真正用算法进行了实现。

KMP算法原理

最长前缀

首先针对字符串,有个前缀和后缀的概念要先理解清楚。前缀指的是从首个字符开始一直到任意一个非结尾字符组成的子字符串,例如xiaofu的前缀就可以是x,xi,xia,xiao,xiaof后缀指的是从结尾字符开始往前一直到非开头字符的子字符串,例如xiaofu的后缀就可以是u,fu,ofu,aofu,iaofu

前缀和后缀的集合中相交的最长子字符串称之为最长前缀。例如字符串abacaba的前缀有a,ab,aba,abac,abaca,abacab,而后缀有bacaba,acaba,caba,aba,ba,a,交集只有aba,所以最长前缀也是aba

2-max.png

理解最长前缀是理解KMP算法的关键,因为不管i和j在什么位置,对于已经成功匹配的部分,主字符串和子字符串的内容相同,针对这部分内容,如果没有最长前缀,则说明i不用变(回去了也不可能有匹配)直接j变为0继续开始匹配。如果有最长前缀,那么最长前缀部分是可以跳过不用重复匹配的,i同样不用变(让j往前一点同样达到目的)j也不用变为0了。

下面用我自己的话来表述一下KMP算法的原则就是,主字符串的指针i永远不回溯,子字符串的指针j根据此时已匹配内容的最长前缀进行适当回溯

例如下图中,当i=4和j=4时出现元素不相等的情况,此时i不变,而子字符串中0到j-1位置为前4个字符abab,最长前缀为2,其下一位也就是j=2的位置。之后从j=2和i=4继续比较,周而复始。

3-kmp.png

所以如果能够知道子字符串中每一位所对应回溯的新的位置问题就非常简单了。在KMP中有一个专门的list用来记录这个,这个记录每一位对应回溯位置的list称之为该字符串的next数组(也有的叫fail数组,总之会有个数组),next[j]就是在j位置不匹配时候要回溯的下标。

代码实现

下面首先来看看如何得到一个字符串的next数组,然后在已知next数组的情况下再去遍历就容易很多。

next数组部分

求一个字符串的next数组,其实也是一个匹配问题,只不过相当于子字符串自己和自己进行匹配。此时主字符串从下标1开始遍历,子字符串从下标0开始,逐个对比。在主字符串进行遍历的时候,如果成功匹配,说明下标为i位置的最长前缀就是下标j(长度j+1),那么在i+1位置如果没有匹配,就回到下标j+1(长度j+2)的位置继续匹配,所以分别把i和j自加1然后next[i]=j放入next数组。如果没有匹配成功,则通过查找next数组回到子字符串当前的最长前缀处继续匹配。

最后再考虑一下特殊情况,下标为1的元素如果不匹配,回到下标0继续匹配。而下标为0的地方不匹配,则认为规定是-1,强制主字符串的指针往前进一位再比较。

这里尤其要注意下标和长度有一个1的差距

def getNext(p: str) -> list:
    """
    当str在list某个下标位置匹配失败时候,从list对应下标的值的位置开始重新匹配
    """
    next = [0] * (len(p))  # 不事先赋值后面不能用下标去修改
    next[0] = -1  # 人为规定
    i = 0
    j = -1
    while i < len(p) - 1:  # 因为会自加1所以注意临界条件
        if j == -1 or p[i] == p[j]:
            # 意味着在下标为i的地方,最长前缀的长度为j+1,所以如果是在第i+1位匹配失败,意味着最长前缀长度为j+2
            # 所以要继续从下标为j+1(也就是长度j+2)的地方开始继续查找
            i += 1
            j += 1
            next[i] = j
        else:
            # 从前面已经匹配的地方再次查找
            j = next[j]
    return next

这里比较巧妙的就是匹配失败的时候因为j一定比i小,所以next数组中一定已经有了next[j]的值,可以用来进行查询再回去重新匹配。

字符串匹配部分

已知next数组的情况下,再进行字符串匹配就容易多了。

def KMP(m: str, s: str) -> int:
    """
    在m中查找n第一次出现的下标
    :param m: 被匹配的长字符串
    :param s: 用来做模式匹配的短字符串
    :return: 返回匹配成功后的起始位置的下标
    """
    next = getNext(s)
    i = 0
    j = 0
    while i < len(m) and j < len(s):
        if j == -1 or m[i] == s[j]:
            i += 1
            j += 1
        else:
            j = next[j]
    if j == len(s):
        return i - j
    return -1

如果成功匹配,i和j都自加1,如果没有匹配,j查询next数组回到新的位置再继续匹配。最后两种结果,如果子字符串遍历完成说明成功找到,返回起始位置i-j,否则说明主字符串遍历完成说明没有找到,返回-1。

因为一共进行了两次便利,遍历子字符串求next数组,遍历主字符串进行真正的查找,所以时间复杂度为O(m+n)

完整代码如下

#! /usr/bin/env python
# -*- coding: utf-8 -*- 
# @author: xiaofu
# @date: 2020-Sep-01

def getNext(p: str) -> list:
    """
    当str在list某个下标位置匹配失败时候,从list对应下标的值的位置开始重新匹配
    """
    next = [0] * (len(p))  # 不事先赋值后面不能用下标去修改
    next[0] = -1  # 人为规定
    i = 0
    j = -1
    while i < len(p) - 1:  # 因为会自加1所以注意临界条件
        if j == -1 or p[i] == p[j]:
            # 意味着在下标为i的地方,最长前缀的长度为j+1,所以如果是在第i+1位匹配失败,意味着最长前缀长度为j+2
            # 所以要继续从下标为j+1(也就是长度j+2)的地方开始继续查找
            i += 1
            j += 1
            next[i] = j
        else:
            # 从前面已经匹配的地方再次查找
            j = next[j]
    return next


def KMP(m: str, s: str) -> int:
    """
    在m中查找n第一次出现的下标
    :param m: 被匹配的长字符串
    :param s: 用来做模式匹配的短字符串
    :return: 返回匹配成功后的起始位置的下标
    """
    next = getNext(s)
    i = 0
    j = 0
    while i < len(m) and j < len(s):
        if j == -1 or m[i] == s[j]:
            i += 1
            j += 1
        else:
            j = next[j]
    if j == len(s):
        return i - j
    return -1


if __name__ == "__main__":
    str1 = 'ababababca'
    str2 = 'bab'
    # print(getNext('abababca'))
    print(KMP(str1, str2))

注意事项

KMP算法虽然理论上的时间复杂度有提升,但是如果子字符串并没有规律进行太多的重复,其实优化是不明显的。同时,细心的朋友应该也注意到了,有的时候在匹配不成功时会连续进行好几次next数组查询,最后j还是回到了比较靠前的位置,这中间的重复next查询也是没有必要的,所以才有了后面KMP算法的改进,下次我们再看。

参考

  1. https://www.zhihu.com/question/21923021/answer/281346746

  2. https://zhuanlan.zhihu.com/p/41047378

  • 2
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值