定义
我们假设要在主串中寻找子串出现的所有位置
我们记主串中的开始位置为匹配位置,如在 “abc” 中匹配 “bc” ,则匹配位置为 (2)
暴力
我们把匹配过程拆解为
- 枚举匹配位置
- 验证主串从匹配位置开始是否一一匹配子串
以此,有显然的
O
(
n
m
)
O(nm)
O(nm) 算法
基于优化推出KMP
主串:ABCABCABD
子串:ABCABD
- 枚举匹配位置 (1),已经验证了ABCAB是匹配的,现在发现C和D匹配不上
- 显然我们该要枚举下一个匹配位置,但是我们其实已经在刚刚的验证过程中知道了下一个匹配位置 (2) 的前几位就是 BCAB,同理下下位 (3) 就是 CAB,这些一定会和已经看到了的 ABCAB 就匹配失败的,我们有没有办法直接让他跳到最近的下一个可以匹配得上的位置,也就是 (4) AB 呢。
- 我们考虑思考匹配位置从 (i) 变到 (i+1) 意味着什么
对于主串来说:匹配位置右移,我们将看到的主串就等于已经匹配的 ABCAB 靠左切掉一个字母 (ABCAB
→
\rightarrow
→A|BCAB)
对于子串来说,匹配位置右移,相当于已匹配长度少了 1,也就是 ABCAB 靠右切掉一个字母 (ABCAB
→
\rightarrow
→ABCA|B)
此时匹配位置向右挪 x 位能否匹配就相当于问,已看到的 ABCAB 左边去掉 x 位是否和右边去掉 x 位相同,因为我们要按顺序找到下一个最近的匹配位置,所以最小的 x (x>0) 等于在问,最长前后公共缀,然后总长度减它就是 x
前后公共缀:同时是串的前缀和后缀,如ABACABA的前后公共缀有A,ABA,(ABACABA本身也是,但是x不为0,相当于一定要删点东西,所以我们不考虑串本身)
- 我们考虑把枚举下一个匹配位置想象成主串不动,子串和它的头向后拖,然后 nxt[i] 直接记作 i 失配要跳到哪个位置比如
主串 ABCABCABD
子串 ABCABD,此时 i=6 , j=6 失配,因为nxt[6]=3
-> 跳到 i=6 j=3
主串 ABCABCABD
子串 ABCABD
- 显然存在一种情况就是没有不是本身的前后公共缀,如 ABC
主串 ABCABCD
子串 ABCD 此时 i=4 , j=4 失配
就说明匹配位置 (1) (2) (3) 都是不可能的
那么下一个匹配位置就是 nxt[4]=1
主串 ABCABCD
子串 ABCD 此时i=4 , j=1
- 如果匹配成功,到达子串最后一位了,我们假假地认为子串结尾有一个唯一存在的字符,那么再匹配一位就相当于又失配了,所以我们可以定义出 nxt[len+1],它取决于整个子串的最长公共前后缀
求 nxt 数组
求 nxt 数组的过程可以先想象成对子串本身的一次 kmp
- 先以子串 ABACABA 为例
假设要求nxt[7],已经匹配出nxt[6]的最长公共缀是ABACABA,那么加入最后一位 A 之后,发现这时候能接着匹配上,那么得到 nxt[7]=nxt[6]+1
- 如果匹配不上,如 s = “ABACABAB” ,要求nxt[8],已经匹配出nxt[7] ABACABAB,加入的 B 和前面的 C 失配,那么问题就变成了求前面的 ABA 靠右切 x-1,后面的 ABAB 靠左切 x,相等的最小 x,所以我们又变成了求 ABAB 的最长公共缀,这里 ABA 是 nxt[7] 已经匹配的,然后再加上第 8 位 B,而这又相当于把s[4]替换成B,所以我们变成了求 s[4]=B 时的 nxt[4],不难发现继续下去我们得到了一个递归结构,边界就是跳到头,那么就说明不存在
代码实践
相同的思想,不同的实现之间有比较大的差别,以下给出最简练的代码和解释
示例代码的下标从0开始记
不难发现nxt天然即和下标有关,又和公共缀长度有关,是相等还是差1取决于下标起点
/*求nxt,s2是子串,m是子串长度*/
nxt[0]=nxt[1]=0;
for(int i=1;i<=m;i++){
int j=nxt[i];
while(j && s2[j]!=s2[i]) j=nxt[j];
//s2前后相同i+1就记j+1了,不同则j=nxt[j],递归成子问题
nxt[i+1]=s2[j]==s2[i]?j+1:0;
//nxt=0表示不存在公共缀,从头开始
}
/*求匹配位置并输出*/
for(int i=0,j=0;i<n;i++){
while(j && s2[j]!=s1[i]) j=nxt[j];
if(s2[j]==s1[i]) j++;
if(j==m){
printf("匹配位置:%d\n",i-(m-1));
j=nxt[j];
}
}
时间复杂度
图片来自"云端潜行" 遵循 CC 4.0 BY-SA 版权协议
这张图展示了KMP的匹配过程,从代码上看,最令人担心的就是每个 for 内部的 while,我们极端化,while 每次都 while 到头,情况如上图,那么我们发现每次 while 中
j
j
j 回退的次数最多是这个周期中
i
i
i 增加的个数,所以
j
j
j 改变量的和不超过
i
i
i 改变量的和,所以加起来是小于
2
N
2N
2N 的
nxt 数组和 KMP 匹配的过程类似
总结
kmp 的后半部分就是在失配后,充分利用前面已经验证出来的匹配部分来减少试错
kmp 的前半部分求 nxt 也差不多
改进
nxt 的设计上还有一种改进,假设子串 abaabaaba
nxt[6] 正常是 3,但是我们可以发现s[3]=s[6],所以 6 失配,3 一定也会失配的,所以我们直接把nxt[6] 指到 nxt[3]
同理我们发现 s[9]=s[6] , 所以 nxt[9] 本来是指到 6 的,现在指到 nxt[6] ,因为 nxt[6] 又指到了 nxt[3],所以结果是 nxt[9]=nxt[3]
这种改进是基于我们在观察失配时只考虑了失配前的已匹配串,但我们其实可以把失配的那个位置的字母也考虑进去。如果我们不处理这一优化,kmp 进行时会自己一路跳下去,如果这种情况出现多次,那么我们提前处理就会加速。
经测试,对于随机数据,可以加速 5-10%