字符串模式匹配KMP算法详解(Python语言)

版权声明:本文为博主原创文章,遵循 CC 4.0 by-sa 版权协议,转载请附上原文出处链接和本声明。
本文链接:https://blog.csdn.net/qq_16137569/article/details/81740425
问题描述

  主串为ababcabcacbab,模式串为abcac,现在要求模式串在主串中出现的位置。

暴力解法

  直接用两层循环,从主串的第一个位置和模式串的第一个位置开始,依次比较字符是否相等,如果相等,则继续比较下一个;如果不相等,则从主串的第二个位置重新和模式串的字符匹配。完整匹配过程如下图所示:(图片截图与电子书,数组下标从i=1开始,和程序中有些不同,读者注意一下)

  暴力解法的代码如下,很简单:

def index(self, S, T):
    """
    :type S: str
    :type T: str
    :rtype: int
    """
    i = 0
    j = 0
    while i < len(S) and j < len(T):
        if S[i] == T[j]:    # 依次比较,相等则比较下一个字符
            i += 1
            j += 1
        else:    # 如果不相等,指针i需要回溯到上个起点的下一个位置
                 # 并从头开始比较
            i -= j - 1
            j = 0
    # while循环结束后,要么是找到合适匹配了,要么是遍历完主串都没有找到合适匹配
    if j == len(T):
        return i - j
    else:
        return -1

  如果主串长度为n,模式串长度为m,那么暴力法最坏的时间复杂度为O(mn)。但在一般情况下,其实际的执行时间近似于O(m+n),因此这个方法至今仍被采用。

KMP算法

  分析一下暴力法的匹配过程,每次重新开始匹配模式串,我们都需要从模式串的第一个位置重新开始,如果主串中有很多和模式串“部分匹配”的情况,这种方法就显得很累赘了,其实有很多比较过程都可以跳过的。改进的过程如下图所示:

  这就是著名的KMP算法,它最大的特点就是主串的指针i不需要回溯!不需要回溯!不需要回溯!重要的事情说三遍。整个匹配过程中,对主串仅需从头到尾扫描一遍即可,这对处理从外设输入的庞大文件很有效,可以边读边匹配,无序回头重读。那么KMP算法是如何做到这一点的呢?可能上面这个例子特殊性还不够,我们现在对原问题进行稍稍一点变化:主串改为acabaabaabcacaabc,模式串为abaabcac,它的匹配过程如下图所示:

  现在重点关注第三趟匹配过程,前面5个字符abaab匹配成功,在第6个字符时ac匹配失败。我们注意到abaab前两个字符和后两个字符一样,那么我们是不是就可以跳过前两个字符,直接从模式串的第三个字符开始比较?这就是KMP算法的核心所在。只要我们在之前匹配成功的模式串中发现这种“首尾相等”的情况,那么我们下一次可以直接跳过首尾相等的这一部分子串(如上图中第四趟括号中的字符);当然如果第一个字符就匹配失败,那就还是用暴力法。所以KMP算法仅当模式与主串之间存在许多“部分匹配”的情况下才比暴力法快得多。
  现在讨论一般情况,假设主串为s1s2sn,模式串为p1p2pm,本轮匹配在sipj处失败。我们需要考虑这样一个问题,si下一次应该与模式串中的哪个字符比较?假设此时应与模式中第k个字符pkk<j)继续比较,我们记next[j]=k,它表示模式中第j个字符pj与主串字符si匹配失败时,模式中重新和si进行比较字符的位置(这个定义很重要!时刻记着!)。那么si的前k1个字符一定与模式串中pkk1个字符相等(比如第四趟中s6s7=p1p2),即

(1)sik+1sik+2si1=p1p2pk1
而我们这一轮已经得到的匹配结果是si的前k1个字符和pj的前k1个字符相等(第三趟中s6s7=p4p5),即:
(2)sik+1sik+2si1=pjk+1pjk+2pj1
综合式(1)和式(2),可以得到:
(3)p1p2pk1=pjk+1pjk+2pj1
即前k1个字符和后k1个字符相等。用上图举例就是说,通过第三轮匹配,我们知道s6s7=p1p2=p4p5,所以下一次我们只需要将模式串前两位和s6s7对齐,从s8==p3开始比较就可以了,省去了从s4s7匹配的过程。
  我自己看到这里时有个疑问:为什么能保证s4s7这些位置不可能发生正确匹配?现在假设这个算法漏了s5这个位置,也不存在什么“首尾相等”的情况(next[j]找到的一定是首尾相等的情况),上一轮匹配情况是s3s4s5s6s7=p1p2p3p4p5s8p6,如果s5是一个答案,就至少要满足p1p2p3=s5s6s7=p3p4p5,然后这不又是首尾相等了?这和假设是矛盾的,假设不成立。
  现在来看看KMP基本的算法流程,假设现在我们已经求得了正确的next函数(这个KMP中最难理解的一个地方,目前暂且将它视为一个黑盒子)。在进行模式匹配过程中,每次遇到sipj1的情况,就调用next[j1]函数得到si下一个要比较的pj2,如果pk=si则继续向下比较,否则继续用next[j2]找到下一个j3,如果next找不到下一个位置了,则说明最终主串和模式串匹配成功的那部分子串中不可能包含si这个位置的字符,所以这时我们就需要放弃sisi+1处从头和模式串进行匹配。代码如下,重申一下,书中下标是从1开始的,程序中是从0开始的,所以会稍有不同:

def kmp(self, S, T):
    i = 0
    j = 0
    while i < len(S) and j < len(T):
        if j == -1 or S[i] == T[j]:    # 当匹配成功时,往下继续匹配
                                       # 当j=-1时,表示找不到下一个点,从s[i+1]开始重新和T[0]匹配
            i += 1
            j += 1
        else:    # 匹配不成功,用next(j)找到下一个比较起点
            j = next(j)    # 如果找不到下一个点,返回-1
    # while循环结束后,要么是找到合适匹配了,要么是遍历完主串都没有找到合适匹配
    if j == len(T):
        return i - j
    else:
        return -1

  这么看KMP是不是还比较简单,和暴力法相比,就是多了一个next函数。错!!!就是它花了我一下午时间!!!接下来我们来讲讲怎么实现这个next函数。(又回到下标为1的背景,懒得自己作图,见谅哈哈)
  直觉上我们只要找到上一次匹配成功的那部分模式串中首尾相等的那部分子串,然后移动模式串让首尾对齐即可,k1就是相等子串的长度。next[j]和主串无关,函数值仅取决于模式串本身,可以递推出下列模式串next函数值:

  乍一看,这不是就是用模式串匹配它自身吗?这和上面岂不是一样?NoNoNo,还是有点差别的。我们约定,当next[j]不存在时,返回0(程序中是-1,数组起始下标不同的原因),即图中第二趟匹配所示:

  第二趟中因为s2p1直接就不匹配了,p1前面已经没有前缀,当然也不存在接下来什么“首尾相等”的情况,所以就返回0这个没有实际意义的数字。然后下一轮直接从s3开始重头匹配。所以next[1]=0第一项就确定了,接下来用next[j]来递推next[j+1](不要说用暴力法找相同前缀后缀,不然KMP也就失去了时间效率上面的优势)。
  现在假设next数据前j项已经全部知道了,且next[j]=k,这说明p1pj1k1项和后k1项相等:

p1pk1=pjk+1pj1
  那现在如果加入一个pj,那么对于新得到的p1pjnext[j+1]会等于多少呢?这里分两种情况来讨论:
  (1) 当pj=pk时,就相当于前缀和后缀各增加一个相同的字符,可以接起来,所以next[j+1]=k+1
  (2) 如果pjpk,那不就接不起来了?怎么办?举个例子说明一下(用Excel截的图,将就看一下)。

  现在比方说j=5,那么k=next[5]=2(假设next[1]next[5]都是已知的),所以下一步应当比较s5p2。因为s5=p2,所以相当于前缀后缀各增加一个字符b,所以next[6]=next[5]+1=3,这对应着第(1)种pj=pk的情况。

  现在我们来计算next[7]。我们已经知道k=next[6]=3,所以用p3来和s6比较,但我们发现p3s6next[7]就是要求s7下一步应该和模式串中的那个字符比较,才能使s7前面的前缀j6尽可能长,当前abcaba,所以需要继续右移模式串,寻找符合这个要求的位置。根据KMP的思想,这个右移操作岂不是可以用next[3]=1来表示?于是比较p6==p1,但不幸的是,又不相等,只好用next[1]=0继续寻找点,发现不存在,找不到s7得前缀,只好从头开始匹配,所以next[7]=1。纵览整个过程,不匹配的情况就是不断利用next前面几轮的信息在模式串的不匹配字符位前面的子串中跳跃地寻找匹配点的过程。next的代码如下(分析了一大堆,代码结果只有一丢丢,好气啊):

def get_next(self, T):
    i = 0    # 指向主串的指针
    j = -1   # 指向模式串的指针,一开始
    next_val = [-1] * len(T)    # 要返回的next数组
    while i < len(T)-1:    # next[0]=-1,只需要求后面的m-1个值即可
        if j == -1 or T[i] == T[j]:    # 匹配成功,相同前缀长度增加1;找不到时直接开始下一位
            i += 1
            j += 1
            next_val[i] = j
        else:    # 匹配不成功则在前面的子串中继续搜索,直至找不到
            j = next_val[j]
    return next_val

  但是上面的next函数还是有一丢丢缺陷,例如模式aaaab,上面得到的结果是1,0,1,2,3,这意味着前面重复的a还是进行了比较,但是明明可以一口气跳过前面的重复部分,直接比较第4个a,我们可以通过在上面的函数中加一个小小的条件判断进行改进:

def get_next(self, T):
    i = 0    # 指向主串的指针
    j = -1   # 指向模式串的指针,一开始
    next_val = [-1] * len(T)    # 要返回的next数组
    while i < len(T)-1:    # next[0]=-1,只需要求后面的m-1个值即可
        if j == -1 or T[i] == T[j]:    # 匹配成功,相同前缀长度增加1;找不到时直接开始下一位
            i += 1
            j += 1
            if i < len(T) and T[i] != T[j]:
                next_val[i] = j
            else:     # 如果字符重复则跳过
                next_val[i] = next_val[j]
        else:    # 匹配不成功则在前面的子串中继续搜索,直至找不到
            j = next_val[j]
    return next_val

  终于把next这个大头搞定了,最后看下完整的KMP算法:

class Solution:
    # 获取next数组
    def get_next(self, T):
        i = 0
        j = -1
        next_val = [-1] * len(T)
        while i < len(T)-1:
            if j == -1 or T[i] == T[j]:
                i += 1
                j += 1
                # next_val[i] = j
                if i < len(T) and T[i] != T[j]:
                    next_val[i] = j
                else:
                    next_val[i] = next_val[j]
            else:
                j = next_val[j]
        return next_val

    # KMP算法
    def kmp(self, S, T):
        i = 0
        j = 0
        next = self.get_next(T)
        while i < len(S) and j < len(T):
            if j == -1 or S[i] == T[j]:
                i += 1
                j += 1
            else:
                j = next[j]
        if j == len(T):
            return i - j
        else:
            return -1


if __name__ == '__main__':
    haystack = 'acabaabaabcacaabc'
    needle = 'abaabcac'

    s = Solution()
    print(s.kmp(haystack, needle))    # 输出 "5"
展开阅读全文

没有更多推荐了,返回首页