请注意:这是我很久之前在 luogu 发的一篇题解(虽然没过),当时水平欠佳,对于 KMP 的理解也没现在深刻,看看就行。。。luogu题解网址
好处
对于我们通常的判断一个字符串是否是另一字符串的子串,通常会用直接判断的方式:即依次判断每个字符是否相同,当不同时就将其后移继续判断。若用 \(m,n\) 表示两个字符串的长度,这个的时间复杂度约为 \(O(mn)\) 。
但这样每个字符所做的贡献 (如果你不知道贡献是什么,请往下看) 就会被忽略,如果我们将每个字符的贡献使用,就可以实现 KMP 算法,时间复杂度为 \(O(n + m)\) 。
定义 (算法原理)
先看看 《算法竞赛进阶指南》 的定义:
KMP 算法,又称模式匹配算法,能够在线性时间内判断字符串 \(A[1 \sim N]\) 是否为字符串 \(B[1 \sim M]\) 的子串,并求出字符串 \(A\) 在字符串 \(B\) 中各次出现的位置。
所以 KMP 算法并不只是判断字符串是否是另一字符串子串的算法,也可应用在判断字符串子串个数、位置,与字符串重合等方面。
原理
KMP 的原理较难懂,这里先举出例子:
假设字符串 abab
与 abcd
中,按照朴素判断方法:
- 我们会先让
abab
中的a
与abcd
判断。 - 发现相同后,我们会让
abab
中的b
与abcd
判断。 - 发现也相同,我们会让
abad
中的a
再与abcd
中的c
判断,发现不相同。 - 于是程序就会让
abab
中的a
重新与abcd
中的b
判断。 - ......
发现了什么?是不是判断 b
这一步似乎没有必要,因为 ab
已经确定与 ab
相同了,那么 a
一定和 b
不同,这就是这个字符的贡献。
那么 KMP 的想法就是记录这些字符的贡献,从而跳过这些无用的判断,从而加快算法,减少复杂度。
对于如何记录贡献呢,那么我们就得知道字符串的重复情况,让跳过时直接跳到上一个可以符合这一情况的时候,具体实现重复呢,例如字符串 abababaac
。
我们需要一个 next 数组的帮助,他的功能是记录上一个可以的字符。(这里不在介绍求 next 数组的朴素算法,和朴素求子串代码类似)
首先,对于最开始的 abababaac
我们
next[1] = 0;
具体原因是因为 字符 a
之前没有字符。
之后,我们依次判断 abababaac
与 abababaac
的重合情况,具体如下:
- 从第二位判断重合(因为如果是第一位就一定会重合,没有必要)
- 发现
a
与b
不相同,尝试跳到之前可能重合的地方。 - 发现没有之前的地方,所以向下判断,并记录
next[2] = 0
- 发现
abababaac
与abababaac
(从第三位开始)重合aba
,记录
next[3] = 1,next[4] = 2,next[5] = 3;
可以发现, next 每次记录的都是在前方的最近的相同字符点。
5. 发现再次不重合,通过 next 数组记录,跳到最前面的 a
再次判断。
......
通过这些,我们求出来 next 数组并且又通过 next 数组巧妙的节省了时间复杂度。
需要注意的是,在我们看来是移动,实际上在程序中只不过是换了个字符
图片解释
对应代码
ne[1] = 0;//最开头没东西
for(int i = 2,j = 0;i <= n;i ++)//j意味着现在跳到的字符的位置
{
while(j > 0 && b[i] != b[j + 1]) j = ne[j];//跳位置,知道跳到最头或找到和这个点相同的
if(b[i] == b[j + 1]) j ++;//若相同,就向下判断
ne[i] = j;//标记此时位置的的next
}
需要注意由于 next 在 C++ 中属于关键字,所以我们只能使用 ne 或 nxt 这种类似的代替。
判断完自身后,我们就要判断此字符串和另一字符串,其过程与判断自身相似,不过有几点要注意:
- 在判断另一字符串时我们没有字符串自身的长度,所以在找前的时候一定要算上自身的长度。
- 在判断时我们会用另一数组(设这个数组为 p )这个数组记录的是在另一字符串中,我们这一字符串的最大长度。
相关模拟与上类似,只不过判断的字符串切换成另一字符串,但使用的还是 next 数组。
代码
for(int i = 1,j = 0;i <= m;i ++)
{
while(j > 0 && (j == n || a[i] != b[j + 1])) j = ne[j];//这里与前面相同,只是增加j == n 来判断是否到达末尾
if(a[i] == b[j + 1]) j ++;//向下判断
p[i] = j;//用p记录最大的长度
}
回归本题
可以发现,要求输出 \(s2\) 在 \(s1\) 的位置,也就是说明我们需要判断当 p[i] == n
(其中 \(n\) 为要判断的字符串的长度)这个字符串所在位置即为 \((i - n + 1)\) (解释一下, \(i\) 为现在的末尾位置, \(i - n + 1\) 为现在的末尾长度减去字符串的长度,因为字符串长度会吞掉起始点,所以加一)。
之后输出 \(s2\) 的 next 数组。注意是 next 数组,而不是 p 数组,我才不会告诉你我为什么提醒这个。
最后这个题就解决了。
std
#include<bits/stdc++.h>
using namespace std;
const int N = 1e6 +66;//注意 abs(s1),abs(s2) <= 1e6
int ne[N],p[N];//next 数组 和 p 数组
char a[N],b[N];//两个字符串
int main()
{
cin >> a + 1 >> b + 1;//输入,从下标一开始,a 表示 s1,b 表示 s2
int m = strlen(a + 1),n = strlen(b + 1);//m 为 a 的长度,n 为 b的长度
ne[1] = 0;//最开头没东西
for(int i = 2,j = 0;i <= n;i ++)//j意味着现在跳到的字符的位置 (可理解为将字符串移动的距离)
{
while(j > 0 && b[i] != b[j + 1]) j = ne[j];//跳位置,知道跳到最头或找到和这个点相同的
if(b[i] == b[j + 1]) j ++;//若相同,就向下判断
ne[i] = j;//标记此时位置的的next
}
for(int i = 1,j = 0;i <= m;i ++)
{
while(j > 0 && (j == n || a[i] != b[j + 1])) j = ne[j];//这里与前面相同,只是增加j == n 来判断是否到达末尾
if(a[i] == b[j + 1]) j ++;//向下判断
p[i] = j;//用p记录最大的长度
if(p[i] == n)//当子串长度为
cout << (i - n + 1) << endl;
}
for(int i = 1;i <= n;i ++)
cout << ne[i] << " ";
return 0;
}
尾声
KMP 算法作为字符串匹配算法,在 CCF 举办的考试中似乎并不是很常见,大家在选择算法学习的话可以先寻找主流算法,在学习这些算法,当然考到了请别说我。