一、概述与基本概念
字符串匹配问题,即给定一个匹配串 TTT 和主串 SSS ,在一个 SSS 中寻找一个子串 S′∈SS' \in SS′∈S ,使得 S′=TS'=TS′=T 的问题。
KMP 算法是基于最长公共前后缀计算原理实现的字符串匹配算法。一般我们在主串和匹配串分别设置一个指针,通过两个指针对应值的判断调整指针位置,从而完成字符串的匹配。
举例来说,假设我们有两个字符串 SSS 和 TTT ,其中 S=S=S= abcabcd , T=T=T= cab ,那么如果使用暴力(枚举)做法,可以从前往后枚举 SSS 的每一位 Si(1≤i≤∣S∣)S_i(1 \le i \le |S|)Si(1≤i≤∣S∣) ,对于每一个 SSS 的子串 S′=[Si,Si+∣T∣]S'=[S_i,S_{i+|T|}]S′=[Si,Si+∣T∣] ,将它与 TTT 进行匹配。显然,这个算法的时间复杂度为 O(nm)O(nm)O(nm) ,而 KMP 算法将可以将这个复杂度优化为 O(n+m)O(n+m)O(n+m) ,可以应对 n,m≤106n,m \le 10^6n,m≤106 数据规模的问题。
二、 KMP 算法的子问题:最长公共前后缀
前缀,就是一个字符串以这个字符串的第一个字符为开头,任意字符为结尾的子串。后缀,就是一个字符串以这个字符串的任意字符为开头,最后一个字符为结尾的子串。最长公共前后缀,即在一个字符串中长度最大的相同前缀和后缀,如 agcabcagc 的最长公共前后缀是 agc 。
在接下来的讲述中,我将使用 next[i]next[i]next[i] 表示以第 iii 个位置结尾的子串(主串 SSS 的子串 S′=[S1,Si]S'=[S_1,S_i]S′=[S1,Si])的最长公共前后缀的长度。
为了更好地理解 next 数组的含义,看这样一个字符串 S=S=S= abacabab 。
显见,最长公共前后缀不能是字符本身,所以 next1=0next_1=0next1=0 。
因为在 [S1,S2][S_1,S_2][S1,S2] 中没有最长公共前后缀(a≠ba \neq ba=b) ,所以 next2=0next_2=0next2=0 。
对于 next3next_3next3 ,因为 S1=S_1=S1= a =S3=S_3=S3 ,所以 next3=1next_3=1next3=1 。
类似地,我们可以得出 next4=0,next5=1,next6=2,next7=3,next8=2next_4=0,next_5=1,next_6=2,next_7=3,next_8=2next4=0,next5=1,next6=2,next7=3,next8=2 。
1. 观察 nextnextnext 数组的性质
A. nextinext_inexti 是位置 iii 的对应前缀最后一个字符的位置。
这点还是比较显然的,因为 nextinext_inexti 表示最长公共前后缀的长度,由于开头的位置是 111 ,所以主串 SSS 的子串 S′=[S1,Si]S'=[S_1,S_i]S′=[S1,Si] 中的最长公共前后缀的前缀部分结尾应该是 1+nexti−1=nexti1+next_i-1=next_i1+nexti−1=nexti 。
B. Si=SnextiS_i=S_{next_i}Si=Snexti 。
这点也应该比较容易看出,从 nextnextnext 数组的定义就直接可以得到,但是这个性质非常重要,对于 nextnextnext 数组的构造和 KMP 算法的实现意义重大。它规定了 nextnextnext 数组的可跳转性,且保证了跳转后不会出现错误答案。
2. nextnextnext 数组的构造
记 lenlenlen 为当前最长公共前后缀中前缀部分的最后一个字符。由上面的性质 A,得到 len=nextilen=next_ilen=nexti 。
分三种情况讨论:
- 若 Slen+1=SiS_{len+1}=S_iSlen+1=Si 。
此时,最长公共子序列的下一项仍然可以完成匹配,令 nexti=len+1next_i=len+1nexti=len+1 , 变量 len←len+1len \leftarrow len+1len←len+1 。 - 若 Slen+1≠SiS_{len+1} \neq S_iSlen+1=Si 且 len≠0len \neq 0len=0。
由上面的性质 B ,知 Snextlen=SlenS_{next_{len}}=S_{len}Snextlen=Slen ,可以保证最大程度上的相等,而不用考虑前面的字符是否匹配。所以跳转到 nextlennext_{len}nextlen 不会影响最终答案。因为 len≠0len \neq 0len=0 ,所以可以继续进行跳转。令 len←nextlen+1len \leftarrow next_{len}+1len←nextlen+1 ,查询该字符是否匹配。 - 若 Slen+1≠SiS_{len+1} \neq S_iSlen+1=Si 且 len=0len = 0len=0 。
此时, SiS_iSi 无法与任何一个前缀相匹配,因此令 nexti=0next_i=0nexti=0 。
举一个例子来理解 nextnextnext 数组的构造,其中红色代表当前查询的点 iii ,黄色代表 lenlenlen ,蓝色代表将要跳转的点 nextlennext_{len}nextlen。

3. 代码实现与分步讲解
求最长公共前后缀,代码如下。
// 头文件、变量定义省略
void init() // 求解 next
{
int len = 0, pos = 1;
while (pos <= n) //此处 n 是 S 的长度
{
if (s[pos] != s[len + 1])
{
if (len == 0) nxt[pos++] = 0; // 此处 next 改为 nxt 是为了避免与库函数的重复
// 否则,Linux 下容易 CE,要注意
else len = nxt[len];
}
else
{
nxt[pos++] = ++len;
}
}
}
代码分步讲解
while (pos <= n)
对每一个 pos 进行操作。
if (s[pos] != s[len + 1])
即上面讨论的情况 2 和 3 。
if (len == 0) nxt[pos++] = 0
情况 3 ,此时不能进行操作,nxt[pos++] = 0 表示该位置 pos 没有公共前后缀,指针向下一位移动。
else len = nxt[len]
情况 2 , Spos≠Slen+1S_{pos} \neq S_{len+1}Spos=Slen+1 但是 len≠0len \neq 0len=0 , 此时可以令指针后移到一个与 SlenS_{len}Slen 相等的地方,即 SnextlenS_{next_{len}}Snextlen 。因此, len = nxt[len] 。
else nxt[pos++] = ++len
情况 1 ,Spos=Slen+1S_{pos}=S_{len+1}Spos=Slen+1 ,匹配顺利,增加最长公共前后缀的长度,指针向下一位移动。
三、 KMP 算法的基本步骤
1. 主串指针 iii 与匹配串指针 jjj 的移动规律
如第一部分所述,我们定义一个主串 SSS 的指针 iii ,匹配串 TTT 的指针 jjj 。关于 i,ji,ji,j 的移动规则,下面将展开叙述。
首先,判断条件 Si=TjS_i = T_jSi=Tj 是否成立。
- 若 Si=TjS_i = T_jSi=Tj,则令 i,ji,ji,j 均后移一位,一定是最优解。(显见,证明略)
- 若 Si≠TjS_i \neq T_jSi=Tj 且 j>1j > 1j>1,考虑指针 jjj 的后移。由 j≠0j \neq 0j=0 ,得 jjj 还可以继续移动,就让 jjj 移动到下一个让 SSS 和 TTT 已匹配的部分仍然匹配的位置。因为主串已经完成了部分匹配,所以我们应该保留 [Si−j,Si][S_{i-j},S_i][Si−j,Si] 部分保持已匹配状态。根据 nextnextnext 数组的性质 A ,我们可以得知, Tnextj−1=Tj−1T_{next_{j-1}}=T_{j-1}Tnextj−1=Tj−1 ,因此我们可以让 jjj 指针移动到 nextj−1+1next_{j-1}+1nextj−1+1 进行尝试。
- 否则,如果 Si≠TjS_i \neq T_jSi=Tj 且 j=1j = 1j=1 ,则说明不能继续移动,当前位置一定不能匹配到合适的主串字符,令主串的指针 iii 右移一位。
直到 j>∣T∣j > |T|j>∣T∣ ,说明匹配已经完成,返回匹配位置或者继续匹配(j=nextjj=next_jj=nextj 或 j=1j=1j=1)。
使用 S=S=S= abacabab , T=T=T= abab ,举例如下。
S: abacabab
T: abab
i=1,j=1i=1,j=1i=1,j=1 时,可以满足条件。
i=4,j=4i=4,j=4i=4,j=4 时,不满足条件,令 j=nextj−1+1=2j=next_{j-1}+1=2j=nextj−1+1=2 。
S: abacabab
T: abab
i=4,j=2i=4,j=2i=4,j=2 时,不满足条件,主串指针加 111 ,令 i=5i=5i=5 。
S: abacabab
T: abab
i=5,j=2i=5,j=2i=5,j=2 时,不满足条件,主串指针加 1 ,令 i=6i=6i=6 。
S: abacabab
T: abab
i=6,j=2i=6,j=2i=6,j=2 时,满足条件,TTT 串指针 jjj 可以移动到 j=5j=5j=5 ,完成匹配。
通过上述讲解,我们应该得知, 我们应该先对匹配串 TTT 求出 nextnextnext 数组,再利用 nextnextnext 数组完成 SSS 与 TTT 之间的 KMP 算法匹配过程。
2. 代码实现与分步讲解
使用 KMP 算法解决字符串匹配问题,代码如下。
void init()
{
int len = 0, pos = 1;
while (pos <= n)
{
if (t[pos] != t[len + 1])
{
if (len == 0) nxt[pos++] = 0;
else len = nxt[len];
}
else
{
nxt[pos++] = ++len;
}
}
}
int KMP() // 字符串匹配函数
{
int spos = 1, tpos = 1;
while (spos <= slen)
{
if (s[spos] == t[tpos])
{
spos++;
tpos++;
}
else
{
if (tpos > 1) tpos = nxt[tpos - 1] + 1;
else spos++;
}
if (tpos > tlen)
{
return spos - tlen;
}
}
return -1;
}
代码分步讲解:
while (spos <= slen)
对于每一个 1≤i≤∣S∣1 \le i \le |S|1≤i≤∣S∣ ,进行字符串匹配操作。
if (s[spos] == t[tpos]) spos++, tpos++
上述讨论的第一种情况,Si=TjS_i=T_jSi=Tj ,即该字符成功完成匹配,双指针 i,ji,ji,j 均后移一位。
else { if (tpos > 1) tpos = nxt[tpos - 1] + 1
上述讨论的第二种情况, Si≠TjS_i \neq T_jSi=Tj 且 j>1j > 1j>1 ,指针 jjj 移动到下一个匹配点, j=nextj−1+1j=next_{j-1}+1j=nextj−1+1 。
else spos++; }
上述讨论的第三种情况, Si=TjS_i = T_jSi=Tj 且 j=1j = 1j=1 ,指针 iii 右移一位继续匹配 TTT, i←i+1i \leftarrow i+1i←i+1 。
四、 KMP 算法的常用变形
1. 求一个字符串 SSS 中包含多少个不重叠的匹配串 TTT
首先,我们先展示一个普通的 KMP 代码:
int KMP() // 字符串匹配函数
{
int spos = 1, tpos = 1; // 主串指针 spos ,匹配串指针 tpos
while (spos <= slen)
{
if (s[spos] == t[tpos])
{
spos++;
tpos++;
}
else
{
if (tpos > 1) tpos = nxt[tpos - 1] + 1;
else spos++;
}
if (tpos > tlen)
{
spos--;
return spos - tlen;
}
}
return -1;
}
显然,这里我们不能这么简单地进行一次 KMP 算法,应该持续直到 i≥∣S∣i \ge |S|i≥∣S∣ 。为了实现这个目的,我们应该忽略已经匹配的 SSS ,让 TTT 与 SSS 的剩余部分 S′S'S′ 继续完成匹配。
具体来说,假设我们当前 S,TS,TS,T 串的指针分别枚举到了 i,ji,ji,j ,则目标截取的 S′=[Si,S∣S∣]S'=[S_i,S_{|S|}]S′=[Si,S∣S∣]
。这样,我们可以令 i←i+1i \leftarrow i+1i←i+1 ,同时, j=1j=1j=1 。这相当于让 TTT 串和 S′S'S′ 串重新进行匹配直到 i=ni=ni=n 。
举个例子,更好理解这个过程:
我们令 S=S=S= abacababccbbabab , T=T=T= abab 。
当我们在 i=8,j=4i=8,j=4i=8,j=4 处匹配完成后,按照上面的结论,我们可以令 i=9,j=1i=9,j=1i=9,j=1 ,即忽略前面已经匹配的部分,完成 S9,S16S_9,S_{16}S9,S16 与 TTT 串的匹配。
最后的答案是 SSS 中包含两个 TTT 串。
|<--S'->|
S: abacababccbbabab abacababccbbabab
| => |
T: abab abab
代码实现如下:
#include <bits/stdc++.h>
using namespace std;
const int N = 1007;
char a[N], b[N];
int nxt[N];
void getNext(char t[])
{
int tlen = strlen(t + 1);
int len = 0;
nxt[1] = len;
int pos = 2;
while (pos <= tlen)
{
if (t[len + 1] != t[pos])
{
if (len == 0) nxt[pos++] = 0;
else len = nxt[len];
}
else
{
nxt[pos++] = ++len;
}
}
}
int KMP(char s[], char t[])
{
int slen = strlen(s + 1);
int tlen = strlen(t + 1);
int spos = 1, tpos = 1, ans = 0;
while (spos <= slen)
{
if (s[spos] == t[tpos])
{
spos++;
tpos++;
}
else if (tpos > 1)
{
tpos = nxt[tpos - 1] + 1;
}
else
{
spos++;
}
if (tpos > tlen)
{
ans++;
tpos = 1;
}
}
return ans;
}
int main()
{
while (cin >> a + 1, !(a[1] == '#' && strlen(a + 1) == 1))
{
cin >> b + 1;
getNext(b);
cout << KMP(a, b) << endl;
}
return 0;
}
2. 求一个字符串 SSS 包含几个可重叠的子串 TTT
方法与上面的问题大致相同,但区别在于该问题的 TTT 可以重叠。
使用 nextnextnext 数组的性质,一次匹配完成后,我们可以令 j=nextj−1+1j=next_{j-1}+1j=nextj−1+1 。因为此时 j=∣T∣+1j=|T|+1j=∣T∣+1 ,所以 j−1=∣T∣j-1=|T|j−1=∣T∣ , nextj−1+1next_{j-1}+1nextj−1+1 刚好对应下一次应该匹配的位置。
代码的改动不大,展示如下:
#include <bits/stdc++.h>
using namespace std;
const int N = 1007;
char a[N], b[N];
int nxt[N];
void getNext(char t[])
{
int tlen = strlen(t + 1);
int len = 0;
nxt[1] = len;
int pos = 2;
while (pos <= tlen)
{
if (t[len + 1] != t[pos])
{
if (len == 0) nxt[pos++] = 0;
else len = nxt[len];
}
else
{
nxt[pos++] = ++len;
}
}
}
int KMP(char s[], char t[])
{
int slen = strlen(s + 1);
int tlen = strlen(t + 1);
int spos = 1, tpos = 1, ans = 0;
while (spos <= slen)
{
if (s[spos] == t[tpos])
{
spos++;
tpos++;
}
else if (tpos > 1)
{
tpos = nxt[tpos - 1] + 1;
}
else
{
spos++;
}
if (tpos > tlen)
{
ans++;
tpos = next[tpos - 1] + 1;
}
}
return ans;
}
int main()
{
while (cin >> a + 1, !(a[1] == '#' && strlen(a + 1) == 1))
{
cin >> b + 1;
getNext(b);
cout << KMP(a, b) << endl;
}
return 0;
}
温馨提示:本文的代码均未进行编译和调试,如有问题欢迎在评论区和私信指出,感谢您的反馈。
1080

被折叠的 条评论
为什么被折叠?



