KMP(Knuth-Morris-Pratt) 字符串查找算法

1、背景

  我接触到这个算法是在力扣的每日一题中(28. 实现 strStr()),这本来只是一个分类为简单的题目。但是却在官方题解中介绍了这个看似十分复杂的算法。

  在官方题解中给出了详细的证明和公式推导,但是题解中满屏公式确实难以让人快速理解KMP.所以本文的目的是对推导做一个直观可视的解释。阅读时推荐与力扣官方题解对比理解。
  

2、解决的问题

这里直接引用力扣对题目的描述:

  给你两个字符串 haystack 和 needle ,请你在 haystack 字符串中找出 needle 字符串出现的第一个位置(下标从 0 开始)。如果不存在,则返回 -1。

  题目的描述是要求我们实现一个与 C 语言的 strstr() 或Java 的 indexOf()功能相同的函数,相信稍微有编程基础的同学都使用过类似的函数。从题目得定义上看,确实配得上简单的标签。但如果我们的目的只是为了在程序中使用查找字符串的功能(可以直接使用内置函数),或者只是为了在力扣通过用例(可直接使用暴力算法,时间复杂度O(m*n))。也没有必要再费周章来学习Knuth-Morris-Pratt 算法、Boyer-Moore 算法、Sunday 算法等算法。

  下文将根据题解中KMP(Knuth-Morris-Pratt)算法,做一个可容易理解的教学。
  

3、解题思路

3.1 暴力算法

  在了解KMP算法之前,我们有必要先了解暴力算法的实现,这样有助于我们在暴力算法的基础上进一步优化算法。

  暴力的算法的思路很简单,遍历haystack数组的每个元素下标为i,从i开始截取长度为needle.length()的子数组。对比 “需要查找的数组needle” 和每一个 “截取的子数组” ,返回第一个匹配成功的数组。在实现上,当我们已经成功匹配第一个子数组之后,便已经确定了要返回的结果。对于之后的子数组匹配可以进行剪枝操作,无需继续匹配。暴力匹配的代码非常的简单,下面用一个例子来实际演示一下。

class Solution {
    public int strStr(String haystack, String needle) {
        int n = haystack.length(), m = needle.length();
        for (int i = 0; i + m <= n; i++) {
            boolean flag = true;
            for (int j = 0; j < m; j++) {
                if (haystack.charAt(i + j) != needle.charAt(j)) {
                    flag = false;
                    break;
                }
            }
            if (flag) {
                return i;
            }
        }
        return -1;
    }
}

  下面例子中haystack为"abcaabcdab", 需要查找的needle为"abcd"。

第一步、首先令i=0; j=0; 。

第二步、移动黑色箭头j移动来进行字符比较,直到发现字符不匹配时(‘c’!=‘d’)。如下图:

在这里插入图片描述
第三步、将指针j回溯,令i=i+1;j=i;,然后重复第二步动作。如下图
在这里插入图片描述
第四步、直到needle和haystack的子串完全匹配或者移动到haystack的末尾(准确得说是,当haystack的剩余子串长度已经小于needle的长度时)。整个匹配过程结束。如下图:
在这里插入图片描述
  

3.2 暴力算法的复杂度

  从上述的例子可以很清楚的看到,指针i需要遍历haystack。同时对于i的每个位置,j最多需要比较needle.length()次。因此时间复杂度O(m*n)。m为haystack的长度和n为needle的长度。
  

3.3 优化

  直观上想要从haystack中找到needle,必须完整遍历haystack一次,O(m)这部分是不可减少的复杂度(如果不能完整遍历haystack一次,那么haystack有部分元素相当于是未知的,自然无法实现算法)。我们注意到在3.1小节中在每次匹配失败后,指针j需要回溯(这是O(n)复杂度的来源)。那么是否有方法减少或者消除回溯的成本呢?

  从视觉上观察,我们发现j回溯之前(j=5)。已经扫描的haystack(abcabc)与needle(abcabd),观察他们的蓝色部分,发现haystack的后缀与needle的前缀相同(haystack(abcab)与needle(abcab))。因此这一部分无需进行重复比较。如下图,红色是无须比较的部分。
  
在这里插入图片描述
  在这里插入图片描述
  同时KMP与暴力算法相比,发现指针j无需回溯。因此可以省略i指针(其实是省略的是每次比较字串的j指针,这里只是为了方便说明省略了一个指针。)
  阅读到了这一步,大部分读者基本已经可以发现KMP算法的核心,求解最大的相等的前后缀,即前缀函数π(i),这里我们来看一下前缀函数定义: 对于长度为 m 的字符串 s,其前缀函数π(i)(0≤i<m) 表示 s 的子串 s[0:i] 的最长的相等的真前缀与真后缀的长度。 前缀函数定义有问题的同学可以阅读官方题解中的例子。(这里只补充一点容易误解的地方:最长前缀与最长后缀可以重叠,例如"aaaa"最长的一对相等的真前后缀均为"aaa")。
  

3.4 求解前缀函数推理

(PS:此部分对照力扣题解中公式推导进行讲解)
前缀函数的性质:

1、π(i)≤π(i−1)+1。

  • 依据 π(i) 定义得:s[0:π(i)−1]=s[i−π(i)+1:i]。(这一步直接按照定义展开即可得到π(i)的定义:表示 s 的子串 s[0:i] 的最长的相等的真前缀与真后缀的长度。

  • 将两区间的右端点同时左移,可得:s[0:π(i)−2]=s[i−π(i)+1:i−1]。(右端点左移一位后,得到的结果是移动前的子字符串,所以依然相等。

  • 依据 π(i−1) 定义得:π(i−1)≥π(i)−1,即 π(i)≤π(i−1)+1。(这一步稍难理解。上一步已经得到了长度为 [π(i)−1] 的相等前后缀,且后缀满足π(i−1)的要求。所以可得s[0:i-1]至少存在长度为π(i)−1的相等前后缀,可得结论。

2、如果 s[i]=s[π(i−1)],那么 π(i)=π(i−1)+1。

  • 依据π(i−1) 定义得:s[0:π(i−1)−1]=s[i−π(i−1):i−1]。

  • 因为 s[π(i−1)]=s[i],可得s[0:π(i−1)]=s[i−π(i−1):i]。

  • 依据 π(i) 定义得:π(i)≥π(i−1)+1,结合第一个性质可得π(i)=π(i−1)+1。(这个推理的过程与上一性质完全一致,读者可自行揣摩)

  没有看懂上述两个性质推导的同学,也可无影响的继续阅读。只需记住一点:当且仅当s[π(i−1)]=s[i]时,存在π(i)=π(i−1)+1。否则π(i)<=π(i−1)。

  知道了上面的性质,我们便可以尝试求解π(i)。上面的性质给我们一个非常明显的提示 “π(i)=π(i−1)+1” ,并且π(0)=0已知,我们就可以尝试通过递推公式来求解π(i)。下面分两种情况:

 (1)当s[π(i−1)]=s[i]时,此时我们可以直接套用递推公式。

 (2)当s[π(i−1)]≠s[i]时,因为最大前后缀已经匹配失败,我需要继续递归寻找更小的"最大前后缀"。下面我先举一个非常特殊的例子,来帮助读者理解为什么时"递归"搜索。请看下图:

在这里插入图片描述

  π(22)=10已知(所有小于22的π(i)都应该是已知的,因为我们是从小到大递推的,所以之前的数字都是已经推到过的。这里不一一计算),如何求解π(23)呢?下图为了方便作图对比(以及方便理解),将字符串分成了两部分(读者可以想象它们还是存储在同一个数组内的)。

在这里插入图片描述

  首先尝试(1)规则,发现匹配失败。看到下面的图,一些灵敏的同学可能会发现,这不是和之前haystack和needle的匹配很类似么?我们要找到与橘色前缀相等的最大蓝色后缀,等价于寻找橘色数组的最大相等前后缀。我在网上找到了一个很棒的图帮助大家理解(图中的next数组即为本文中的前缀函数)。

在这里插入图片描述
  把上图转换成我们的例子中的数据就是下图。因为两个数组前十个元素相同(原因是初始数组π(22)=10),所以 大红色数组紫红色数组 元素相同。接下来,如图可以查到此处(π=5),由π=5可得 紫红色数组 等于 浅蓝色数组 。因此可以推出 大红色数组 等于 浅蓝色数组
在这里插入图片描述

  
  此时数组已经递归到了比较"abcabc"与"abcabd"。仔细观察这个例子其实就是我们文章开篇时比较的例子。
在这里插入图片描述

3.5 前缀函数求解步骤

  本节是对该算法的总结,我们在递推求解前缀函数时,另设定j 的初始值为 π(i−1)。我们只需要不断迭代 j(令 j 变为 π(j−1))直到 s[i]=s[j] 或 j=0 即可,如果最终匹配成功(找到了 j 使得 s[i]=s[j]),那么 π(i)=j+1,否则 π(i)=0。
  

4、 回归到 实现 strStr()

  其实在之前求解前缀函数的过程中,笔者已经有意将前缀函数的意义与strStr()做对比。其实strStr()可以理解为前缀函数的一种特例(即在haystack中寻找后缀等于needle时的i)。

   在实现上,可以将needle与haystack拼接,在递推π(i)的过程中,如果发现π(i)=needle.length()。即可说明寻找到了等于needle的后缀。

  这里还有一点需要强调:从上面的实例中可以看出,在求解前缀函数时递归调用了π(j−1),而仅仅第一次调用,就可以把需要匹配的数组替换为i-1时的最大的前缀。再次放出之前 ,可以发现第一次递归后的查询其实都可以在原来橘色的数组 内进行(橘色数组包括图中的浅蓝橘色紫红,本质上就是递归后查询橘色数组内浅蓝紫红部分。首尾的浅蓝色和紫红色只是为了方便比较而标记出来的)。而我们需要寻找的最大π(i)=needle.length()。所以橘色数组的长度不会超出needle。因此在下面的代码实现中,第一步只需初始化needle数组的前缀函数即可。
在这里插入图片描述
这里直接贴出力扣中的代码实现:


class Solution {
    public int strStr(String haystack, String needle) {
        int n = haystack.length(), m = needle.length();
        if (m == 0) {
            return 0;
        }
        int[] pi = new int[m];
        for (int i = 1, j = 0; i < m; i++) {
            while (j > 0 && needle.charAt(i) != needle.charAt(j)) {
                j = pi[j - 1];
            }
            if (needle.charAt(i) == needle.charAt(j)) {
                j++;
            }
            pi[i] = j;
        }
        for (int i = 0, j = 0; i < n; i++) {
            while (j > 0 && haystack.charAt(i) != needle.charAt(j)) {
                j = pi[j - 1];
            }
            if (haystack.charAt(i) == needle.charAt(j)) {
                j++;
            }
            if (j == m) {
                return i - m + 1;
            }
        }
        return -1;
    }
}

5、复杂度分析

   因为递推求解前缀函数的过程不需要回溯,因此时间复杂度为O(n+m)。其中 n 是字符串 haystack 的长度,m 是字符串 needle 的长度。我们至多需要遍历两字符串一次。
  

小记

   Hi, 初次见面。
   之前我曾经也记录过许多笔记,同时也向别人分享过我的笔记,并且也曾经从网上摘抄过不少精彩的分享。这篇文章是我第一次从最初就以写博客为最初目的去完成的。IT行业普遍热爱开源,崇尚共享和开源精神。也因为此,我们总是能在网上找到非常多的优质学习资料。虽然现阶段我一如既往是个技术菜鸟,但也有成为大神的心,❤️热爱和拥抱开源的心。凡事先从点滴做起,期待可以在博客上,发一些心得体会。如果可以帮助到别人,也算是一个有输出的人。
  目前看来,我还有很多不懂,很多需要学习。期待与每一位逐梦路上的同学一起进步、成长。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值