引言
✨ “说实话,我已经学过 KMP 算法好几次了!第一次接触它时,真的觉得好难啊!😵💫 但最终还是勉强记住了。”
✨ “然而,每次需要手写 KMP 时,还是会遇到各种问题,写着写着就懵了……😥”
所以,我决定写一篇博客,希望可以帮你更好地理解 KMP 的思想,并且可以自己实现出来哦!💪💕
KMP 算法:
我们要解决的问题是 在一个字符串 str
里,找到 match
字符串最早出现的位置。
🌸 如果找到了,就返回索引;如果找不到,就返回 -1。
下面是 KMP 需要掌握的几个重要知识点:
- KMP 算法是干什么的?
- 先看看 BF(Brute Force)暴力解法
- 认识
next
数组 - 如何用
next
数组加速匹配? - 为什么
next
数组能提高效率? - 如何快速求
next
数组? - 代码实现!(C++ & Java)
全文主要采用代码C++
叙述, 不影响KMP算法的掌握。 Java
完整代码详见后文的实现
KMP 算法是什么?
KMP 算法由 Donald Knuth, Vaughan Pratt, James H. Morris 在 1977 年提出。💡
它主要用于字符串匹配和文本搜索。
问题描述:
假设我们有两个字符串:
str = "abcde"
match = "cd"
我们想要找到 match
在 str
里最早出现的位置,所以返回 2(从 0 开始计数)。
如果 str = "abcd"
,match = "de"
,那么显然 match
不在 str
里,所以返回 -1。
先看看 BF(暴力解法)💥
KMP 算法的核心思想是 优化暴力匹配,所以我们先来看看 BF(Brute Force)暴力解法。
BF 算法的思路
- 遍历
str
的所有可能起点(从0
到n-m
)。 - 对每个起点,依次匹配
match
的字符。 - 如果成功匹配
match.length()
个字符,就找到了!🎉 - 如果匹配到一半发现不对劲,就换下一个起点继续匹配。
🌸 代码实现(C++)
class Solution {
public:
int strStr(string haystack, string needle) {
int n = haystack.length(), m = needle.length();
if (n < m) return -1;
for (int i = 0; i <= n - m; i++) {
int j;
for (j = 0; j < m; j++) {
if (haystack[i + j] != needle[j]) {
break;
}
}
if (j == m) return i;
}
return -1;
}
};
🌟 BF 算法的时间复杂度是
O
(
n
×
m
)
O(n \times m)
O(n×m)。
当 n
远远大于 m
时,匹配过程会非常慢,比如:
str = aaaaaaaaaaaaaaaaaaaaab
match = ab
会导致 大量的无效匹配,所以我们需要更聪明的做法—— KMP!🤩
认识 next
数组 💡
KMP 的核心优化点就是 next
数组!但它到底是什么呢?😵💫 别急,慢慢来~
🌸 next[i] 的含义:
next[i]
代表的是:match[0:i]
的前后缀的最大匹配长度。
(前后缀不能是这个字符串本身哦)
看完这个示例你应该理解next数组的含义。
暂时不要考虑next数组如何求解, 先看下一个标题如何利用它加速求解吧。
举个例子:
假设 match = "aabaabtabc"
,我们来计算 next[6]
,也就是 match[0:6] = "aabaab"
的 next
值:
找 next[6]
就是找 前后缀匹配最长的部分:
- 前缀:从开头开始,不能包含整个
aabaab
- 后缀:从结尾往前看,不能包含整个
aabaab
以下模拟"找"的过程。
aabaabtabc
i
i指向match字符串的t字符, next[i]是i位置前面字符串`aabaab` 前后缀字符串的最大匹配长度。
🆗,下面来求解这个前后缀字符串是什么, 以及长度
前缀串是[0,l], 后缀串是[r,i-1]
aabaab
l r
前缀串:a
后缀串:b
❌ 不匹配 前后缀串最大长度为0
aabaab
l r
前缀串:aa
后缀串:ab
❌ 不匹配 前后缀串最大长度为0
aabaab
lr
前缀串:aab
后缀串:aab
✅ 匹配长度 前后缀串最大长度更新为3
aabaab
rl
前缀串:aaba
后缀串:baab
❌ 不匹配 ,前后缀串最大长度保持为3。
aabaab
r l
前缀串:aabaa
后缀串:abaab
❌ 不匹配 前后缀串最大长度保持为3
aabaab
r l
这种情况前后缀串等于这个字符串本身了本身了,不符合next的要求。
next[i] = 3。✅ 匹配长度 3
🎀 总结:
next[i]
让我们知道 如果匹配失败,模式串该回退到哪里,这样就能避免大量的无效匹配,提高效率!
KMP 代码实现 🎯
利用 next
数组进行匹配加速!
🌸 核心思想:
- 如果
str[i] == match[j]
,匹配成功,i++
,j++
。 - 如果
str[i] != match[j]
,匹配失败:j > 0
,说明match
里有前后缀匹配,我们直接跳到next[j]
位置,避免重新匹配。j == 0
,说明match
没有可以跳过的部分,i++
继续匹配下一个字符。
🌸 代码实现
int kmp(const string& str, const string& match) {
int n = str.length(), m = match.length();
if (n < m) return -1;
// 计算 next 数组
vector<int> next = next_array(match);
int i = 0, j = 0;
while (i < n && j < m) {
if (str[i] == match[j]) {
i++, j++;
} else if (j > 0) {
j = next[j]; // 匹配失败,回溯到 next[j]
} else {
i++; // 没有可以回溯的地方,继续向前
}
}
return (j == m) ? i - j : -1;
}
🌸模拟说明:
下面是索引index,str,match字符串已经match的next数组值
index: 0 1 2 3 4 5 6 7 8 9 10111213
str = a a b a a b c a a b a a b a ...
match= a a b a a b c a a b a a b t
next= -1 0 1 0 1 2 3 0 1 2 3 4 5 6
i
j
按照上面的代码逻辑,它会一直执行str[i]==match[j]分支,
直到index=13处,str[13]=a, match[13]=t. 两者不相等。
如果按照Brute Force算法,下面应该移动起始索引(从0开头挪到1开头)进行匹配。
KMP算法利用next数组,已知的前后缀串信息, 加速匹配。
match下标13的前面字符串是aabaabcaabaab, 前后缀串是aabaab,长度是6。(next[13]存储的信息)
前后缀串的位置是已知的,因为开头start确定,末尾end确定。 前缀串是[start,start+6), 这里start是0,表示str[0,6)的子串。end从start(0)到13(不匹配),后缀串是[end-6,end),这里是str[7,13)的子串。
我们发现[0,6),和(7,13]是完全匹配的。next的定义!
思路是平移和跳转
index: 0 1 2 3 4 5 6 7 8 9 10111213
str = a a b a a b c a a b a a b a ...
match= a a b a a b c a a b a a b t
i
j
match的0与str的7对齐,末尾6和13对齐。
这个时候我们舍弃前面的1 2 3 4 5 6作为起始索引的可能了, 它们一定不会匹配成功的(why? 详见下文)。 这样就加速了。
只需要再次考察index=13的位置。
由于有平移操作,因此要更新j的位置, j原先在match串13位置
[0,6)
[7,13)
前缀串的开区间的末尾下标6就是next[13].
因此跳跃j=13->j=6
相当于
`j=next[j]`, 这是第二个分支else-if的含义。
next[j]!=-1或者j>0说明当前j不是在0位置,可以跳跃。
接着看这个样例。
str[13]!=match[6], 尽管平移了一次。但仍然不匹配。
继续平移。
j = next[6]; //next[6]等于3.
----
index: 0 1 2 3 4 5 6 7 8 9 10111213
str = a a b a a b c a a b a a b a ...
match= a a b a a b c a a b a a b t
i
j
终于
str[13]与match[3]匹配上了。
后续str字符串省略了。
但我说明的核心过程已经完成了。
💖 为什么这样可以加速?
- 传统暴力匹配失败时,要 回到开头 重新比对;
- KMP 利用
next
数组,让match
直接跳到合适的位置,避免重复匹配。
str : ----------l
match: ----------y
next :-10--------k
i
j
现在假设在下标j处str[i]=l, match[j]=y,两者不匹配。
next[j]=k.
说明[start,start+k) 与 [end-k,end)匹配。
因此想到平移。
有如下对应:
start<-end-k
start+1<-end-k+1
...
start+k-1<-end-1
比较start+k<-end
j往左挪到start+k的位置。
然后继续比较。
总结第一点:
由next数组记录的前后缀串长度信息,
完整地[start,start+k)与[end-k, end)一定相等,不用验证。
第二个:
为什么start+1, start+2,一直到end-k+1的位置为开头一定不成立,不验证了,直接挪到end-k位置开始比对呢?
不妨反证一下,
从[start, end-k), 假设取一个下标x能够匹配。
start x start+k end-k end
| | | | |
str : ---------------------l
match: ---------------------y
next :-10-------------------k
i
j
假设str[x,end)能匹配上match。那么说明match必须要从0开始有end-x的长度。
next[j]是end-x
但由于x在[start, end-k), 这个假设会导致next[j]值应当大于k,故这样的x不存在。
因此,[start,end-k)这样位置直接放弃!
next 数组快速求解 💡
🤩快速求解 match
字符串的 next
数组。
首先,next[0] = -1
,next[1] = -1
。这两个是确定的。那么对于一般的 next[i]
,可以根据前面的已知信息来推导。
在我看来,这本质上与前面加速求解相同,核心是跳转。
示例一:
match = abatabasabatabas?
next = ---------------7?
7 i
求解 next[i]
,已知 next[i-1]
,我们观察一下 i-1
,它的前缀后缀串是 abataba
,长度是 7。
我们不妨观测 match[7]
的位置,match[i-1]
与 match[7]
是相等的!
故 next[i] = next[i-1] + 1
。
遗憾的是,示例一不总是发生。
示例二:
match = abatabasabatabat?
next = ---------------7?
3 7 i
i-1
位置字符变为 t
了,match[i-1] != match[7]
。
直觉告诉我们,next[i]
只能小于 next[i-1]
。
t
字符前后缀串是 i-1
前后缀串的子串。
由于前缀串后缀串的位置对应关系,
先从 i-1
位置跳转 7
。
next[7] = 3
,观察一下 match[3]
,match[3] = match[i]
!
说明 next[i] = next[3] + 1
。
为什么?
即 7
位置前缀串 aba
等于 7
位置的后缀串。
7
位置的后缀串是 i-1
位置后缀串的子串。
总之,经过这种对应(自行体会),abat
这个串就作为 i
位置的前缀串,同样在对应后它也满足后缀串。
总之:经过了一次跳转,然后用示例一的思路和前后缀对应的思路,同样推出了 next[i]
的信息。
示例三:
match = a b a
next = -1 0 ?
i
b
字符 next
值为 0
,跳转到 0
下标处。 a
字符与 b
字符不等。
但已经无法跳转,故确定 next[i] = 0
。
代码实现 ✨
vector<int> next_array(const string& s, int n) {
if (n == 1) return {-1};
vector<int> next(n);
next[0] = -1, next[1] = 0;
for (int i = 2, pre = 0; i < n;) {
if (s[i - 1] == s[pre]) next[i++] = ++pre;
else if (pre > 0) pre = next[pre];
else next[i++] = 0;
}
return next;
}
代码解释 ✨
- 首先,如果
match
串本身长度为1
,返回{-1}
即可。 next[0] = -1, next[1] = 0;
不解释。pre
是前一个字符比对的下标。i
表示当前求解next[i]
的下标。
示例一举例:
match = abatabasabatabas?
next = -10--------------
i
- 初始
pre = 0, i = 2
pre = 0
,无法跳转,执行分支三,结算当前next[i] = 0
,i
自增1
。
match = abatabasabatabas?
next = -100-------------
i
s[i-1]
与s[pre]
比对,a
与a
相等。- 结算
i
位置next[i] = next[i-1] + 1, next[i-1] = pre,
故next[i++] = ++pre
。 i
自增是因为结算了,pre
自增是更新后续下一个i
的比对位置。- 注意前增和后增!
时空复杂度分析 💡
next_array
函数,KMP 算法的预处理过程,时间复杂度推导。
求解 next 数组时间复杂度分析
✨
vector<int> next_array(const string& s, int m) {
if (m == 1) return {-1};
vector<int> next(m);
next[0] = -1, next[1] = 0;
for (int i = 2, pre = 0; i < m;) {
if (s[i - 1] == s[pre]) next[i++] = ++pre;
else if (pre > 0) pre = next[pre];
else next[i++] = 0;
}
return next;
}
i
从2
递增到m
, O ( m ) O(m) O(m)。如果不走第二个分支的话。- 考虑到第二个分支,时间复杂度可能比较难理解了。
推荐这样的方式理解:
- 构造一个变量
i - pre
。 i - pre
是有上界的,就是m
。- 从第一个分支,
i
自增,i - pre
不变。因为pre
也增加。 - 从第二个分支,
i
不变,i - pre
增加。因为pre
会回溯到更小的位置处。 - 从第三个分支,
i
自增,i - pre
不变。 - 我们可以得出
i + (i - pre)
是严格递增的。无论从哪个分支,因此循环总次数就是i + (i - pre)
的上界 2 m 2m 2m。
next_array
函数的时间复杂度是
O
(
m
)
O(m)
O(m)。
同样地,得出 kmp
函数时间复杂度是
O
(
n
)
O(n)
O(n)。
准确地:KMP 算法的时间复杂度是
O
(
n
+
m
)
O(n + m)
O(n+m),一般 str
长度远大于 match
长度。即
n
≫
m
n \gg m
n≫m,
时间复杂度
O
(
n
)
O(n)
O(n)。
空间复杂度使用了部分匹配表 next
数组:
O
(
m
)
O(m)
O(m)。
C++,Java完整实现. ✨
c++:
#include<iostream>
#include<vector>
#include<string>
using namespace std;
//KMP算法 用于字符串匹配
int kmp(const string& str, const string& match);
vector<int> next_array(const string& s, int n);
int kmp(const string& str, const string& match){
int n = str.length(), m = match.length();
vector<int> next = next_array(match, m);
int i=0,j=0;
while(i<n&&j<m){
if(str[i]==match[j]) ++i,++j;
else if(j==0) ++i;
else j=next[j];
}
return j==m?i-j:-1;
}
//求s串的next数组
vector<int> next_array(const string& s, int n){
if(n==1) return {-1};
vector<int> next(n);
next[0]=-1,next[1]=0;
for(int i=2,pre=0;i<n;){
if(s[i-1]==s[pre]) next[i++]=++pre;
else if(pre>0) pre=next[pre];
else next[i++]=0;
}
return next;
}
int main(){
ios::sync_with_stdio(0);
cin.tie(0);
string str, match;
getline(cin, str);
getline(cin, match);
int idx = kmp(str, match);
if(idx==-1){
cout<<"str不存在子串match"<<'\n';
}else{
printf("str第一次出现match串的下标:%d\n", idx);
}
return 0;
}
java:
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.io.IOException;
public class code01kmp {
public static int[] DEFALUT_ARRAY = new int[]{-1,0};
public static int[] getNextArr(String s, int n){
if(n<=2){
return DEFALUT_ARRAY;
}
int[] next = new int[n];
next[0] = -1;
next[1] = 0;
for(int i=2,pre=0;i<n;){
if(s.charAt(i-1)==s.charAt(pre)) next[i++] = ++pre;
else if(pre>0) pre=next[pre];
else next[i++]=0;
}
return next;
}
public static int KMP(String str, String match){
int n = str.length(), m = match.length();
if(n<m) return -1;
int[] next = getNextArr(match,m);
int i,j;
for(i=0,j=0;i<n && j<m;){
if(str.charAt(i)==match.charAt(j)){
i++;
j++;
}else if(j==0){
++i;
}else{
j = next[j];
}
}
return j==m?i-j:-1;
}
public static void main(String[] args) throws IOException{
BufferedReader in = new BufferedReader(new InputStreamReader(System.in));
PrintWriter out = new PrintWriter(new OutputStreamWriter(System.out));
String str = in.readLine();
String match = in.readLine();
int idx = KMP(str, match);
if(idx==-1){
out.println("str不存在match子串");
}else{
out.printf("str第一次出现match子串下标:%d \n", idx);
}
out.flush();
out.close();
in.close();
}
}
总结 🎀
✨ KMP 的核心是 next
数组,利用它可以避免无效匹配,提高匹配效率。
✨ 暴力解法的时间复杂度是 O(n × m),KMP 通过 next
数组将其优化到 O(n)!
✨ 掌握 KMP 的关键是理解 next
数组的含义和作用! 🌸
结语✨
时隔一个月再次更新。
怎么说呢?
感觉自己爆更,刻意写博客写得很烂很水。偶尔写一次挺不错的。😊
嗯, 随缘更新吧。写一篇有质量的文章太难了, 好难受。 月更博主, 加油读者朋友们。
希望这篇文章能帮助你理解 KMP 算法~ 💖
如果有什么问题,欢迎留言交流哦!🌷✨