KMP算法原理描述,告诉你为什么要“j = next[j]”

KMP算法原理描述,告诉你为什么要“j = next[j]”

研究KMP算法的起因,是在刷leetcode的 214.最短回文串时,一开始使用了 O ( n 2 ) O(n^2) O(n2)时间复杂度的暴力算法,结果在一条 n = 40000 n=40000 n=40000的测试数据上超时了,后来在题解的启发下,用了KMP算法双95%+成功过了该题。但是在用KMP算法的时候,对于next数组的计算方式,尤其是回退的那一步始终没想明白,看了许多博客也没有描述清楚,当初上课学习KMP算法的时候对这块就是一知半解,今天花了点时间,终于算是把最令人困惑的"j = next[j]"这里搞清楚了。

0. KMP算法简介

KMP 算法是 一种字符串匹配算法,D.E.Knuth、J,H,Morris 和 V.R.Pratt 共同提出的,称之为 Knuth-Morria-Pratt 算法,简称 KMP 算法。该算法相对于暴力算法的最核心优化点是在匹配过程中,通过某些规则移动模式串指针,使得主串指针可以保持不回退,从而提升匹配效率。

1. 暴力算法的缺陷

在暴力算法中,当主串的某一段与模式串的前i-1位相同,但第i位不同时,本次匹配失败。模式串会向后移动一位、指针回退到0,而主串的指针也会回退到与模式串首位相同的位置上,如下图所示:

暴力算法

每次主串指针都会回退到上一次开始地方的后一位,模式串的指针也会回退到0,这样的时间复杂度是 O ( m ∗ n ) O(m * n) O(mn)

2. KMP算法的核心概念:最长公共前缀子串

当一次匹配失败后,我们每次将模式串右移一位,并且将主串和模式串的指针全部回溯,其实是浪费了我们在本次匹配中已经发现的一些经验。考虑下面的一种情况:

KMP算法示例

如图,在这种情况下,我们可以将原来模式串头部深绿色的部分,挪动到模式串没有匹配成功的红色前面的深绿色部分去,这样主串的指针就不用回退了,模式串的指针也不再需要回退到开头的位置了。

模式串中,重复的两个深绿色的部分具有这样的特点:
1. 第一个深绿色的部分,从模式串的头部开始。为什么是从模式串的头部开始?因为我们要保证,模式串右移后,和主串仍有一部分是匹配的,我们可以从模式串的这个前缀后面继续判断,这是模式串指针不需要回退到开头的原因;
2. 第二个深绿色的部分,以模式串当前未匹配成功的位置为结尾。为什么是以模式串当前未匹配成功的位置为结尾?因为只有这样,当我们右移模式串,使得这两个深绿色的部分“重合”之后,我们仍可以从主串出错的位置(红色部分)和模式串(第一个深绿色部分后的下一个字符)进行匹配,这是主串指针不需要回退的原因。

至此,KMP算法的核心概念已经呼之欲出了:深绿色的部分,就是浅绿色部分的“最长公共前缀子串”。“公共”,代表着两个深绿色部分的内容相同;“前缀”,代表着从模式串的头部开始。

3. 恼人的“next数组”

你可能已经意识到了,“最长前缀公共子串”和主串无关,它完全是模式串的一个属性。而KMP算法求“最长前缀公共子串”的方法,使用的就是“next数组”的概念,这个也是大多数人在学习KMP算法上面最大的一个坎。next数组的概念如下:

next[i] = j,即模式串的前i个字符组成的字符串中,最长前缀公共子串的长度为j。

是不是有点困惑?看看下面的例子:
next数组示例
在上一个例子中,next数组元素的值分别为:

next[0] = 0;
next[1] = 0;
next[2] = 0;
next[3] = 0;
next[4] = 1;
next[5] = 2;
next[6] = 0;

再抽象一点,next数组的含义是这样的:
next数组抽象含义
emmm,与其叫它”next“数组,还不如叫它”最长公共子串前缀长度数组“呢。。。

那么问题来了,怎么求这个next数组呢?

根据我们前面分析的next数组的定义,可以找到下面的规律:

  1. 如果新加入的字符与前一个最长公共前缀子串的后一个字符相同:
    新加入的字符与前一个最长公共前缀子串的后一个字符相同
    这里的深绿色部分长度是可以为0的,对后面计算没有影响。
    [新加入的字符与前一个最长公共前缀子串的后一个字符相同加入后]
    这种情况下,很简单,next[i] = next[i-1] + 1。这个很好理解,相信大家一看就能想到。

  2. 如果新加入的字符与前一个最长公共前缀子串的后一个字符不同:
    很多人看KMP算法就是卡在了这一步上,我最开始看的时候也是对这个地方百思不得其解,在其他人的代码中,那个最让人头痛的回退操作——j = next[j],就是在这里出现的。为了说明白这个问题,我们看下这张图:
    新加入的字符不同
    在这种情况下,原来的深绿色部分就不能用了,显然,我们要缩小深绿色的部分,还要满足最长公共前缀子串的要求,也就像下面这个样子:
    新的最长前缀公共子串
    这个蓝色的部分怎么求呢?这就是很多博客和代码里面没有说清楚的,最让人头痛的回退操作,就是那个臭名昭著的”j= next[j]“。
    我们换一种理解方式,这个蓝色的部分,首先要内容相同,其次还要位于深绿色部分的开头和结尾,这不就是深绿色部分的最长公共前缀子串吗?
    回退就是这么来的
    也就是这样:
    回退之后的样子
    更新j = next[j]后,又回到这个问题的起点了,然后再次判断s[i-1]和s[j]是否相同就可以了。
    至此,我们终于搞懂那个让人费解的回退操作到底是什么意思了,其实就是在当前的公共前缀已经不能用了,那就继续去尝试这个公共前缀的公共前缀,一直试到成功或者公共前缀的长度为0时为止。

4. Java实现

最后贴个Java实现代码。注意模式串长度小于2就没有必要用KMP了,这里简单先不考虑了,要抄代码的话记得检查,小心数组越界哦:

/**
 * 模式串长度小于2就没有必要用KMP了,这里简单先不考虑了,要抄代码的话记得检查,小心数组越界哦
*/
private int[] getNext(String s) {
    int[] next = new int[s.length()];
    //前0个字符,肯定没有最长公共前缀子串
    next[0] = 0;
    //前1个字符,那也没有嘛
    next[1] = 0;
    //上一个最长公共前缀子串长度
    int subPublicPreSubStrLength = 0;
    for (int subStrLength = 2; subStrLength < s.length(); ) {
        //需要求前i个字符组成的字符串中的前缀子串,这个是新加入尾部的字符
        char addedSubStrChar = s.charAt(subStrLength - 1);
        //位于最长公共前缀子串后的字符
        char addedPreSubStrChar = s.charAt(subPublicPreSubStrLength);   
        if (addedSubStrChar == addedPreSubStrChar) {
            //新加入的字符和之前的最长公共前缀子串后的字符相同
            
            //最长公共子串是在前一个的基础上,再加一个字符
            next[subStrLength] = ++subPublicPreSubStrLength;
            //继续求i+1个字符组成的字符串中的前缀子串
            subStrLength++;     
        } else {
            //新加入的字符和之前最长公共前缀子串后的字符不同,说明之前的公共子串已经不能用了
            if (subPublicPreSubStrLength == 0) {
                //前一个公共子串长度已经为0了,那说明当前情况下的公共子串也只能是0了
                next[subStrLength] = 0;
                //继续求i+1个字符组成的字符串中的前缀子串
                subStrLength++;     
            } else {
                //继续求前一个前缀子串的前缀子串,这里就是那个最让人搞不懂的,k = next[k]的回退
                subPublicPreSubStrLength = next[subPublicPreSubStrLength];
            }
        }
    }
    return next;
}

本来可以写的很简洁,但是我特意把各个变量的命名写的清楚一些,配合注释希望大家能看懂。建议大家要是抄代码的话,可以换个其他人的写法来抄,我这个为了看清楚写的很冗长,很多地方可以优化调整,主要是能看明白思路就好。

  • 64
    点赞
  • 93
    收藏
    觉得还不错? 一键收藏
  • 31
    评论
评论 31
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值