upd on 2021/4/1 优化一些细节
声明:本文的字符串下标均从1开始,对于某个字符串a,a.substr(i,j)表示a从第i位开始,长度为j的字串
模板题
KMP算法的大致原理
个人认为其他博客已经讲得很好,这里简单讲,把重点放在next数组上
先推几篇博客:
- [ ] https://blog.csdn.net/qq_42833585/article/details/88818245
- [ ] https://blog.csdn.net/dark_cy/article/details/88698736
- [ ] https://blog.csdn.net/starstar1992/article/details/54913261
首先,我们把模板题中的(s_1)串称为文本串,重命名为(s),(s_2)称为模式串,重命名为(t)(本文中不区分s与t的大小写)
设(n)为(s)的长度,(m)为(t)的长度(会在代码片中出现)
看图,在第一轮匹配中,匹配到了一个不相等的位置,如果用暴力,那就是从头再匹配,但是可以看到(t)串中有一段重复的“ABC”,无需重复匹配,所以第二轮直接跳到如图所示的位置比较两个蓝色的部分
这就是KMP算法的大致思路
next数组
定义
看了KMP的大致原理,相信大家都产生了疑问:我怎么知道要让T串跳到哪个位置呢?这就要用到next数组了,这是KMP的核心,也是难点
先不用管怎么求next数组,看定义(我自己写的):
令(j=next_i),则有(j<i)且(t.substr(1,j)==t.substr(i-j+1,i)),且对于任意(k(j<k<i)),(t.substr(1,k)≠t.substr(i-k+1,k))
也就是说,next[i]
表示“T中以i结尾的非前缀字串”与“T的前缀”能匹配的最长长度,当不存在这样的j时,next[i]=0
举个例子:
若T="ABCDABCE",则对应的next={0 0 0 0 1 2 3 0}
应用
根据next数组的定义,next中存储的是长度,但是由于它是T的某个前缀字串的长度,我们也可以将next当做下标使用(一定要弄清楚,不然后面很蒙)
仍然用上面的图片真懒呐
设S的指针为i,T的指针为j,表示当前完成匹配的位置(也就是说S[i]和T[j]是相等的)
第一轮匹配中,当(j==7)时,我们发现(t)的下一位和(s)的下一位不等,但是(t)的第57位和13位是一样的,即next[7]=3
,所以我们需要将(t)的指针(j)跳到第3位,也就是j=next[j]
,这里有一些细节不是很好理解,KMP在实现时是很巧妙的,我们放到整段代码理解:
while(j != 0 && s[i] != t[j+1])
j = next[j];
if(s[i] == t[j+1])
j++;
if(j == m){//j==m标志着已经全部完成匹配
printf("%d
",i - m + 1);
j = next[j];
}
求法
这里是整个KMP最难理解的部分,所以放到最后
先贴出代码
next[1] = 0;//初始化
for(int i = 2 , j = 0 ; i <= m ; i++){
while(j != 0 && t[j+1] != t[i])
j=next[j];//全算法最confusing的语句
if(t[j+1] == t[i])
j++;
next[i] = j;
}
考虑暴力枚举:最外层循环枚举每一位(i),第二层枚举next[i]
,里层判断第二层枚举的是否合法
显然,时间复杂度是在(O(n^2)~O(n^3)),还不如(O(ncdot m))的暴力匹配
优化求法:
先提前声明:求next[i]
是要用到next[1~i-1]
的,所以我们要从前向后顺序枚举i
定义“候选项”的概念(可能跟《算法竞赛……》的不大一样):如果j满足 t.substr(1,j)==t.substr(i-1-j-1,j)&&j<i-1
则j是next[i]
的一个候选项
例子:
绿色表示相等的两个字串,则j是next[i]
的一个候选项,若标成蓝色的两个字符相等,则候选项j是合法的,next[i]
就是所有合法的(j)中的最大值+1
很显然,对于next[i]
而言,next[i-1]
是它的候选项,但是,问题是next[next[i-1]],next[next[next[i-1]]],......
都是候选项,为什么呢?还是看图:
假设next[13]=5
,根据(next)的定义,标绿色部分是相等的,再细化一下绿色部分中相等的部分:假设next[5]=2
,同理,第二行(不计最上面的下标行)的黄色部分相等,又因为绿色部分相等,我们可以得到第三行的黄色部分都是相等的,再简化为第4行,会发现:这不是和第一行一样了吗(只是长度小了)!
以此类推,可以得到next[i-1],next[next[i-1]],next[next[next[i-1]]],......
都是候选项,且他们的值是从左向右递减的,因此,按照这个顺序找到第一个合法的候选值之后,我们就可以确定next[i]
了
重新看一下代码:
next[1] = 0;
for(int i = 2 , j = 0 ; i <= m ; i++){
while(j != 0 && t[j+1] != t[i])//找到第一个合法的候选项
j=next[j];//缩小长度
if(t[j+1] == t[i])
j++;
next[i] = j;
}
发现,每一轮循环没有j=next[i-1]
的语句。原因很简单:上一轮结束时语句next[i]=j
决定了这一轮刚开始就有j==next[i-1]
,注意这里的前后的(i)不一样(都不是同一轮循环了)不要学傻了
时间复杂度
上结论:(O(n+m))
以(next)数组的求值为例:
next[1] = 0;
for(int i = 2 , j = 0 ; i <= m ; i++){
while(j != 0 && t[j+1] != t[i])
j=next[j];
if(t[j+1] == t[i])
j++;
next[i] = j;
}
最外层显然是(O(m))的,问题是里面
在while
循环中,(j)是递减的,但是又不会变成负数,所以整个过程中,(j)的减小幅度不会超过(j)增加的幅度,而(j)每次才增加1,最多增加(m)次,故(j)的总变化次数不超过(2m),整个时间复杂度近似认为是(O(m))
如果还不能理解,就想像一个平面直角坐标系,(x)轴为(i),(y)轴为(j),从原点出发,(i)每向右一个单位,(j)最多向上一个单位,(j)也可以往下掉(while
循环),但不能掉到第四象限,(j)向下掉的高度之和就是while
内语句执行的总次数,是绝对不会超过(m)的
匹配的循环与上述相近,时间为(O(n+m)),不再赘述
所以,总的时间复杂度为(O(n+m))
模板题代码
不要问模板题输出的最后一行是什么意思,我也不知道,反正输出(next)数组就对了
#include <iostream>
#include <cstdio>
#include <cstring>
#define nn 1000010
using namespace std;
int sread(char s[]) {
int siz = 1;
do
s[siz] = getchar();
while(s[siz] < 'A' || s[siz] > 'Z');
while(s[siz] >= 'A' && s[siz] <= 'Z') {
++siz;
s[siz] = getchar();
}
--siz;
return siz;
}
char s[nn];
char t[nn];
int next[nn];
int n , m;
int main() {
n = sread(s);
m = sread(t);
next[1] = 0;
for(int i = 2 , j = 0 ; i <= m ; i++){
while(j != 0 && t[j+1] != t[i])
j=next[j];
if(t[j+1] == t[i])
j++;
next[i] = j;
}
for(int i = 1 , j = 0 ; i <=n ; i++){
while(j != 0 && s[i] != t[j+1])
j = next[j];
if(s[i] == t[j+1])
j++;
if(j == m){
printf("%d
",i - m + 1);
j = next[j];
}
}
for(int i = 1 ; i <= m ; i++)
printf("%d " , next[i]);
return 0;
}