KMP算法——AC自动机青春版
一.前言
上一节中,我们通过一种暴力的串搜索(匹配)方法,学名叫做BF算法
。而这种方法的时间复杂度能够达到O(mn)的数量级,而有些程序员就不乐意了,于是乎有三个分别以KMP字母开头的大佬发明出了这种理论上在O(mn),实际上经常在O(m+n)的一种搜索算法,我们称为KMP算法
。
二.原理介绍
1.前后缀
我们要先懂得前后缀是啥玩意儿。假设我们有字符串Kanna
那么它的前缀有:Kann
、Kan
、Ka
、 K
而它的后缀有:anna
、nna
、na
、a
很好理解对吧,就不多说了。不过一个字符串的本身是不构成其前后缀的。
2.最大公共(相同)前后缀
对于一个串,如果它有相同的前后缀,那么找出最大的那个,就叫做最大公共前后缀,也叫最大相同前后缀。
比如:
aabb
,它就没有公共前后缀。aabba
,它的最大公共前后缀是a
。aabbaa
,它的最大公共前后缀是aa
。ababa
,它的最大公共前后缀是aba
。
很简单,如果实在不知道怎么来的,就把他们所有的前缀后缀列出来,取交集,然后在交集里找最长的那个。
3.失配
假设我们字符串主串S=ababb,匹配串D=abaa,那么我们在遍历指针的时候S[0~2]和D[0~2]都是一样的aba
,而在S[3] D[3]处发生了b!=a
,那么我们就可以说,匹配串在S[3]处,发生了失配。
4.串的名字
被查找的串,我们叫主串
;用于查找的串叫模式串
。
比如在abaa
里查找baa
,那么前者是主串,后者则是模式串。
三.匹配过程
1.查找模式
::对于主串和模式串,遍历主串的指针是不会回溯的::先记住这句话。
假设我们要在主串S=ababcaabcab
查找匹配串P=abcab
,
这样我们就匹配到了我们的字符串abcab
我们注意第二次失配后,到第三次变化,我们的主串的指针并没有变化,而是我们模式串的指针发生了变化。而第二次到第三次,指针从[4]到了[1],也没有到[0],这就是该算法的魅力所在。
2.细解查找模式
为什么我们在第一次到第二次的时候,直接就从主串的指针依旧停留在S[2],而没有会退到S[1]呢?假设我们退回到S[1],那么我们匹配就是S[1]与P[0]比较,很显然:S[1] = b,P[0]=a,它俩并不相等。
我们来看一看对于串ab
,它们没有公共串,所以,ab
后面(后缀)的必不可能等于前面(前缀)的部分。那么我们在下一次匹配的时候,会用匹配串的前面的去与上一次匹配的末尾(也就是匹配串的末尾)进行匹配,而我们在上一次匹配的时候已经知道了,后面不可能等于前面的,所以就没必要进行匹配。当然,就直接跳过这一次比较。
再看第二次到第三次的时候,我们在S[6]处发生失配,但是在下一次比较的时候,并不是从P[0]开始,而是从P[1]开始,为什么?
我们看看串abca
,它好像有一个最大公共串a
,那么也就是说,上一次匹配的末尾,有我们这次匹配的开头,那么我们就也不用匹配了,直接跳过。
(好家伙,我直接好家伙。有公共串的也跳过,没公共串的也跳过,真的懒)
3.next数组介绍
好家伙,那么我怎么知道p指针回溯多少呢?那么我们需要用一个数组来存一下我们的串,这个串保存着我们该跳多少。比如我们的abcab
,我们的next数组对应的是00012
,怎么求?我们看看:
a
0个公共串ab
0个公共串abc
0个公共串abca
最大公共串a
,长度1
abcab
最大公共串ab
,长度2
这样,我们next数组不只代表着当前下标前所有字符组成的串的最大公共串长度,而且还代表了我们模式串指针回溯后,下一次遍历开始的地方。
4.求next数组的过程
那么,我们如何来求我们的next数组呢?求next数组的方法和我们匹配的方法也差不多,只不过它是自己匹配自己。
这一自然段是该小节的精髓,我觉得我自己总结得很精辟,大家细细揣摩一下:
用一个p指针指向前缀的末尾的下一个位置,也是描述当前连续的前后缀长度。
而next数组的内容,是回溯后的起点,换句话说,就是当前下标回溯后的p指针指向的下标然后s指针指向当前的字符。回溯的边界就是在P[0]处也失配,无法进行回溯了,就直接记0
还是以模式串Pabcab
来看,我们的next[0]肯定是0。
然后从s=1 p=0开始匹配。
P[1]和P[0]不相等,且在P[0]处失配,所以next[1]=0, ++s
P[2]和P[0]不相等,且在P[0]处失配,所以next[2]=0, ++s
P[3]和P[0]相等,所以++p, next[3]=1, ++s, 注意,此时p指针加了1,说明我们连续最大的公共串的长度就是1。
P[4]和P[1]相等,所以 ++p, next[4]=2, ++s
这样我们的next数组就构造完成了。
当然,我们再举一个有回溯的最大公共串例子:
ababc
首先,next[0]一定为0,s=1,p=0
P[1]!=P[0],在P[0]失配,next[1]=0, ++s
P[2]=P[0],,++p, next[2]=1,++s
P[3]=P[1],++p, next[3]=2,++s
P[4] != p[2], 所以p=next[p-1] = 0
P[4] != P[0], 在P[0]失配,next[4] = 0, ++s
所以得到ababc
的next数组是00120
如果搞不清过程,可以拿纸画一画
5.next数组的变种
很多时候,有些人对next数组进行了变种,假设有一种next数组是00120
变种数组有:
错位版-10012
,这种就是把S[0]初始化为-1,且并不参与前缀计算,真的前缀计算从S[1]开始。
也有减1版-1-101-1
。具体的next数组是为具体的代码服务的,代码不同,next数组也不同。
不过错位版的代码量是最少的。
四.代码编写流程
1.KMP匹配
那么我们查找模式的流程因该是:主串指针用s表示,匹配串指针用p表示。
- 如果匹配到,那么++s,++p,进行下一个匹配,一直循环直到s或者p遍历完整个主串S或模式串P。
- 如果没匹配到,p指针回溯,回溯到前面重新匹配。
- 当我们循环完成1、2过后,判断下p指针是否指向P的末尾,这样就可以判断模式串是否全部被匹配到了。
2.next数组的构造
- 初始化 next[0] = 0, s=1, p=0
- 开始遍历,直到遍历完S
- 如果相等,++p,next[s] = p(此时p的含义是指向前缀的末尾的下一个,也就是前缀的长度), ++s ,两个指针都向前移动
- 如果不相等,先判断p是否等于0
- 在p=0时,匹配不相等,那么说明,匹配不到,则next[s] = 0, s++
- 在p!=0时,那么p=next[p-1] (此时右边的p含义是当前前缀的末尾的下一个,所以需要减1),左边的p含义是下一次匹配前缀的起点
- 遍历完S
五.代码实现
因为原理说明白了,我们的代码实现也很简单。那就用上一篇文章写的字符串来实现。
int StringKMP(sstring *bstr, sstring *mstr)
{
int *next=(int *)malloc(sizeof(int)*mstr->len);
int s = 1, p = 0;
next[0] = 0;
// 构造next数组
while(s < mstr->len)
{
if (*(mstr->ch+s) == *(mstr->ch+p)) // 这里是匹配到了
{
++p; // 先给p+1,使得p代表当前公共缀的储长度,或者是公共缀下标的下一个位置
next[s] = p; // 当前位置的公共缀长度
++s; // 下一轮匹配
}
else if(p == 0) // 从这里开始,肯定就是匹配失败的情况了
{
next[s] = 0; // 先判断是不是到达了回溯边缘,是就无公共缀
++s; //进行迭代
}
else
{
p = next[p-1]; //也不在边缘,可以回溯,但注意,我们的p永远是指向公共缀下标的下一个位置故要减一
}
}
// next 数组构造完毕,开始KMP查找
s = 0;
p = 0;
while(s < bstr->len && p < mstr->len)
{
if (*(bstr->ch+s) == *(mstr->ch+p)) // 匹配,进行下一轮匹配
{
++s;
++p;
}
else if (p == 0) // 匹配不上,且在回溯边缘,++s进行下一轮匹配
{
++s;
}
else
{
p = next[p-1]; // 匹配不上,回溯到上一个前缀
}
}
if (p == mstr->len) // 是否把模式串匹配完了
{
return s-p; // 作差即为下标
}
else
{
return -1; // 没找到
}
}
至于如何使用,加到上一节博客的内容后就行了,算了还是放一个完整代码:
#include <stdio.h>
#include <stdlib.h>
#define OK 1
#define ERROR 0
typedef struct sstring{
char *ch;
int len;
}sstring;
sstring* StringInit(char* str);
int StringShow(sstring *str);
int StringConcat(sstring* str1, sstring* str2);
int StringCompare(sstring *str1, sstring *str2);
sstring *StringGet(sstring *str, int index, int len);
int StringFind(sstring *bstr, sstring *mstr);
int StringKMP(sstring *bstr, sstring *mstr);
int main()
{
// 主函数随便改
sstring *test1 = StringInit("ababdabcababc");
sstring *test2 = StringInit("ababc");
//StringConcat(test1, test2);
//printf("%d", StringCompare(test1, test2));
//StringShow(StringGet(test2,0,3));
//printf("%d", StringFind(test1,test2));
printf("\nanseris %d\n", StringKMP(test1, test2));
return 0;
}
sstring* StringInit(char* ss)
{
int lenth = 0;
//首先创建要返回的
sstring *str = (sstring*)malloc(sizeof(sstring));
//动态生成失败,直接退出
if (str == NULL) exit(1);
//如果传入的是空字符串,我们就返回一个空的字符串
if (ss == NULL)
{
str->ch = NULL;
str->len = 0;
return str;
}
// 通过依次遍历,获得传入字符串中,非/0部分长度。
while(*(ss + lenth) != '\0')
{
++lenth;
}
// 修改我们字符串的长度和动态分配它的储存空间
str->len = lenth;
str->ch = (char*)malloc(sizeof(char)*lenth);
--lenth;
// 通过遍历,将C语言字符串的内容,复制到我们的新字符串中
while(lenth >= 0)
{
*(str->ch+lenth) = *(ss+lenth);
--lenth;
}
return str;
}
int StringShow(sstring *str)
{
int ptr = 0;
printf("the string len is %d context is: ", str->len);
while(ptr < str->len)
{
printf("%c", *(str->ch + ptr));
++ptr;
}
printf("\n");
return OK;
}
int StringConcat(sstring* str1, sstring* str2)
{
sstring* stringNew = NULL;
int ptr = 0;
// 如果两个串的长度都是0,那就直接返回即可
if (str1->len + str2->len == 0)
{
return OK;
}
// 否则就先生成我们的新串,修改长度与内容
stringNew = (sstring*)malloc(sizeof(sstring));
stringNew->ch = (char*)malloc(sizeof(char)*(str1->len+str2->len));
stringNew->len = str1->len+str2->len;
// 通过循环,将str1的值写入新串
for(;ptr < str1->len; ++ptr)
{
*(stringNew->ch+ptr) = *(str1->ch+ptr);
}
// 在str1写入新串的基础上,向新串写入str2
for(ptr = 0;ptr < str2->len; ++ptr)
{
*(stringNew->ch+ptr+str1->len) = *(str2->ch+ptr);
}
// 然后这里优点坑,因为传递过来的指针是形参,并不是引用
// 所以 我们只能把新串的值赋值给原来的串
// 此时,传入函数字符串的地址没变,但是len变了, ch的地址变了
*str1 = *stringNew;
return OK;
}
int StringCompare(sstring *str1, sstring *str2)
{
int i = 0;
// 长度都不一样,所以通过长度,反应关系
if (str1->len > str2->len)
{
return 1;
}
else if (str1->len < str2->len)
{
return -1;
}
else
{
// 长度一样了,只有依次对比了
for (; i < str1->len; ++i)
{
// 只要有一个字符不一样,那就根据ascii的关系去返回大小关系
if (*(str1->ch+i) < *(str2->ch+i))
{
return -1;
}
else if (*(str1->ch+i) > *(str2->ch+i))
{
return 1;
}
}
// 循环完了也没有找到不同,所以它俩是一样的
return 0;
}
}
sstring *StringGet(sstring *str, int index, int len)
{
sstring *rstr = NULL;
int i = 0;
// 如果目标串的长度小于我们要求的长度,所以直接返回空的
if (str->len < index+len)
{
return NULL;
}
else
{
// 动态生成我们的返回串
rstr = (sstring *)malloc(sizeof(sstring));
rstr->ch = (char *)malloc(sizeof(char)*str->len);
rstr->len = len;
// 然后把目标串里的值复制到我们的返回串里
for (i = 0; i < len; ++i)
{
*(rstr->ch+i) = *(str->ch+index+i);
}
return rstr;
}
}
int StringFind(sstring *bstr, sstring *mstr)
{
int fptr = 0, lptr = 0;
int mark = 0;
// 如果我们要查找的串的长度大于了目标串,那肯定找不到的,直接返回-1
if (bstr->len < mstr->len)
{
return -1;
}
// lptr是指向 我们目标串的开始指针
// 它只需要从0遍历到(目标串长度-要查找的串的长度)就行了
for (;lptr <= (bstr->len-mstr->len); ++lptr)
{
// mark是标记位,如果有不同,那就是1 没有不同就还是0
mark = 0;
// 这个是查找指针,我们要对比的内容因该是lptr+fptr
// 它的范围是 0到查找串的长度-1
for (fptr = 0; fptr < mstr->len; ++fptr)
{
// 对比的内容是 lptr+fptr
if (*(bstr->ch+lptr+fptr) != *(mstr->ch+fptr))
{
// 有不同,更新标识,并跳出这一轮 fptr的遍历
mark = 1;
break;
}
}
// fptr遍历完了,都还没有不同的,说明找到了
if (mark == 0)
{
// 那么就因该返回我们lptr的起始位置
return lptr;
}
}
// 查遍了整个串都没找到,那就只能返回 -1了
return -1;
}
int StringKMP(sstring *bstr, sstring *mstr)
{
int *next=(int *)malloc(sizeof(int)*mstr->len);
int s = 1, p = 0;
next[0] = 0;
// 构造next数组
while(s < mstr->len)
{
if (*(mstr->ch+s) == *(mstr->ch+p)) // 这里是匹配到了
{
++p; // 先给p+1,使得p代表当前公共缀的储长度,或者是公共缀下标的下一个位置
next[s] = p; // 当前位置的公共缀长度
++s; // 下一轮匹配
}
else if(p == 0) // 从这里开始,肯定就是匹配失败的情况了
{
next[s] = 0; // 先判断是不是到达了回溯边缘,是就无公共缀
++s; //进行迭代
}
else
{
p = next[p-1]; //也不在边缘,可以回溯,但注意,我们的p永远是指向公共缀下标的下一个位置故要减一
}
}
// next 数组构造完毕,开始KMP查找
s = 0;
p = 0;
while(s < bstr->len && p < mstr->len)
{
if (*(bstr->ch+s) == *(mstr->ch+p)) // 匹配,进行下一轮匹配
{
++s;
++p;
}
else if (p == 0) // 匹配不上,且在回溯边缘,++s进行下一轮匹配
{
++s;
}
else
{
p = next[p-1]; // 匹配不上,回溯到上一个前缀
}
}
if (p == mstr->len) // 是否把模式串匹配完了
{
return s-p; // 作差即为下标
}
else
{
return -1; // 没找到
}
}