字符串匹配—KMP算法

KMP算法是一种字符串匹配算法,其关键是利用匹配失败后的信息,尽量减少模式串与主串的匹配次数以达到快速匹配的目的。最基础的字符串匹配就是每一次匹配,模式串都重头开始,主串后移一位,这样时间复杂度为O(m×n),而KMP是字符串匹配算法的改进,改进后的时间复杂度可以缩小至O(m+n)。

KMP算法基础版

  KMP算法的核心就是求模式串的next数组,简单解释一下,next[i]就是模式串除去第i个字符,从头到第(i-1)个字符前缀与后缀最长重复的个数。
这里要理解一下什么是前缀和后缀,这个概念很重要!在“aba”中,前缀就是“ab”,即除去最后一个字符的剩余字符串;后缀就是"ba",即除去第一个字符的后面全部的字符串。"aba"的前缀与后缀最长重复的个数就为1。 特别要注意的是:

前缀必须要从头开始算,后缀要从最后一个字符开始算,中间截一段相同字符串是不行的!!!!

当字符串匹配时,如下图:
图片说明
当匹配到C和D时,失配了。这个时候,模式串就应该往后移两位,即下图:

图片说明

  为什么只往后移两位呢,因为第一位的A和第三位的A匹配了,此时就不需要重复匹配了,这就是KMP的精髓。那怎么知道当主串模式串失配时,模式串往后移几位,也就是重新定位到模式串的第几个字符呢?

  以上面的图为例,虽然ABAD失配,但是ABA是匹配的,也就是说主串的ABA与模式串的ABA完全匹配,除去当前的完全匹配,把模式串的ABA往后拖,两个ABA的最大重复子字符个数。这就相当于主串是后缀,模式串是前缀,找出前缀与后缀最大的重复字符个数,在上图中,ABA的前缀与后缀最大重复个数为1,即模式串重新定位到1的位置(因为字符串从0开始,所以这里就省去了+1),让B和C开始比较。所以模式串到i位失配时,下一个移动点就是模式串(0…i-1)位的前缀与后缀最大重复字符个数。


KMP算法基础版代码

public int[] getNext(String ps) {
    char[] p = ps.toCharArray(); // 模式串
    int[] next = new int[p.length];
    next[0] = -1;// 初始化
    int j = 0; // 当前位置
    int k = -1; // 要返回的位置
    while (j < p.length - 1) {
        if (k == -1 || p[j] == p[k]) {
            // 当P[k] == P[j]时, 有next[j+1] == next[j] + 1
            next[++j] = ++k;
        } else {
            k = next[k];
        }
    }
    return next;
}

public int KMP(String ts, String ps) {
    char[] t = ts.toCharArray(); // 主串
    char[] p = ps.toCharArray(); // 模式串
    int i = 0; // 主串的位置
    int j = 0; // 模式串的位置
    // next[j]的值表示,当P[j] != T[i]时,j指针的下一步移动位置。
    int[] next = getNext(ps);

    while (i < t.length && j < p.length) {
        if (j == -1 || t[i] == p[j]) { // 当j为-1时,移动i的同时j也要归0
            i++;
            j++;
        } else
            j = next[j]; // j回到指定位置
    }
    // 返回模式串在主串中开始匹配的头位置
    if (j == p.length) {
        return i - j;
    } else {
        return -1;
    }
}

如何理解getNext中的k = next[k] ?

  函数getNext(ps)就是算出next数组,next[i]就是模式串(0…i-1)位的前缀与后缀最大重复字符个数。这个就相当于模式串自己与自己匹配! 注意要初始化next[0]=-1,也就是将模式串1作为主串,模式串2作为模式串开始匹配。模式串1的后缀与模式串2的前缀的最大重复字符个数,当模式串1遍历到k与模式串2遍历到j不匹配时,模式串2要重新定位,所以开始找模式串2即[0…k-1]中的前缀与后缀最大重复字符个数,此时要知道,下面是重点!!
  模式串2的[0…k-1] 不仅与模式串1的[j-k,j-1]是一样的,与模式串1的[0…k-1]也是一样的!!!(有点废话,因为模式串1和2本来就是同一个字符串,只不过错位了。)模式串1的[0…k-1]的前缀与后缀最大重复字符个数为next[k],而模式串2当前要移位,也就是当前的k=模式串2的[0…k-1]的前缀与后缀最大重复字符个数,模式串1=模式串2,所以 k = next[k]!

KMP算法改进版

下面的图摘自详解KMP算法,这篇博客讲的挺详细,对KMP不了解的可以重头看一遍。

  上面的程序是没有问题的,但不够好!
  如果是人为来寻找的话,肯定不会再把i移动回第1位,因为主串匹配失败的位置前面除了第一个A之外再也没有A了,我们为什么能知道主串前面只有一个A?因为我们已经知道前面三个字符都是匹配的!(这很重要)。移动过去肯定也是不匹配的!有一个想法,i可以不动,我们只需要移动j即可,如下图:

图片说明

  上面的这种情况还是比较理想的情况,我们最多也就多比较了再次。但假如是在主串“SSSSSSSSSSSSSA”中查找“SSSSB”,比较到最后一个才知道不匹配,然后i回溯,这个的效率是显然是最低的。
  大牛们是无法忍受“暴力破解”这种低效的手段的,于是他们三个研究出了KMP算法。其思想就如同我们上边所看到的一样:“利用已经部分匹配这个有效信息,保持i指针不回溯,通过修改j指针,让模式串尽量地移动到有效的位置。”

  所以,整个KMP的重点就在于当某一个字符与主串不匹配时,我们应该知道j指针要移动到哪?

接下来我们自己来发现j的移动规律:
图片说明
如图:C和D不匹配了,我们要把j移动到哪?显然是第1位。为什么?因为前面有一个A相同啊:
图片说明
如下图也是一样的情况:
图片说明

可以把j指针移动到第2位,因为前面有两个字母是一样的:
图片说明
至此我们可以大概看出一点端倪,当匹配失败时,j要移动的下一个位置k。存在着这样的性质:最前面的k个字符和j之前的最后k个字符是一样的。

如果用数学公式来表示是这样的

    

这个相当重要,如果觉得不好记的话,可以通过下图来理解:
图片说明
弄明白了这个就应该可能明白为什么可以直接将j移动到k位置了。

因为:

当T[i] != P[j]时

有T[i-j ~ i-1] == P[0 ~ j-1]*

由P[0 ~ k-1] == P[j-k ~ j-1]

必然:T[i-k ~ i-1] == P[0 ~ k-1]

公式很无聊,能看明白就行了,不需要记住。

  这一段只是为了证明我们为什么可以直接将j移动到k而无须再比较前面的k个字符。

  好,接下来就是重点了,怎么求这个(这些)k呢?因为在P的每一个位置都可能发生不匹配,也就是说我们要计算每一个位置j对应的k,所以用一个数组next来保存,next[j] = k,表示当T[i] != P[j]时,j指针的下一个位置。

public static int[] getNext(String ps) {

    char[] p = ps.toCharArray();

    int[] next = new int[p.length];

    next[0] = -1;

    int j = 0;

    int k = -1;

    while (j < p.length - 1) {

       if (k == -1 || p[j] == p[k]) {

           next[++j] = ++k;

       } else {

           k = next[k];

       }

    }

    return next;
}

  这个版本的求next数组的算法应该是流传最广泛的,代码是很简洁。可是真的很让人摸不到头脑,它这样计算的依据到底是什么?

  好,先把这个放一边,我们自己来推导思路,现在要始终记住一点,next[j]的值(也就是k)表示,当P[j] != T[i]时,j指针的下一步移动位置。

  先来看第一个:当j为0时,如果这时候不匹配,怎么办?
图片说明
像上图这种情况,j已经在最左边了,不可能再移动了,这时候要应该是i指针后移。所以在代码中才会有next[0] = -1;这个初始化。

如果是当j为1的时候呢?
图片说明
请仔细对比这两个图。

我们发现一个规律:

当P[k] == P[j]时,

有next[j+1] == next[j] + 1

其实这个是可以证明的:

因为在P[j]之前已经有P[0 ~ k-1] == p[j-k ~ j-1]。(next[j] == k)

这时候现有P[k] == P[j],我们是不是可以得到P[0 ~ k-1] + P[k] == p[j-k ~ j-1] + P[j]。
即:P[0 ~ k] == P[j-k ~ j],即next[j+1] == k + 1 == next[j] + 1。

这里的公式不是很好懂,还是看图会容易理解些。

那如果P[k] != P[j]呢?比如下图所示:



图片说明
像这种情况,如果你从代码上看应该是这一句:k = next[k];为什么是这样子?你看下面应该就明白了。

图片说明

现在你应该知道为什么要k = next[k]了吧!像上边的例子,我们已经不可能找到[ A,B,A,B ]这个最长的后缀串了,但我们还是可能找到[ A,B ]、[ B ]这样的前缀串的。所以这个过程像不像在定位[ A,B,A,C ]这个串,当C和主串不一样了(也就是k位置不一样了),那当然是把指针移动到next[k]啦。

有了next数组之后就一切好办了,我们可以动手写KMP算法了:

public static int KMP(String ts, String ps) {

    char[] t = ts.toCharArray();

    char[] p = ps.toCharArray();

    int i = 0; // 主串的位置

    int j = 0; // 模式串的位置

    int[] next = getNext(ps);

    while (i < t.length && j < p.length) {

       if (j == -1 || t[i] == p[j]) { // 当j为-1时,要移动的是i,当然j也要归0

           i++;

           j++;

       } else {

           // i不需要回溯了

           // i = i - j + 1;

           j = next[j]; // j回到指定位置

       }

    }

    if (j == p.length) {

       return i - j;

    } else {

       return -1;

    }
}

和暴力破解相比,就改动了4个地方。其中最主要的一点就是,i不需要回溯了。

最后,来看一下上边的算法存在的缺陷。来看第一个例子:

图片说明

显然,当我们上边的算法得到的next数组应该是[ -1,0,0,1 ]

所以下一步我们应该是把j移动到第1个元素咯:

图片说明

不难发现,这一步是完全没有意义的。因为后面的B已经不匹配了,那前面的B也一定是不匹配的,同样的情况其实还发生在第2个元素A上。

显然,发生问题的原因在于P[j] == P[next[j]]。

上面这个现象最主要的原因就是 P[j] == P[next[j]]。也就是p[j]失配时,下一步会跳到j = next[j],而p[next[j]]的值与p[j]的值一模一样,就像上图中B跳到了B。如何避免这个问题呢,需要将getNext中的核心代码给改一下,如下图:
图片说明

一开始看这个代码比较难理解,可以分解下来看。最核心的更改就是,左边的next[++j] = ++k 变成了一个if…else…判断。而右图中的else其实算法内容和next[++j] = ++k是一样的,也就是,右边多了一个 if (p[++j] == p[++k]) {next[j] = next[k]; }。最主要的是理解这句代码,结合下图更易理解。
图片说明

  当连续两个(便于理解,假设为两个,实际上两个及两个以上)p[k]与p[j]匹配时,会形成上图所画的样子,如果模式串1作为模式串与主串匹配,若匹配到位置3的时候失配了,它下一步会跳到位置1继续比较开始匹配(怎么跳的看上面基础版的)。但现在我们是要他跳到位置0,重头开始匹配(跳到位置1没意义)。

  接下来,用代码中的k和j重新叙述一遍上面的话!!!!!!!!!!
  如果模式串1作为模式串与主串匹配,若匹配到位置 j+1 的时候失配了,它下一步会跳到位置 k+1 继续比较开始匹配(即 next[++j] = ++k;也就是右图中的next[j] = k;)(怎么跳的看上面基础版的)。但现在我们是要他跳到位置k,重头开始匹配(跳到位置k+1没意义)。

下面一段话是理解的精髓!!!!!!!!!
  因为k+1这个位置匹配了的结果依旧是失配,所以就算跳到了k+1,依旧还要往前跳,所以直接提取从k+1往前跳的值,即next[k+1],所以当前j+1失配时位置跳的过程可以理解为:

j+1 —> k+1 —> next[k+1]

所以next[j+1]保存的值为next[k+1],在代码中为next[j] = next[k],因为上面一行的判断中++了。


KMP算法改进版代码
public int[] getNext(String ps) {
    char[] p = ps.toCharArray(); // 模式串
    int[] next = new int[p.length];
    next[0] = -1;// 初始化
    int j = 0; // 当前位置
    int k = -1; // 要返回的位置
    while (j < p.length - 1) {
        if (k == -1 || p[j] == p[k]) {
            if (p[++j] == p[++k]) {
                // 当两个字符相等时要跳过
                next[j] = next[k];
            } else {
                next[j] = k;
            }
        } else {
            k = next[k];
        }
    }
    return next;
}
全部评论

相关推荐

2025-11-22 15:15
门头沟学院 Java
程序员花海:实习太简单了 学历可以的 实习描述应该是先介绍业务 再介绍技术 技术咋推动业务的 做到了啥收益 有没有做实验 实验组和对照组有什么不同 你最后学到了什么 有没有参与处理过线上问题 有没有参与过公司的code review 有没有参与过技术分享 这些都是可以在实习描述中写的 并且实习和项目不一样不会撞车 这个实习经历描述有点太偏项目了
我的简历长这样
点赞 评论 收藏
分享
评论
点赞
收藏
分享

创作者周榜

更多
牛客网
牛客网在线编程
牛客网题解
牛客企业服务