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行业普遍热爱开源,崇尚共享和开源精神。也因为此,我们总是能在网上找到非常多的优质学习资料。虽然现阶段我一如既往是个技术菜鸟,但也有成为大神的心,❤️热爱和拥抱开源的心。凡事先从点滴做起,期待可以在博客上,发一些心得体会。如果可以帮助到别人,也算是一个有输出的人。
  目前看来,我还有很多不懂,很多需要学习。期待与每一位逐梦路上的同学一起进步、成长。

KMP(Knuth-Morris-Pratt) 字符串查找算法相关推荐

  1. Python:实现knuth morris pratt(KMP)算法(附完整源码)

    Python:实现knuth morris pratt(KMP)算法 from __future__ import annotationsdef kmp(pattern: str, text: str ...

  2. Python:实现前缀Knuth–Morris–Pratt 算法(附完整源码)

    Python:实现前缀Knuth–Morris–Pratt 算法 def prefix_function(input_string: str) -> list:# list for the re ...

  3. 暴力子字符串查找算法

    暴力子字符串查找算法的名字虽然很霸气,但是效率不是很高.是一种简单.粗暴的查找方式. 在最坏的情况下,暴力子字符串查找算法在长度为N的文本中查找长度为M的模式需要~NM次字符比较. 核心思想:就是对主 ...

  4. Prefix function. Knuth–Morris–Pratt algorithm

    Prefix function. Knuth–Morris–Pratt algorithm Prefix function definition You are given a string \(s\ ...

  5. Rabin-Karp 指纹字符串查找算法

    Rabin-Karp 指纹字符串查找算法 M.O.Rabin 和 R.A.Karp 发明了一种完全不同的基于散列的字符串查找算法.我们需要计算模式字符串的散列函数,然后用相同的散列函数计算文本中所有可 ...

  6. 字符串查找算法BF、KMP详解

    字符串查找: BF算法: (朴素查找算法) 当查找不成功时,主串返回刚刚起始字符的下一个,子串返回第一个字符位置 时间复杂度:O(n*m) int BF(const char* str, const ...

  7. KMP子串匹配算法(Knuth–Morris–Pratt algorithm)

    假设原串S,模式串M 简单的说这个算法的主要思想就是利用模式串M自身的匹配性质,快速找到下一跳的位置. 而这个下一跳的位置只与模式串相关,所以可以根据这个串制作一个next的表T,来指示出一旦匹配不成 ...

  8. Rabin-Karp字符串查找算法学习:poj1200

    本来准备学习Hash的,结果看PPT讲的第一个算法竟然是跟字符串处理相关的,本来Hash中也有一块专门讲字符串Hash的,就按照<算法导论>中的分类,把这个分到"字符串处理&qu ...

  9. KMP- Knuth-Morris-Pratt 字符串查找算法

    2019独角兽企业重金招聘Python工程师标准>>> TBD 转载于:https://my.oschina.net/u/1041012/blog/954965

最新文章

  1. 基于web的可定制数据填报平台
  2. hdu3006 状态压缩+位运算+hash(小想法题)
  3. volatile变量与普通变量的区别
  4. delphi 窗体的释放
  5. Java 11功能– Java飞行记录器
  6. 阿里巴巴,华为都不在58上招聘么?
  7. Element UI的数据表格数据检索方法
  8. schedule_timeout 函数分析
  9. vue实现横向时间轴组件
  10. 史上最给力客厅风水 让你躲过血光之灾
  11. patreon cg人物插画作品合集分享
  12. 美标/国标耳机接口区别
  13. mac悬浮窗_Mac OS 悬浮窗口,并且可以保持在全屏的其他应用上。
  14. Figma#3:色彩
  15. MySQL权限授权认证详解
  16. 文献管理软件Zotero配置及使用
  17. 多功能计算机十二生肖,十二生肖那是真人实演的危险特技,不是电脑特效!
  18. 计算机硬盘显示恢复出厂设置,如何对磁盘进行分?
  19. OTFS从零开始(一)
  20. 关于基因家族的全基因组鉴定和表达分析的研究步骤

热门文章

  1. Python随机数判断
  2. unity回顾之力的四种ForceMode
  3. nnU-Net医学影像分割论文,可自动调参,适应所有数据集
  4. 当游戏遇到Chrome操作系统
  5. java中两个list合并和去重操作
  6. 蛙蛙推荐:蛙蛙牌软件注册码算法
  7. 数据结构与算法——队列( Quene )
  8. 你的定位,你的一生--解读IT人员的从业方向
  9. 瑞萨——离线烧录器使用方法
  10. codewars 算法题小结(转载)