前言
某题KMP算法的题解有这样一段话,我总结为:一定要通过图文来理解KMP算法,否则将很难理解它为什么这样去做。不能排除您有聪明的头脑,只是这样去学习KMP,几乎一定可以为您省下很多时间。
读者需要注意以下几点:
① KMP 算法虽然有着良好的理论时间复杂度上限,但大部分语言自带的字符串查找函数并不是用 KMP 算法实现的。这是因为在实现 API 时,我们需要在平均时间复杂度和最坏时间复杂度二者之间权衡。普通的暴力匹配算法以及优化的 BM 算法拥有比 KMP 算法更为优秀的平均时间复杂度;
②学习 KMP 算法时,一定要理解其本质。如果放弃阅读晦涩难懂的材料(即使大部分讲解 KMP 算的材料都包含大量的图,但图毕竟只能描述特殊而非一般情况)而是直接去阅读代码,是永远无法学会KMP 算法的。读者甚至无法理解 KMP 算法关键代码中的任意一行。
因此,我会尝试去使用以上所有,从一道题目的暴力解法开始,再到优化思路,引出KMP,再去拆解算法的重难点,一点点来讲解KMP算法。个人认为这是有效的思路,因为我本人就是这样理解的。如果哪里不理解,希望在评论区指出,我(如果看见了)会去按照我的理解作答,或者一起讨论我没有想到过的点。文章字数虽多,但整体不长,尽了最大努力去精炼地描述,虽然在这一点上作者还在持续追求进步。希望本文对您理解KMP算法有所帮助!
示例题目
力扣链接:找出字符串中第一个匹配项的下标
为了节省大家时间,简单复述题目,就是实现一下Java中String
的方法indexOf
。
然后提出两个名字,后面不再解释:
假设代码:int index = t.indexOf(s);
这里的t我们叫做目标字符串,s叫做模板字符串。即从目标位置中找到第一次匹配模板字符串的下标。
常规思路?
我第一下想怎么解呢?
- 从被搜索的字符串的第一个下标开始,逐一地去匹配模版字符串,试图找到和模板字符串第一个下标相等的字符;
- 然后开始进入第二个状态,即逐一的去比较模版字符串。
- 如果找到就返回,如果中间发现失败了,比如开始下标是10,模板字符串长度是6,匹配到第5个字符发现不相等,则继续从11开始比较,直到到达目标字符串长度-模板字符串长度的下标位置。
- 退出循环后,返回-1。
好像哪里不对?
上面我特意举了一个例子,带上了数字。开始下标是10,模板长度6,比较了5个了,现在到了14的位置了,然后让我退回到11去比较?如果长度是100,比较了90个了,也还从11吗?当然不是不可以,只是这样不好。我们既然学习算法,那就要考虑如何优化。
怎么优化?
直接上例子,考虑这样的模板字符串abcdefabcdfg,目标字符串ffffabcdefabcdefabcdfggggg。我把目标字符串做个分割,ffff
abcdef
abcdef
abcdfg
gggg
,为了表述方便,按序分别叫第几组字符串。我们考虑模板字符串在目标字符串第一次出现的开始下标时,首先忽略了第一组,在第二组时开始检索,再到第三组时发现了冲突。
如果根据上面的思路,在第三组字符串中做检索失败后,我们应该回退到第二组的第二个字符重新开始检索,然后忽略掉整个第二组字符串剩下的内容,在第三组的开头重新进入与目标字符串的匹配。这就是上面的思路我们所做的。
不过,这虽然正确,但是我们人眼可以很容易的分辨,我们只是从第三组字符串的e那里发生了冲突
,在之前的abcd
这一部分与模板字符串的开头
是相等的,我们完全不再需要匹配这一部分。
理论存在
理解了上面所说的可以优化的部分后,接下来就该引入我们的KMP算法了。在上面一段文字中,我使用行内代码标注了“开头”这个词,其实在字符串中,这个词通常被称为前缀。
不知道说到这大家明白了没有,结合上面的例子再解释一下就是:匹配到第三组的e的时候,发现了冲突,但是同时我们知道(KMP的重难点)在e的前面,有4个字符与字符串开头的长度为4的前缀相同,那么接下来只需从模板字符串下标为4的地方继续匹配即可。
(加粗了长度和下标,大家稍微对照着例子的字符串就可以知道,当前相等的前缀的长度就是从新开始匹配的位置,毕竟下标从0开始嘛)
简单总结一下
到这里简单总结一下KMP算法的精髓思想:即保留模版字符串每一个位置之前有多少个字符与字符串的前缀相等,匹配到冲突时,跳过相等字符数量长度的前缀继续匹配。
难点
KMP的重难点就在于,如何才能获知当前冲突的位置的前面有多少个字符与字符串的前缀相等。这里我们解决这个问题使用到了一个next或者叫prefix数组,用来在冲突的时候查询可以跳过字符串的多长的前缀。
解决难点
解决这个难点,我们抛去前面的内容,专心地去理解如何计算到前缀数组。我们这里换一个更好的例子:ABABDABABAE。如果直观地按照刚才描述的前缀,列出一个表格应该是这样。
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
---|---|---|---|---|---|---|---|---|---|---|
A | B | A | B | D | A | B | A | B | A | E |
0 | 0 | 1 | 2 | 0 | 1 | 2 | 3 | 4 | 3 | 0 |
直接观察的话不难理解,问题是如何计算?
简单情况
如果没有动态规划算法基础,尝试向前遍历,先假设匹配的前缀长度为1,那么从头开始截取一个字符长度的字符串,从当前下标位置向前截取相同长度字符串,一直比较,直到不相等或匹配到了(假设下标为i)i/2
长度,都不相等,那就是0了。
为什么要提动态规划呢?这是因为后面的状态典型地可以从前面的计算结果推断而无需重复计算。
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
---|---|---|---|---|---|---|---|---|---|---|
A | B | A | B | D | A | B | A | B | A | E |
0 | 0 | 1 |
从目前的情况开始理解,如果我获知了前三个,那下标3如何计算?(假设数组为chars,下标为index)
已知,index为2的位置,从开头已经有一个字符和他匹配。如果index3还可以继续匹配,那么一定是chars[index[2]] == chars[3]
。有点绕,各位尝试先理解一下。简单说就是如果想接上,你得和刚才匹配上的字符串的下一个位置相等。
复杂情况
刚刚的情况应该已经解决了,可以理解了,那就可以接着思考了。我们假设,下面数组的内容已经算完了,都是正确的哈,继续算的这个位置才是最难理解的。
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
---|---|---|---|---|---|---|---|---|---|---|
A | B | A | B | D | A | B | A | B | A | E |
0 | 0 | 1 | 2 | 0 | 1 | 2 | 3 | 4 |
算到现在,加粗的两对的确是匹配的。(我们也可以看到,如果匹配到模板字符串下标为8的位置以后冲突了(不是A),接着看目标字符串的下一个位置是不是D就行了(下标为4),这是正确的。接着就看怎么计算下标为9位置的值了。)
首先还是和刚刚一样,试图去比对下标为9和下标为4的字符,这里就和刚刚不同了,匹配是失败的。那应该是几?
我们当然不能一个一个地试下去?5不行可不可以是4?4不行可不可以是3?……那样你想想,最终就退化成了一开始的想法了,开始一个一个地去比较,最后变成O(m²)
。(从当前位置的前一个位置存的值开始,一个个地从头匹配直至到0,搞不好真成平方量级)
这样我来重新加粗这个表格,先展示一个结果,思考一下为什么直接变成这样了:
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
---|---|---|---|---|---|---|---|---|---|---|
A | B | A | B | D | A | B | A | B | A | E |
0 | 0 | 1 | 2 | 0 | 1 | 2 | 3 | 4 |
一定好奇怎么来的,为什么一下子停到这了?我来解释一下。
在之前我们比较ABABD和ABABA的过程中,我们尝试失败了。但是,如果尝试缩短字符串,可能会成功,就想这个实例中展示的这样,我们的所有的前缀中,虽然不包括ABABE,但是可以包含BABA或ABA或BA或A,这要取决于有没有这样的前缀。即需要在ABAB中继续寻找一个前缀,然后对比这个前缀的下一个位置的字符是不是下标9位置的字符。
这样说可能不理解,换种说法:已知有一个前缀是ABAB,下标是0~3。这个长度为4的字符串中,可能还有前缀,这个新前缀的下一个字符如果是A,那就和9的位置匹配上了。怎么知道还有没有前缀?从下标为3的位置存储的值看一直找回去,直到为0或找到。以上就是KMP算法的思路了。
实践开始
一种通用的实现KMP计算prefix数组的实现。
/**
* aabaabaabaab 010123456789
* @param prefix 前缀数组,函数负责修改其内部的值
* @param s 原字符串
*/
private void prefix (final int[] prefix, final String s) {
// 初值是0
prefix[0] = 0;
// 对于下标我习惯叫max,意为当前所能匹配的最长的前缀长度
int max;
for (int i = 1; i < s.length(); i++) {
// 复制前一个位置的最长长度到此处
max = prefix[i - 1];
// 并尝试判断是否可以继续匹配
// 如不能,则对比上一个匹配的前缀,试图当前值可以匹配稍短一点的前缀
while (max > 0 && s.charAt(i) != s.charAt(max)) {
max = prefix[max - 1];
}
// 判断:这里算是比较巧妙,如果是找到了前缀,那本应该长度+1
// 如果不能,说明退回到了开头,也需要对比一下是否与开头位置相同,如aba下标为2的a
if (s.charAt(i) == s.charAt(max)) {
max++;
}
prefix[i] = max;
}
}
当然,我们也可以将上面的prefix数组从1开始的位置整体向右移动一位,这样是为了在检索的时候,当考虑下标为i的位置可以匹配的最长的前缀的时候,省去-1的操作,也即省去是否数组越界的检查。当然,这是具体情况具体分析,可以根据题目或实际场景来对实现做微调。算法并不是一成不变的模板。
希望本文对您理解KMP算法有所帮助!(首尾呼应的写作手法)
勘误
邮箱:2476594519@qq.com