KMP算法学习(包含所有next数组的求解)

2025.1.14发现之前写的有不少错误,进行了一些勘误和补充

kmp算法:

Knuth-Morris-Pratt 字符串查找算法,简称为 “KMP 算法”,常用于在一个文本串 S 内查找一个模式串 P 的第一次出现位置,这个算法由Donald Knuth、Vaughan Pratt、James H. Morris 三人于 1977 年联合发表,故取这 3 人的姓氏命名此算法。KMP 是一个解决模式串在文本串是否出现过,如果出现过,最早出现的位置的经典算法。KMP 方法算法就利用之前判断过的信息,通过一个 next 数组,保存模式串中前后最长公共子序列的长度,每次回溯时,通过 next 数组找到,前面匹配过的位置,省去了大量的计算时间。

所以kmp算法的关键也是next数组它指明了如果当前位置不匹配时的最佳回溯位置(而不是像普通暴力法一样永远从头开始)。这也是kmp的精髓模板串指针永不回退!!!

next数组与最长相等前后缀的长度有关,我们先从求解最长相等前后缀的长度开始:

前缀、后缀的定义:不包含尾字母\首字母的其余字母的组合

Eg:对于字符串"aabaa"为例

前缀集合{a, aa, aab, aaba, aaba} 后缀集合{a, aa, baa, abaa} 所以这个字符串的最长相等前后缀的长度就为2(两个集合中最长相等的)。

所以我们能够将一字符串的每一部分的最长相等前后缀长度求出,以"aabaac"为例:

val[i]数组是当前位置及之前位置的子串最长相等前后缀长度

next[i]数组是在出现不匹配的情况下的下一次匹配的回溯的位置

(val和next的详细求解过程在后面)

当下标i从0开始时
​
i           0   1   2   3   4   5
​
str[i]      a   a   b   a   a   c
​
val[i]      0   1   0   1   2   0
​
next_0数组的每一位都比next_1数组少1,也就是next_1[i] = val[i - 1] (i > 0, 初始化next_1[0] = -1)
​
next_0[i]  -1   0   1   0   1   2
当下标i从1开始时
​
i           1   2   3   4   5   6
​
str[i]      a   a   b   a   a   c
​
val[i]      0   1   0   1   2   0
​
next_1数组就可以通过val[]数组得到,next_1[i] = val[i - 1] + 1(i > 0, 初始化next_1[0] = 0),大家先往下看之后会解释为什么
​
next_1[i]   0   1   2   1   2   3

根据《数据结构 C语言版》上对于next[i]的归纳如下(当下标从1开始):

            0;              当i=1时
next_1[i] =     k | max{s[1]...s[k-1] = s[i-k+1]...s[i-1]};//即能够匹配的最长前后缀长度      当存在匹配的前后缀集合时
            1;              其他情况

下标从0开始时也可作如下归纳:

            -1;             当i=1时
next_0[i] =     k | max{s[0]...s[k-1] = s[i-k]...s[i-1]};//即能够匹配的最长前后缀长度        当存在匹配的前后缀集合时
            0;              其他情况

其实对于以上的val[]、next_0[]、next_1[]都是可以当作next[]的,下面会一一演示

i指向主串,j指向模式串
KMP算法的精髓就是i不回退,而暴力法的缺陷也是i频繁的回退导致的
​
以val[]作next数组
因为val记录的是最长相等的前后缀,我们可以通过这个来规避一些重复比较
index:              0   1   2   3   4   5   6   7   8   
对于主串:            a   a   b   a   a   b   a   a   c
模式串:              a   a   b   a   a   c
我们可以求出val[]     0   1   0   1   2   0
​
当i=5,j=5时
在比较下标为5的时候我们发现不匹配于是查出next[4](当前位的前一位)为2,所以我们令j=next[4]
0   1   2   3   4   5   6   7   8   
a   a   b   a   a   b   a   a   c
a   a   b   a   a   c
                    |
​
这里可以解释一下为什么可以通过next数组跳过,我么知道next数组本质就是记录前缀的最长前后缀长度,我们既然能匹配到这就代表前面位置都是匹配成功的。在样例中就是模板串中"aabaa"和匹配串中"aabaa"是一样的,然后想想我们的next数组是什么,记录的就是最长前后缀长度啊
a a b a a
0 1 0 1 2
next[4] = 2这说明存在的最长前后缀长度就是2("aa"和"aa")
匹配串的后缀"aa"和模式串的不是也是对应的吗,所以我们才可以直接拿匹配串的前缀直接匹配原先后缀匹配的地方。
                    
i=5,j=2
0   1   2   3   4   5   6   7   8   
a   a   b   a   a   b   a   a   c
            a   a   b   a   a   c
                    |
我们发现[i]=[j],所以接着往下比较(如果还不相同我们则再令j=next[j-1])
0   1   2   3   4   5   6   7   8   
a   a   b   a   a   b   a   a   c
            a   a   b   a   a   c
                                |
比较到j=s2.length()就代表匹配成功了,返回开始下标i-j
当以next_0[]作next数组时
因为next_0[j]总等于val[j-1](i!=0, next_0[0] = -1),其实就相当于我们不需要向上面一样找next[j-1]了,直接用next[j]就行
​
index:              0   1   2   3   4   5   6   7   8   
对于主串:            a   a   b   a   a   b   a   a   c
模式串:              a   a   b   a   a   c
我们可以求出next[]    -1  0   1   0   1   2
​
​
当i=5,j=5时
在比较下标为5的时候我们发现不匹配于是查出next[5](当前位)为2,所以我们令j=next[5]
0   1   2   3   4   5   6   7   8   
a   a   b   a   a   b   a   a   c
a   a   b   a   a   c
                    |
                
                    
i=5,j=2
0   1   2   3   4   5   6   7   8   
a   a   b   a   a   b   a   a   c
            a   a   b   a   a   c
                    |
我们发现[i]=[j],所以接着往下比较(如果还不相同我们则再令j=next[j])
0   1   2   3   4   5   6   7   8   
a   a   b   a   a   b   a   a   c
            a   a   b   a   a   c
                                |
比较到j=s2.length()就代表匹配成功了,返回开始下标i-j
当以next_1作next数组时与以next_0的情况基本一致,只是下标差个1,大家可以自己按上面情况分析

那么val数组是怎么算出的呢

暴力的话可以但是时间复杂度就高了,和我们使用kmp算法的初衷违背了,然后我们发现可以用dp(动态规划)快速得到.

假设我们现在已经知道了部分val,想要求val[4]

aabaac
​
0101

通过val[4-1] = 1我们知道aaba这个串的最长前后缀长度是1(也就是str[0] = str[3]我们已经知道了),所以我们只要比较str[1]和str[4]是否相等

相等:

那么val[4] = val[3] + 1

不相等:

说明我们要重新找匹配的串,但是我们要从头开始找吗?显然我们也可以使用之前的结论

str[2] != str[5]
​
aabaac
​
01012

虽然"aac"不等于"aab"但是"ac"和”aa“还是有相等的机会的(因为还没比较过我们不知道)

我们当前匹配上了两个字符
然后我们发现val[4] = 2("aa" = "aa"),val[2] = 1("a" = "a")我们可以捋一捋
​
val[4] = 2("aa" = "aa")
​
代表的是str[0] = str[3], str[1] = str[4]
​
val[2] = 1("a" = "a")
​
代表的是str[0] = str[1]
​
那么我们显然可以得到str[0] = str[4],所以我们可以跳过str[0]和str[4]的比较

所以接下来就是比较str[1]和str[5]是否相等,过程和上面是一样的

用公式总结一下就是:

//i是后缀的首位,j是前缀的末位
​
val[0] = 0;//初始
​
str[i] == str[j] 的话 val[i] = val[i - 1] + 1 
否则:j = val[j - 1]比较str[j]是否等于str[i]如此重复
​

代码部分:

val[i]的求解
​
    aabaac
    01012
//i是后缀的首位,j是前缀的末位     因为str默认从0开始所以j就初始为0
​
static void getVal(String str, int[] val) {
    int j = 0;
    val[0] = 0;
    for(int i = 1; i < str.length(); i++) {
        // 当前字符和 j 指向的字符不相等,需要回溯
        while(j > 0 && str.charAt(j) != str.charAt(i)) {
            j = val[j-1];
        }
        // j == 0 或 str.charAt(i) == str.charAt(j) 或 j == 0 && str.charAt(i) == str.charAt(j)
        // str.charAt(i) == str.charAt(j) 和 j == 0 && str.charAt(i) == str.charAt(j)
        if(str.charAt(i) == str.charAt(j)) {
            val[i] = ++j;
        } else {// j == 0
            val[i] = j;
        }
    }
}
next[i]求解
​
//前两种是通过next[i]与val[i]的关系得出的
​
/*
(1). 设置一个固定指针指向i-1位置,一个移动指针j首先指向i-1。
(2). 如果第i-1个字符和第j位置next指向的字符相同,则设置next为j位置next值+1。
(3). 如果不相同则让j指针移动。移动到目前next指向的位置。直到第i-1个字符和j位置next指向的字符相同或者j位置next指向0,则设置next为j位置next值+1。
*/
    
//下标从1开始的next数组
static void getNext1(String str, int[] next1) {
    next1[1] = 0;
    for(int i = 2; i <= str.length(); i++) {
        int j = i - 1;
        while(next1[j] != 0 && str.charAt(i-2) != str.charAt(next1[j] - 1)) j = next1[j];
        next1[i] = next1[j] + 1;
    }
}
    
​
//下标从0开始的next数组
static void getNext0(String str, int[] next0) {
    next0[0] = -1;
    for(int i = 1; i < str.length(); i++) {
        int j = i - 1;
        while(next0[j] != -1 && str.charAt(i-1) != str.charAt(next0[j])) j = next0[j];
        next0[i] = next0[j] + 1;
    }
}
​
//下标从1开始的next数组,也是《数据结构 C语言版》上的求解方式
static void getNext3(String str, int[] next3) {
    int i= 1, j = 0;
    while(i < str.length()) {
        if(j == 0 || str[i] == str[j]) {
            i++, j++;
            next[i] = j
        } else {
            j = next[j];
        }
    }
}

通过以上求next的方式都是正确的至于该选择哪一种就看个人习惯了,现在我们来解释为什么next数组是这样的。

上面介绍next数组是在出现不匹配的情况下的回溯的位置,这个很重要,对于暴力法来说如果出现不匹配的情况我们会回溯到这次匹配的首位的下一位,对于某些情况来说会很浪费

比如主串为:aaaaaaab
​
待模式串为: aaab
​
但是如果我们通过上面的求next_0(下标从0开始)的方法先预处理好模式串的next[]:
​
i        0 1 2 3
​
str[i]   a a a b
​
next    -1 0 1 2
​
匹配过程如下:
​
i = 3, j = 3
​
0 1 2 3 4 5 6 7
​
a a a a a a a b
​
a a a b
​
      |
​
当我们匹配到下标为3处时发生不匹配,我们查询next[j],就可以得到下一次匹配的j的下标2
​
i = 3, j = 2,匹配成功   i=4, j=3匹配不成功
​
0 1 2 3 4 5 6 7
​
a a a a a a a b
​
    a a a b
​
          |
​
当我们匹配到j下标为3处时发生不匹配,我们查询next[j],就可以得到下一次匹配的j的下标2
​
i = 4, j = 2匹配成功        i = 5, j = 3匹配不成功
​
0 1 2 3 4 5 6 7
​
a a a a a a a b
​
    a a a b
​
          |
​
重复以上步骤直至j等于模式串的长度就代表找到了匹配的串
.
.
.
​
匹配完成
完整kmp匹配过程:
next_0作next数组的完整过程代码:
​
public static void main(String[] args) {
//        String str = "abacabab";
    String str = "ababcbbcacbab";
    String str2 = "bbcac";
    System.out.println(KMP(str, str2));
}
​
//下标从0开始的next0数组
static int[] getNext0(String str, int[] next0) {
    next0[0] = -1;
    for(int i = 1; i < str.length(); i++) {
        int j = i - 1;
        while(next0[j] != -1 && str.charAt(i-1) != str.charAt(next0[j])) j = next0[j];
        next0[i] = next0[j] + 1;
    }
    return next0;
}
​
static int KMP(String str1, String str2) {
    int[] next = getNext4(str2, new int[str2.length() + 1]);
    int i = 0, j = 0;
    while(i < str1.length() && j < str2.length()) {
        if(str1.charAt(i) == str2.charAt(j)) {i++; j++;}//当[i]=[j]的时候我们匹配下一个
        else if(j > 0)  j = next[j];//不匹配并且j>0(防止越界),我们令j=next[j]
        else i+=1;//不满足以上两种的情况说明第一位就不匹配我们找主串的下一位
        if(j == str2.length()) return i - j;//找到了返回开始下标
    }
    return -1;//没找到
}
使用val[]作next数组的代码
public static void main(String[] args) {
//        String str = "abacabab";
    String str = "ababcbbcacbab";
    String str2 = "bbcac";
    System.out.println(KMP(str, str2));
}
​
static void getVal(String str, int[] val) {
    int j = 0;
    val[0] = 0;
    for(int i = 1; i < str.length(); i++) {
        // 当前字符和 j 指向的字符不相等,需要回溯
        while(j > 0 && str.charAt(j) != str.charAt(i)) {
            j = val[j-1];
        }
        // j == 0 或 str.charAt(i) == str.charAt(j) 或 j == 0 && str.charAt(i) == str.charAt(j)
        // str.charAt(i) == str.charAt(j) 和 j == 0 && str.charAt(i) == str.charAt(j)
        if(str.charAt(i) == str.charAt(j)) {
            val[i] = ++j;
        } else {// j == 0
            val[i] = j;
        }
    }
}   
​
static int KMP(String str1, String str2) {
    int[] next = getVal(str2, new int[str2.length() + 1]);
    int i = 0, j = 0;
    while(i < str1.length() && j < str2.length()) {
        if(str1.charAt(i) == str2.charAt(j)) {i++; j++;}//当[i]=[j]的时候我们匹配下一个
        else if(j > 0)  j = next[j-1];//不匹配并且j>0(防止越界),我们令j=next[j-1]
        else i+=1;//不满足以上两种的情况说明第一位就不匹配我们找主串的下一位
        if(j == str2.length()) return i - j;//找到了返回开始下标
    }
    return -1;//没找到
}

这相对于暴力法在有大量前后缀相同的情况下大大提高了效率,而由于计算机的二进制特性所有数据都是由0or1组成,这势必会产生大量的相同前后缀,所以KMP算法才会如此重要。

本文参考以下文章:(KMP-两种方法求next数组-优快云博客)

(图解KMP算法,带你彻底吃透KMP-优快云博客)

配合视频食用更佳哦:【最浅显易懂的 KMP 算法讲解】 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值