关于KMP算法的一点思考

关于KMP算法的一点思考

引子

KMP算法是解决找出字符串中第一个匹配项的下标问题的算法,首先要明确的一点是,KMP算法核心是求字符匹配出错时的跳转数组next,而next数组又和最长相等前后缀子串息息相关。本文重点放在next数组的求取上,以及对算法合理性的一点深入思考,也是对个人学习过程的记录。

最长相等前后缀子串

这里给出相等前后缀子串的定义,对于字符串s,如果存在j满足0<j<L(L表示字符串s的长度),使得s[0~j-1]==s[L-j~L-1](此处为了方便描述,两端都是闭合区间,下同),则称子串s[0~j-1]s的相等前后缀子串,在以上定义中,很显然j就是相等前后缀子串的长度。而最长相等前后缀子串,顾名思义就是长度最大的相等前后缀子串,即j为可能值的最大值。

一个例子,比如对字符串aaaa,如下图所示,aaaaaa都是它的相等前后缀子串,而aaa即为aaaa的最长相等前后缀子串。

在这里插入图片描述

next数组

由于next元素含义的定义不同,求取next代码会存在差异,因此这里统一定义next[i]为KMP匹配过程中当子串下标i处匹配出错时子串指针跳转的位置,个人认为是比较符合常规逻辑的定义。

那么当子串下标i处匹配出错时,子串指针应该如何跳转呢?传统的BF算法(Brute Force,暴力匹配)会将字符串整体右移一位,相当于主串指针不变,子串指针i跳转到i-1。但是这样效率太低了,没有充分利用到已匹配信息,即主串haystack的一部分可以和子串的一部分———needle[0~i-1]完全匹配(此处使用LeetCode上题目的字符串主串及子串变量名,下同)。

结合以上最长相等前后缀子串的概念,显然如果能找到needle[0~i-1]的长度为j的最长相等前后缀子串needle[0~j-1],子串指针就可以从i直接跳转到j(j < \lt <i),相当于字符串前进了i-j位,而不用每次只前进一位。

以子串ababcd的匹配为例,如下图所示,在子串串needle下标5处匹配出错。显然needle的指针从5跳转到4(needle前进一位)、跳转到3(needle前进二位)都是不可能匹配成功的,这一点是由子串needle本身的构成决定,与匹配的主串haystack无关。而如果我们已经知道子串needle已匹配成功部分abcab)的最长相等前后缀子串ab(对应下标为0、1),长度为2,那么主串**haystack匹配成功部分也有同样的最长相等前后缀子串**,将当前子串needle指针跳转到2无疑是最佳选择。当然此例中跳转到下标2处仍然匹配出错,需要进一步匹配。

在这里插入图片描述

通过以上的例子我们可以知道,子串needle在下标i处匹配出错时,如果i之前的子串needle[0~i-1]存在长度为j的最长相等前后缀子串,那么就由i跳转到j处。那么很显然needle的转移数组满足next[i]==j

此时问题转化为求下标i对应的子串needle[0~i-1]最长相等前后缀子串的长度j。不过,在i=0时,如果在此处匹配出错,因为前面已匹配成功部分,无法进行跳转,只能将子串needle后移一位,等价于跳转到“下标”-1处,即next[0]始终为-1。由此可以写出以下代码。

public int[]  getNext(String s) {
        // next[i]表示 第 i 字符匹配出错时, 跳转的位置
        int[] next = new int[s.length()];
        char[] needle = s.toCharArray();
        next[0] = -1;
        int j = -1;
        for (int i = 1; i < needle.length; i++) {
            // 每次循环 j 初始为 i-1 之前 (needle[0~i-2]) 的最长相等前后缀子串长度
            while (j >= 0 && needle[i - 1] != needle[j]) {
                j = next[j];
            }
            next[i] = ++j;
        }
        return next;
    }

以上代码中,每次进入循环i开始时,j都表示next[i-1],即needle[0~i-2]的最长相等前后缀子串长度。此时我们希望在j的基础上延长一位,获得next[i],即needle[0~i-1]的最长相等前后缀子串长度。直觉上我们会比较needle[i-1]==needle[j],如果相等就可以获得next[i]。而如果不等,则需要进行上面匹配时类似的跳转。因为我们已经已知needle[0~i-2]的最长相等前后缀子串为needle[0~j-1],那么我们就要在此范围里寻找更短的相等前后缀子串,即通过j=next[j]跳转,再试探是否可以延长,如此循环,直到j为-1或者找到可以延长的地方。而两种终止情况下j终值都为needle[0~i-1]的最长相等前后缀子串长度,为下一次循环i+1服务。

以字符串abcababcaae为例,当求next[11]时,寻找next[0~10]的最长相等前后缀子串长度过程如下图所示。

在这里插入图片描述

改进next数组

最开始我们将next[i]定义为为匹配过程中当子串needle下标i处匹配出错时子串指针跳转的位置,而上面的求法将这个跳转位置与needle[0~i-1]的最长相等前后缀子串长度画上了等号,但是这真的是最佳的跳转位置吗?

以子串abcabce的匹配过程为例,如下图所示。

当匹配到needle[5]时匹配错误(这里用?表示主串haystack中匹配错误的字符,它可以是任意不为c的字符),原始版本的KMP算法希望needle指针跳转到下标2。但是在判断c==?时,我们已经获得了c ≠ \neq =?的信息,跳转到下标2处岂不是要又要判断一次c==?,很显然这是一次冗余判断。而这也是由needle自身的结构决定的,与haystack无关,在求next数组时我们就可以得到c ≠ \neq =?这一信息。

在这里插入图片描述

所以我们对KMP算法作出改进,next[i]不再单纯是needle[0~i-1]的最长相等前后缀子串长度,而是在此基础之上要再加上一个约束前提needle[i]!=needle[next[i]]。所以在以上代码的基础上,我们在循环中增加一个判断,代码如下。

这里要注意,进入循环i时,j仍然是needle[0~i-2]的最长相等前后缀子串长度,但未必是next[i-1]

public int[]  getNext(String s) {
    // next[i]表示 第 i 字符匹配出错时, 跳转的位置
    int[] next = new int[s.length()];
    char[] needle = s.toCharArray();
    next[0] = -1;
    int j = -1;
    for (int i = 1; i < needle.length; i++) {
        // 每次循环 j 初始为 i-1 之前 (needle[0~i-2]) 的最长相等前后缀子串长度
        // i-1 前最长相等前后缀子串长度可能不等于 next[i-1]
        // needle[j] 对应 needle[i-1]
        while (j >= 0 && needle[i - 1] != needle[j]) {
            j = next[j];
        }
        // 无论 j == -1 还是 j != -1 (needle[i - 1] == needle[j]) 的情况,
        // 都需要考虑是否 needle[i] == needle[j + 1]
        if (needle[i] == needle[j + 1]) {
            next[i] = next[++j];
        } else {
            next[i] = ++j;
        }
    }
    return next;
}

关于改进算法的思考

以上改进的代码可以确定是完全正确的,但是不知大伙是否注意到如下问题:

进入循环i时,jneedle[0~i-2]的最长相等前后缀子串长度,但**next[1~i-1]不再是对应最长相等前后缀子串长度**。这时如果进入while循环发生了跳转1j = next[j],而恰好此前求next[j]时发生了跳转二(它使得next[j]不再是needle[0:j-1]的最长相等前后缀子串长度)。那么我们还能保证后续求next[i+1]next[i+2]·····的循环中,刚进入循环时jneedle[0~i-1]needle[0~i]······的最长相等前后缀子串长度吗?

while (j >= 0 && needle[i - 1] != needle[j]) {
	// 跳转一
    j = next[j];
}
if (needle[i] == needle[j + 1]) {
	// 跳转二
    next[i] = next[++j];
} else {
    next[i] = ++j;
}

答案是肯定的,如下图所示,除黑色外同色直线表示相等的字符串部分。

在这里插入图片描述

在求next[j](假设此前从未发生跳转二,和原版KMP算法等价)过程中,尽管needle[0~j-1]的最长相等前后缀子串长为k,但是因为needle[j]==needle[k],所以跳到更前面needle[m]处,且needle[j]!=needle[m]满足条件。后来,求next[i]时,尝试拓展相等前后缀子串时,由于needle[i-1]!=needle[j],发生了跳转一到达m(m=next[j])处,似乎略过了“最佳选择”k。但是很显然,有needle[j]==needle[k]!=needle[i-1],即便尝试在k处拓展仍然要发生跳转一。

综上,改进版本KMP增设的跳转二处判断不会影响j在进入循环i时,始终为needle[0~i-2]的最长相等前后缀子串长度。

相反,如果只想和原始版本一样,求字符串needle的最长相等前后缀子串长度,或许按照改进版本添加约束判断比较好,这样可以减少冗余判断,不过此时需要用另一个数组来存储每轮循环j的终值。

题外话

KMP算法是比BF时间复杂度更优的寻找子串方法,在很长的一段时间里,我都以为一般编程语言中内置的该功能函数(比如Java的indexOf)底层使用的是类似于KMP这种取巧的方法,但没想到事实是底层真的是用的是暴力法。上Google搜了下,一种解释是在子串不太长的情况下,KMP和BF基本没差,而对于长子串,计算next数组的开销会很大,所以库函数一般还是用BF。
indexOf)底层使用的是类似于KMP这种取巧的方法,但没想到事实是底层真的是用的是暴力法。上Google搜了下,一种解释是在子串不太长的情况下,KMP和BF基本没差,而对于长子串,计算next数组的开销会很大,所以库函数一般还是用BF。

  • 30
    点赞
  • 19
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值