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