KMP 算法,是很多人大学的噩梦,我也不例外,今天刷力扣,恰巧遇到一道需要使用 KMP 算法求解的题目,因此在这里整理一波 KMP 算法。在此声明,本文借鉴于代码随想录题解,本人仅作学习总结用,无其他用途。
更加推荐到语雀来阅读我的文章:语雀-KMP算法(Jungle)
1、什么是 KMP 算法
1、什么是 KMP 算法,先说一下 KMP 这个名字是怎么来的,为什么叫 KMP 呢?
因为是由这三位学者发明的:Knuth,Morris 和 Pratt,所以取了三位学者名字的首字母。所以叫做 KMP。
2、KMP 有什么用处?
KMP 主要应用在字符串匹配上。
KMP 的主要思想是当出现字符串不匹配时,可以知道一部分之前已经匹配的文本内容,可以利用这些信息避免从头再去做匹配。
举一个例子:
文本串 s:a a b a a b a a f a
模式串 t:a a b a a f
在上述中,正是由于记录了已经匹配的文本内容,才可以实现利用这些信息避免从头再去做匹配。 而我们将这些信息记录在一个叫做前缀表的数组里,通常而言,用 next 数组来代表前缀表。
写过 KMP 的同学,一定都写过 next 数组,那么这个next数组究竟是个啥呢?next 数组就是一个前缀表(prefix table)
前缀表有什么作用呢?
前缀表是用来回退的,它记录了模式串和主串(文本串)不匹配的时候,模式串应该从哪里开始重新匹配。
要在文本串:aabaabaafa中查找是否出现过一个模式串:aabaaf。
为了清楚的了解前缀表的来历,我们还是举上面这个例子:
可以看到,文本串第六个字符 b 和模式串的第六个字符 f,不匹配了。如果是暴力匹配,发现不匹配,此时就要从头匹配了。但如果使用前缀表,就不会从头匹配,而是从上次已经匹配的内容开始匹配,找到了模式串第三个字符 b 继续开始匹配。
此时,我们就需要知道前缀表是如何记录的呢?
首先要知道前缀表的任务是当前位置匹配失败,找到之前已经匹配上的位置,再重新匹配,此也意味着在某个字符失配时,前缀表会告诉你下一步匹配中,模式串应该跳到哪个位置。
那么什么是前缀表:记录下标 i 之前(包括 i )的字符串中,有多大长度的相同前缀后缀。
2、 最长相同前后缀
文章中,字符串的前缀是指不包含最后一个字符的所有以第一个字符开头的连续子串。
后缀是指不包含第一个字符的所有以最后一个字符结尾的连续子串。
以字符串 a a b a a 为例:
前缀: a
a a
a a b
a a b a
后缀: a
a a
b a a
a b a a
可以看到,字符串 a a b a a
的前缀和后缀分别有 4 种,其中 aa
是它们的最长相同前后缀,那么这个最长相同前后缀表示的意义是什么呢?,对于字符串 a a b a a
来说,它表示以 b 为中间点,前后字符串是相同的。即当 a 的下一位如果与文本串不匹配,那么此时前缀表可以回退到 b 这个位置来继续进行匹配,为什么能回退到 b 呢?正式因为 b 之前的字符串和 b 之后的字符串是相同的,对于当前而言,b 的右侧 aa 已经匹配上了,因此当然不需要从 b 的左侧从头去匹配,这就是前缀表的作用,那我们怎么知道回退到的位置是 b 呢?
这就是 next 数组需要做的事情,next 数组来做回退操作,因此需要记录每个下标需要回退的位置。
3、 前缀表与 next 数组
接下来说一说如何计算前缀表:
如图:长度为前 1 个字符的子串 a,最长相同前后缀的长度是 0.(注意:字符串的前缀是不包含最后一个字符的所有以第一个字符开头的连续子串;后缀是指不包含第一个字符的所有以最后一个字符结尾的连续子串)
长度为前 2 个字符的子串 aa
,最长相同前后缀的长度为 1。
长度为前 3 个字符的子串aab
,最长相同前后缀的长度为 0。
以此类推:长度为前4个字符的子串aaba
,最长相同前后缀的长度为 1。长度为前 5 个字符的子串aabaa
,最长相同前后缀的长度为 2。长度为前 6 个字符的子串 aabaaf
,最长相同前后缀的长度为 0。
那么把求得的最长相同前后缀的长度就是对应前缀表的元素,如图:
再来看一下如何利用前缀表找到 当字符不匹配的时候指针应该移动的位置。如动画所示:
找到了不匹配的位置,那么此时我们要看它的前一个字符的前缀表的数值是多少。
为什么要前一个字符的前缀表的数值呢?因为要找前面字符串的最长相同的前缀和后缀。所以要看前一位的前缀表的数值。
前一个字符的前缀表的数值是 2,所以把下标移动到下标 2 的位置,然后继续匹配。(可以再反复看一下上面的动画)
最后就在文本串中找到了和模式串匹配的子串了。
很多 KMP 算法的实现都是使用 next 数组来做回退操作,那么 next 数组和前缀表有什么关系呢?
next数组就可以是前缀表,但是很多实现都是把前缀表统一减一(右移一位,初始位置为-1)之后作为next数组。
为什么这么做呢?这个地方其实很多文章视频没有解释清楚。
其实这并不设计到 KMP 的原理,而是具体实现,next 数组既可以就是前缀表,也可以是前缀表统一减一(右移一位,初始位置为 -1)
后面会提供两种不同的实现代码,大家就明白了。
4、 使用 next 数组来做匹配
以下我们以前缀表统一减一之后的next数组来做演示。
有了next数组,就可以根据next数组来匹配文本串s,和模式串t了。注意next数组是新前缀表(l旧前缀表统一减一了)。
匹配过程动画如下:
时间复杂度分析:
其中n为文本串长度,m为模式串长度,因为在匹配的过程中,根据前缀表不断调整匹配的位置,可以看出匹配的过程是O(n),之前还要单独生成next数组,时间复杂度是O(m)。所以整个KMP算法的时间复杂度是o(n+m)的。
暴力的解法显而易见是O(n×m),所以KMP在宇符串匹配中极大地提高了搜索的效率。
为了和力扣题目28.实现strStr保持一致,方便大家理解,以下文章统称haystack为文本串,needle为模式串。
都知道使用KMP算法,一定要构造next数组。
5、 构造 next 数组
这里我再次给出字符串前后缀的例子:
以字符串 a a b a a 为例:
前缀: a
a a
a a b
a a b a
后缀: a
a a
b a a
a b a a
// 获取前缀表next
public void getNext(int[] next, String s) {
int j = -1;
next[0] = j;
for(int i = 1; i < s.length(); i++) {
// i指向后缀末尾位置,j指向前缀末尾位置
while(j >= 0 && s.charAt(i) != s.charAt(j + 1)) {
j = next[j];
}
if(s.charAt(i) == s.charAt(j + 1)) {
j++;
}
next[i] = j;
}
}
接下来让我们来详解一下:
- 初始化:
-
- 定义两个指针 i 和 j,j 指向前缀末尾位置,i 指向后缀末尾位置。
- 然后对 next 数组进行初始化赋值,如下:
int j = -1;
next[0] = j;
这表示对于字符串a
来说,它的最长相同前后缀就是 -1(为什么 j 要初始化为 -1 呢?因为之前说过,前缀表要统一减一的操作仅仅是其中的一种实现,我们这里选择 j 初始化为-1,下文我还会给出 j 不初始化为-1 的实现代码。)
- 处理前后缀不相同的情况:
因为 j 初始化为 -1,那么 i 就从 1 开始,进行s[i]
与 s[j+1]
的比较。所以遍历模式串 s 的循环下标 i 要从 1 开始,代码如下:
for(int i = 1; i < s.length(); i++) {}
如果 s[i]
与 s[j+1]
不相同,也就是遇到 前后缀末尾不相同的情况,就要向前回退。
怎么回退呢?
next[j]
就是记录着 j(包括 j)之前的子串的相同前后缀的长度。那么s[i]
与 s[j+1]
不相同,就要找 j + 1 前一个元素在 next 数组里的值(就是 next[j])。
所以处理前后缀不相同的情况代码如下:
while(j >= 0 && s.charAt(i) != s.charAt(j + 1)) {
j = next[j];
}
- 处理前后缀相同的情况
如果 s[i]
与 s[j+ 1]
相同,那么就同时向后移动i 和j 说明找到了相同的前后缀,同时还要将 j(前缀的长度)赋给next[i]
,因为next[i]
要记录相同前后缀的长度。
代码如下:
if (s[i] == s[j + 1]) { // 找到相同的前后缀
j++;
}
next[i] = j;
最后整体构建 next 数组的函数代码如下:
// 获取前缀表next
public void getNext(int[] next, String s) {
int j = -1;
next[0] = j;
for(int i = 1; i < s.length(); i++) { // 注意i从1开始
while(j >= 0 && s.charAt(i) != s.charAt(j + 1)) { // 前后缀不相同了
j = next[j]; // 向前回退
}
if(s.charAt(i) == s.charAt(j + 1)) { // 找到相同的前后缀
j++;
}
next[i] = j; // 将j(前缀的长度)赋给next[i]
}
}
代码构造 next 数组的逻辑流程动图如下:
6、 使用 next 数组来做匹配
得到了next数组之后,就要用这个来做匹配了。
让我们回顾一下题目:28. 找出字符串中第一个匹配项的下标
在这道题目中,字符串haystack
是文本串 s,字符串needle
是模式串 t。按照我的习惯,先上整体代码,然后逐行解释:
class Solution {
public int strStr(String haystack, String needle) {
// 在java的indexOf函数中,当needle为空时,返回的是0
if(needle.length() == 0) {
return 0;
}
int[] next = new int[needle.length()];
getNext(next, needle);
int j = -1; // 因为next数组里记录的起始位置是-1
for(int i = 0; i < haystack.length(); i++) { // 注意i就从0开始
while(j >= 0 && haystack.charAt(i) != needle.charAt(j + 1)) { // 不匹配,回退
j = next[j];
}
if(haystack.charAt(i) == needle.charAt(j + 1)) { // 匹配,i和j同时向后移动
j++;
}
if(j == needle.length() - 1) { // 文本串s里出现了模式串t,则可以返回
return (i - needle.length() + 1);
}
}
return -1;
}
// 获取前缀表next
public void getNext(int[] next, String s) {
int j = -1;
next[0] = j;
for(int i = 1; i < s.length(); i++) {
// i指向后缀末尾位置,j指向前缀末尾位置
while(j >= 0 && s.charAt(i) != s.charAt(j + 1)) {
j = next[j];
}
if(s.charAt(i) == s.charAt(j + 1)) {
j++;
}
next[i] = j;
}
}
}
我相信看到这里,其实已经对 KMP 算法有所体会了,这段代码也能够看得懂,但还是解释一下这段代码吧。
在文本串s里找是否出现过模式串t,需要定义两个下标,下标j指向模式串起始位置,i指向文本串起始位置。那么j初始值依然为-1,为什么呢?因为 next 数组里记录的起始位置为-1。
i 就从 0 开始,遍历文本串,代码如下:
for (int i = 0; i < s.length(); i++)
接下来就是 s[i] 与 t[j + 1] (因为j从-1开始的)1)进行比较。
如果 s[i] 与 t[j+ 1] 不相同,j 就要从next 数组里寻找下一个匹配的位置。代码如下:
while(j >= 0 && s.charAt(i) != t.charAt(j + 1)) {
j = next[j];
}
如果 s[i] 与tj+ 1] 相同,那么 i 和 j 同时向后移动,代码如下:
if (s.charAt(i) == t.charAt(j + 1)) {
j++; // i的增加在for循环里
}
如何判断在文本串 s 里出现了模式串t呢,如果 j 指向了模式串 t 的末尾,那么就说明模式串 t 完全匹配文本串 s 里的某个子串了。
本题要在文本串字符串中找出模式串出现的第一个位置(从0开始),所以返回当前在文本串匹配模式串的位置i减去模式串的长度,就是文本串字符串中出现模式串的第一个位置。
代码如下:
if (j == (t.length() - 1) ) {
return (i - t.length() + 1);
}
那么使用 next 数组,用模式串匹配文本串的整体代码如下:
for(int i = 0; i < haystack.length(); i++) { // 注意i就从0开始
while(j >= 0 && haystack.charAt(i) != needle.charAt(j + 1)) { // 不匹配,回退
j = next[j];
}
if(haystack.charAt(i) == needle.charAt(j + 1)) { // 匹配,i和j同时向后移动
j++;
}
if(j == needle.length() - 1) { // 文本串s里出现了模式串t,则可以返回
return (i - needle.length() + 1);
}
}
这就是所有的逻辑代码了。
7、 前缀表(统一减一)Java 实现
class Solution {
public int strStr(String haystack, String needle) {
// 在java的indexOf函数中,当needle为空时,返回的是0
if(needle.length() == 0) {
return 0;
}
int[] next = new int[needle.length()];
getNext(next, needle);
int j = -1; // 因为next数组里记录的起始位置是-1
for(int i = 0; i < haystack.length(); i++) { // 注意i就从0开始
while(j >= 0 && haystack.charAt(i) != needle.charAt(j + 1)) { // 不匹配,回退
j = next[j];
}
if(haystack.charAt(i) == needle.charAt(j + 1)) { // 匹配,i和j同时向后移动
j++;
}
if(j == needle.length() - 1) { // 文本串s里出现了模式串t,则可以返回
return (i - needle.length() + 1);
}
}
return -1;
}
// 获取前缀表next
public void getNext(int[] next, String s) {
int j = -1;
next[0] = j;
for(int i = 1; i < s.length(); i++) { // 注意i从1开始
// i指向后缀末尾位置,j指向前缀末尾位置
while(j >= 0 && s.charAt(i) != s.charAt(j + 1)) { // 前后缀不相同了
j = next[j]; // 向前回退
}
if(s.charAt(i) == s.charAt(j + 1)) { // 找到相同的前后缀
j++;
}
next[i] = j; // 将j(前缀的长度)赋给next[i]
}
}
}
- 时间复杂度:O(n + m)
- 空间复杂度:O(m),只需要保存字符串needle的前缀表
8、 前缀表(不减一)Java 实现
那么前缀表就不减一了,也不右移的,到底行不行呢?是可以的。
这仅仅是 KMP 算法实现上的问题,果就直接使用前缀表可以换一种回退方式,找 j = next[j - 1]
来进行回退。
主要就是j=next[x]
这一步最为关键!
我给出的getNext的实现为: (前缀表统一减一)
public void getNext(int[] next, String s) {
int j = -1;
next[0] = j;
for(int i = 1; i < s.length(); i++) { // 注意i从1开始
while(j >= 0 && s.charAt(i) != s.charAt(j + 1)) { // 前后缀不相同了
j = next[j]; // 向前回退
}
if(s.charAt(i) == s.charAt(j + 1)) { // 找到相同的前后缀
j++;
}
next[i] = j; // 将j(前缀的长度)赋给next[i]
}
}
此时如果输入的模式串为aabaaf,对应的next为-1 0 -1 0 1 -1。
这里 j 和 next[0] 初始化为-1,整个 next 数组是以前缀表减一之后的效果来构建的。
那么前缀表不减一来构建next数组,代码如下:
public void getNext(int[] next, String s) {
int j = 0;
next[0] = j;
for(int i = 1; i < s.length(); i++) { // j要保证大于0,因为下面有取j-1作为数组下标的操作
while(j > 0 && s.charAt(i) != s.charAt(j)) {
j = next[j - 1]; // 注意这里,是要找前一位的对应的回退位置了
}
if(s.charAt(i) == s.charAt(j)) {
j++;
}
next[i] = j;
}
}
此时如果输入的模式串为aabaaf,对应的next为 0 1 0 1 2 0,(其实这就是前缀表的数值了)。
那么用这样的 next 数组也可以用来做匹配,代码要有所改动。
实现代码如下:
class Solution {
public int strStr(String haystack, String needle) {
// 在java的indexOf函数中,当needle为空时,返回的是0
if(needle.length() == 0) {
return 0;
}
int[] next = new int[needle.length()];
getNext(next, needle);
int j = 0;
for(int i = 0; i < haystack.length(); i++) { // 注意i就从0开始
while(j > 0 && haystack.charAt(i) != needle.charAt(j)) { // 不匹配,回退
j = next[j - 1];
}
if(haystack.charAt(i) == needle.charAt(j)) { // 匹配,i和j同时向后移动
j++;
}
if(j == needle.length() - 1) { // 文本串s里出现了模式串t,则可以返回
return (i - needle.length() + 1);
}
}
return -1;
}
// 获取前缀表next
public void getNext(int[] next, String s) {
int j = 0;
next[0] = j;
for(int i = 1; i < s.length(); i++) { // j要保证大于0,因为下面有取j-1作为数组下标的操作
while(j > 0 && s.charAt(i) != s.charAt(j)) {
j = next[j - 1]; // 注意这里,是要找前一位的对应的回退位置了
}
if(s.charAt(i) == s.charAt(j)) {
j++;
}
next[i] = j;
}
}
}
文章引用:代码随想录