字符串匹配之超详细最直白讲解 KMP 算法(Python 和 Java)

算法讲解

Knuth-Morris-Pratt 字符串查找算法,简称 KMP 算法,通常用于在一个字符串 S 中查找一个匹配串 P 的出现位置和出现次数

暴力解法

解决子串匹配的暴力算法很容易,子串的首部和字符串的第 i 个对齐,开始匹配,直到匹配成功或者匹配失败;如果匹配失败,则子串的首部需要和字符串的第 i+1 个对齐,并重新开始匹配

KMP 算法

为了充分利用已经匹配的字符串,可以使用两个指针,一个用于字符串 S(指针为 i),一个用于匹配串 P(指针为 j

  • 当匹配失败时,指针 i 回到匹配失败的地方,重新开始匹配;
  • 指针 j 则尽量少回移,因为匹配失败之前的字符是匹配过的,则可以找出这部分字符串的前缀和后缀的最长交集,然后将指针 j 指向此最长交集的末尾的后一个字符;
  • 移动匹配串,使得 i 指针和 j 指针对齐(实际上匹配串并未移动,只是指针 i 的移动,导致了类似的效果)

复杂度分析:
N N N 表示匹配串 P 的长度, M M M 表示字符串 S 的长度,则 KMP 算法的时间复杂度是 O ( M ) O(M) O(M),空间复杂度是 O ( N ) O(N) O(N)

相关先验知识

一些约定:

  1. 本文中,所有字符串从 0 开始编号
  2. 本文中,使用 next 数组,next [i] 表示 0~i 的字符串的最长的相同的前缀后缀的长度

前缀:
指的是字符串的子串中从原串最前面开始且不包含最后一个字符的子串,如 abcdef 的前缀有:a, ab, abc, abcd, abcde

后缀:
指的是字符串的子串中在原串结尾处开始且不包含第一个字符的子串,如 abcdef 的后缀有:f, ef, def, cdef, bcdef

注意:前缀和后缀的构成方式!!!

KMP算法引入数组:
KMP 算法引入了一个 next 数组,next [i] 表示的是前 i 的字符组成的这个子串 最长的、相同的、前缀与后缀的长度

通过一个示例讲 KMP 的流程

设定

首先给出字符串 S = 'abaabaabbabaaabaabbabaab'
给出匹配串 P = 'abaabbabaab'
其中,PS 的最后一部分相匹配

可以算出 next 数组中每个元素:
P = 'a b a a b b a b a a b'
N = '0 0 1 1 2 0 1 2 3 4 5'

如图所示,以子串 abaab 为例
KMP-pre

使用双指针:
使用一个指针 i 表示当前字符串 S 即将匹配的位置,如果 i > 0 i > 0 i>0,则说明 i − 1 i-1 i1 时已经匹配过的了;
使用一个指针 j 表示当前匹配串 P 即将匹配的位置,如果 j > 0 j > 0 j>0,则说明 j − 1 j-1 j1 时已经匹配过的了;

流程

如图所示,从 i = 0 i = 0 i=0 开始匹配,直到 i = 5 , j = 5 i = 5, j = 5 i=5,j=5SP 开始不匹配,此时, n e x t [ j − 1 ] = 2 next[j-1] = 2 next[j1]=2则说明,接下来的匹配只要从 P 的第二位开始匹配 ( 也就是第三个字符 )
根据 next 的定义可知,前两个字符已经匹配过了
匹配成功的子串 abaabnext = 2,如上图所示

KMP1
开始新一轮的匹配,需要对 ij 进行更新, i = 5 , i − 2 = 3 , j = 0 i = 5, i-2=3, j = 0 i=5,i2=3,j=0P 串向后移动 3
P 串移动等价于将 P 串索引为 2 的字符移动到不匹配的位置)

此时发现,S 的 第 13 13 13 位和 P 的第 10 10 10 位不匹配, i = 13 , j = 10 i = 13, j = 10 i=13,j=10
n e x t [ j − 1 ] = 4 next[j-1] = 4 next[j1]=4同上,说明接下来的匹配需要从P的第四位开始匹配(第五个字符)

KMP3
开始新一轮的匹配,需要对 ij 进行更新, i = 13 , i − 4 = 9 , j = 0 i = 13, i-4 = 9, j = 0 i=13,i4=9,j=0P 串向后移动 5
P 串移动等价于将 P 串索引为 4 的字符移动到不匹配的位置)

此时发现,S 的 第 13 13 13 位和 P 的第 4 4 4 位不匹配, i = 13 , j = 4 i = 13, j = 4 i=13,j=4
n e x t [ j − 1 ] = 1 next[j-1] = 1 next[j1]=1同上

KMP4
开始新一轮的匹配,需要对 ij 进行更新, i = 13 , i − 1 = 12 , j = 0 i = 13, i-1 = 12, j = 0 i=13,i1=12,j=0P 串向后移动 2
(P 串移动等价于将 P 串索引为 1 的字符移动到不匹配的位置)

此时发现,S 的 第13位和 P 的第1位不匹配, i = 13 , j = 1 i = 13, j = 1 i=13,j=1
n e x t [ j − 1 ] = 0 next[j-1] = 0 next[j1]=0同上

KMP5
开始新一轮的匹配,需要对 ij 进行更新, i = 13 , i − 0 = 13 , j = 0 i = 13, i-0 = 13, j = 0 i=13,i0=13,j=0P 串向后移动 1
匹配成功

KMP6
当我们将 P 串向后移动过程实则是通过 i 指针的增加实现的
S 串和 P 串的匹配是通过 i += 1j += 1 实现的

通过上述描述可以看到,KMP 的巧妙之处在于:利用 P 串匹配 P 串自己
next 数组是通过 P 串自身求得的,每次匹配过程中也是通过 next 数组、P 串与 S 串的不匹配位置的信息

next 数组的求解

next 数组需要知道前缀和后缀的匹配情况,从而确定最长的相同前缀和后缀的长度;实际上这也是一个可以使用 KMP 算法的字符串匹配问题,当然也可以使用暴力方式,但是那样的话,直接使用暴力解法就好,还用什么 KMP 算法呢?

求解 next
因为代码实现中的一些问题,如果直接把 next[0] = 0 代入,会导致死循环,故可以把 next[0] = -1

对于第一位字符,前面只有第 0 位字符,没有前缀,故可以令 next[1] = 0

next[i] 本质上就是求子串前 i 个字符的最长相同前缀后缀长度,也可以理解为最长相同前缀的后一位的位置,因为索引从 0 开始

对于 next[2] ,考虑前两个字符,以及两个指针 iijj
ii 初始化为 1,指向后缀的末尾;
jj 初始化为 0,指向前缀的末尾;

如果匹配成功,则最长前缀长度为 1ii += 1jj += 1,继续求 next[3],自增后,next[ii] = jj,就是最长前缀的后一位,则 next[2] = 1

如果匹配失败,则令 next[2] = 0,开始下一个的匹配,这里 next[0] = -1就可以令之后的 iijj 自增,继续匹配

图示:
初始化 next 数组所有元素为 -1,方便确定算法运算中的边界问题;

如图所示,实际计算的 next 数组比手动计算演示的向后延迟一位

因为索引是从 0 开始的,手动计算时,对应位时从一个字符开始,即相当于索引从 1 开始

KMP7

实现代码

KMP 算法

def kmp_match(s, p):
    '''KMP 算法主体'''
    i = 0
    j = 0
    next = get_next(p)
    while(i<len(s) and j<len(p)):
        if j == -1 or s[i] == p[j]:
            i += 1
            j += 1
        else:
            j = next[j]
    if j == len(p):
        return i-j
    return -1

def get_next(p):
    next = [-1]*len(p)
    next[1] = 0
    i = 1
    j = 0
    while i<len(p)-1:
        if j == -1 or p[i] == p[j]:
            i += 1
            j += 1
            next[i] = j
        else:
            j = next[j]
    return next

暴力匹配:

def naive_match(s, p):
    '''暴力匹配'''
    res = []
    m = len(s)
    n = len(p)
    for i in range(m-n+1):
        if s[i:i+n] == p:
            res.append(i)
    return res
class Solution {
    public int strStr(String haystack, String needle) {
        //两种特殊情况
        if (needle.length() == 0) {
            return 0;
        }
        if (haystack.length() == 0) {
            return -1;
        }
        // char 数组
        char[] hasyarr = haystack.toCharArray();
        char[] nearr = needle.toCharArray();
        //长度
        int halen = hasyarr.length;
        int nelen = nearr.length;
        //返回下标
        return kmp(hasyarr,halen,nearr,nelen);

    }
    public int kmp (char[] hasyarr, int halen, char[] nearr, int nelen) {
        //获取next 数组
        int[] next = next(nearr,nelen);
        int j = 0;
        for (int i = 0; i < halen; ++i) {
            //发现不匹配的字符,然后根据 next 数组移动指针,移动到最大公共前后缀的,
            //前缀的后一位,和咱们移动模式串的含义相同
            while (j > 0 && hasyarr[i] != nearr[j]) {
                j = next[j - 1] + 1;
                //超出长度时,可以直接返回不存在
                if (nelen - j + i > halen) {
                    return -1;
                }
            }
            //如果相同就将指针同时后移一下,比较下个字符
            if (hasyarr[i] == nearr[j]) {
                ++j;
            }
            //遍历完整个模式串,返回模式串的起点下标
            if (j == nelen) {
                return i - nelen + 1;
            }
        }
        return -1;
    }

    public  int[] next (char[] needle,int len) {
        int[] next = new int[len];
        // 初始化
        next[0] = -1;
        int k = -1;
        for (int i = 1; i < len; ++i) {
            //我们此时知道了 [0,i-1]的最长前后缀,但是k+1的指向的值和i不相同时,我们则需要回溯
            //因为 next[k]就时用来记录子串的最长公共前后缀的尾坐标(即长度)
            //就要找 k+1 前一个元素在next数组里的值,即 next[k+1]
            while (k != -1 && needle[k + 1] != needle[i]) {
                k = next[k];
            }
            // 相同情况,就是 k 的下一位,和 i 相同时,此时我们已经知道 [0,i-1]的最长前后缀
            //然后 k + 1 又和 i 相同,最长前后缀加1,即可
            if (needle[k + 1] == needle[i]) {
                ++k;
            }
            next[i] = k;
        }
        return next;
    }
}
  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值