KMP模式匹配
KMP算法,又称 模式匹配算法,能够在线性时间内判定字符串A[1 ~ N]是否为字符串B[1 ~ M]的子串,并求出字符串A在字符串B中各次出现的位置
首先,一个O(NM)的朴素做法是,尝试枚举字符串B中的每个位置i,把字符串A与字符串B的后缀B[i ~ M]对齐,向后扫描逐一比较A[1]与B[i],A[2]与B[i + 1]…是否相等,我们把这种比较过程称为A与B尝试进行“匹配”
其次,这个问题使用字符串Hash也能在线性时间内求姐。通过上一节中的“兔子与兔子”这道例题,我们已经知道:可以在O(N)时间内与处理一个字符串的所有前缀Hash值,并在O(1)时间内查询该字符串任意一个子串的Hash值。所以,一个很直接的想法是:枚举字符串B中的每个位置 i ( N ≤ i ≤ M ) i(N \leq i \leq M) i(N≤i≤M),检查字符串A的Hash值与字符串B的子串A[i - N + 1 ~ i]的Hash值是否相同
KMP算法能更高效处理这个问题,并且能为我们提供一些额外的信息。详细地讲,KMP算法分为两步:
1、对字符串A进行自我“匹配”,求出一个数组next,其中next[i]表示"A中以i结尾的非前缀子串“与”A的前缀“能够匹配的最长长度,即:
n
e
x
t
[
i
]
=
m
a
x
next[i]=max
next[i]=max {
j
j
j },其中
j
<
i
j<i
j<i并且
A
[
i
−
j
+
1
A[i-j+1
A[i−j+1 ~
i
]
=
A
[
1
i]=A[1
i]=A[1 ~
j
]
j]
j]
特别地,当不存在这样的j时,令 n e x t [ i ] = 0 next[i]=0 next[i]=0
2、对字符串A与B进行匹配,求出一个数组f其中
f
[
i
]
f[i]
f[i]表示“B中以i结尾的子串”与“A的前缀”能够匹配的最长长度,即:
f
[
i
]
=
m
a
x
f[i]=max
f[i]=max {
j
j
j },其中
j
≤
i
j \leq i
j≤i并且
B
[
i
−
j
+
1
B[i-j+1
B[i−j+1 ~
i
]
=
A
[
1
i]=A[1
i]=A[1 ~
j
]
j]
j]
下面讨论next数组的计算方法。根据定义,
n
e
x
t
[
1
]
=
0
next[1]=0
next[1]=0。接下来我们按照i = 2 ~ N的顺序依次计算next[i]
假设
n
e
x
t
[
1
next[1
next[1 ~
i
−
1
]
i-1]
i−1]已经计算完毕,当计算next[i]时,根据定义,我们需要找出所有满足
j
<
i
j<i
j<i且
A
[
i
−
j
+
1
A[i-j+1
A[i−j+1 ~
i
]
=
A
[
1
i]=A[1
i]=A[1 ~
j
]
j]
j]的整数j并取最大值。为了叙述方便,我们称满足这两个条件的j为next[i]的“候选项“
使用朴素算法计算next数组:
该算法对每个i枚举了i-1个非前缀子串,并检查与对应前缀的匹配情况,时间复杂度不会低于
O
(
N
2
)
O(N^2)
O(N2)
引理:
若
j
0
j_0
j0是next[i]的一个“候选项”,即
j
0
<
i
j_0<i
j0<i并且
A
[
i
−
j
0
+
1
A[i-j_0+1
A[i−j0+1 ~
i
]
=
A
[
1
i]=A[1
i]=A[1 ~
j
0
]
j_0]
j0],则小于
j
0
j_0
j0的最大的next[i]的“候选项”是
n
e
x
t
[
j
0
]
next[j_0]
next[j0]。换言之,
n
e
x
t
[
j
0
]
+
1
next[j_0]+1
next[j0]+1 ~
j
0
−
1
j_0-1
j0−1之间的数都不是next[i]的“候选项”。
使用优化的算法计算next数组:
根据引理,当next[i - 1]计算完毕时,我们即可得知next[i - 1]的所有“候选项”从大到小依次是next[i - 1], next[next[i - 1]], … 而如果一个整数j是next[i]的“候选项“,那么j - 1显然也必须是next[i - 1]的“候选项”。因此,在计算next[i]时,只需把next[i - 1] + 1, next[next[i - 1]] + 1, …作为j的选项即可
这就是KMP模式匹配算法。在上面代码的while循环中,j的值不断减小,j = next[j]的执行次数不会超过每层for循环开始时j的值与while循环结束时j的值之差。而在每层for循环中,j的值至多增加1.因为j始终非负,所以在整个计算过程中,j减小的幅度总和不会超过j增加的幅度总和。故j的总变化次数至多为 2 ( N + M ) 2(N+M) 2(N+M),整个算法的时间复杂度为 O ( N + M ) O(N+M) O(N+M)
0、AcWing 831. KMP字符串
题意 :
- 给定一个字符串 S,以及一个模式串 P,所有字符串中只包含大小写英文字母以及阿拉伯数字。
- 模式串 P 在字符串 S 中多次作为子串出现。
- 求出模式串 P 在字符串 S 中所有出现的位置的起始下标。
- 共一行,输出所有出现位置的起始下标(下标从 0 开始计数),整数之间用空格隔开。
思路 :
- KMP算法一般推荐字符串下标从
1
开始! - ne[i]表示字符串p中以i结尾的非前缀子串与p串的前缀 能否匹配的最大长度
- 先考虑假设已知ne数组的情况下,i表示在s串中现在要匹配的位置(因此i从1开始)(且注意到i永远不会回退!这就是kmp的特征),j表示在p串中现在已经被成功匹配的位置(因此从0开始);那么,i每前进一个,都考虑当前这个i和下一个要被匹配的j是否能匹配,如果不能,那么j就一直缩为ne[j],直到j = 0或者当前这个i和下一个要被匹配的j匹配了;j跳出循环后,如果j是p串的最后一个,说明p串完全被匹配上了,注意看这个时候j要变成什么呢?j还是要变成ne[j]
- 那么我们如何来求这个ne数组呢?注意到求ne数组的过程就是p串自己和自己匹配;不过注意i是从2开始的(因为ne[1] = 0),每一个i都可以得到一个ne[i] = j来求ne数组的值
#include <iostream>
#include <cstring>
using namespace std;
const int N = 1e5 + 10, M = 1e6 + 10;
char p[N], s[M];
int ne[N];
int n, m;
int main() {
scanf("%d%s", &n, p + 1);
scanf("%d%s", &m, s + 1);
for (int i = 2, j = 0; i <= n; ++ i) {
while (j && p[i] != p[j + 1]) j = ne[j];
if (p[i] == p[j + 1]) ++ j;
ne[i] = j;
}
for (int i = 1, j = 0; i <= m; ++ i) {
while (j && s[i] != p[j + 1]) j = ne[j];
if (s[i] == p[j + 1]) ++ j;
if (j == n) {
printf("%d ", i - n);
j = ne[j];
}
}
}
1、AcWing 141. 周期
题意 :
- 一个字符串的前缀是从第一个字符开始的连续若干个字符,例如 abaab 共有 5 个前缀,分别是 a,ab,aba,abaa,abaab。
- 我们希望知道一个 N 位字符串 S 的前缀是否具有循环节。
- 换言之,对于每一个从头开始的长度为 i(i>1)的前缀,是否由重复出现的子串 A 组成,即 AAA…A (A 重复出现 K 次,K>1)。
- 如果存在,请找出最短的循环节对应的 K 值(也就是这个前缀串的所有可能重复节中,最大的 K 值)。
思路 :
- 引理:S[1 ~ i]具有长度len < i的循环元 的充要条件 是 len能整数i 并且 S[len + 1 ~ i] = S[1 ~ i - len](即i - len是next[i]的“候选项”)
- 证明:
先证必要性。设S[1 ~ i]具有长度为len的循环元,显然len能整除i,并且S[1 ~ i - len]和S[len + 1 ~ i]都是由 i / len - 1个循环元构成的。故S[1 ~ i - len] = S[len + 1 ~ i]。
再证充分性。设len能整除i并且S[len + 1 ~ i] = S[1 ~ i - len]。因为len < i,所以S[1 ~ i - len]和S[len + 1 ~ i]的长度不小于len且是len的背书。二者各取前len个字符,有S[1 ~ len] = S[len + 1 ~ 2 * len]。以此类推,不断向后取len个字符,可以发现S[1 ~ i - len]和S[len + 1 ~ i]是以len为间隔错位对齐的,故S[1 ~ len]是S的循环元。 - 根据引理,当i - next[i]能整除i时,S[1 ~ i - next[i]]就是S[1 ~ i]的最小循环元。(因为next[i]是最大的“候选项”)。它的最大循环次数就是 i / (i - next[i])。其中i - next[i]能整除i的条件是为了保证循环元每次重复的完整性。
- 进一步地,如果i - next[next[i]]能整除i,那么S[1 ~ i - next[next[i]]就是S[1 ~ i]的次小循环元。以此类推,我们还可以找出S[1 ~ i]所有可能的循环元。
- 值得指出的一个性质是,一个字符串的任意循环元的长度必然是最小循环元长度的倍数。
#include <iostream>
using namespace std;
const int N = 1e6 + 10;
int n;
char s[N];
int ne[N];
void get_next() {
for (int i = 2, j = 0; i <= n; ++ i) {
while (j && s[i] != s[j + 1]) j = ne[j];
if (s[i] == s[j + 1]) ++ j;
ne[i] = j;
}
}
int main() {
int cnt = 0;
while (scanf("%d", &n) && n) {
scanf("%s", s + 1);
printf("Test case #%d\n", ++ cnt);
get_next();
for (int i = 2; i <= n; ++ i) {
int t = i - ne[i];
if (i > t && i % t == 0) printf("%d %d\n", i, i / t);
}
puts("");
}
}
最小表示法
给定一个字符串S[1 ~ n],如果我们不断把它的最后一个字符放到开头,最终会得到n个字符串,称这n个字符串是循环同构的。这些字符串中字典序最小的一个,称为字符串S的最小表示。
例如S = “abca”,那么它的4个循环同构字符串为"abca",“aabc”,“caab”,“bcaa”,S的最小表示为"aabc"。与S循环同构的字符串可以用该字符串在S中的起始下标表示,因此我们用B[i]来表示从i开始的循环同构字符串,即 S[i ~ n] + S[1 ~ i - 1]
如何求出一个字符串的最小表示?最朴素的方法是:按照定义,依次比较这n个循环同构的字符串,找到其中字典序最小的一个。比较两个循环同构字符串B[i]与B[j]时,我们也采用直接向后扫描的方式,依次取k = 0,1,2,…,比较B[i + k]与B[j + k]是否相等,直至找到一个不相等的位置,从而确定B[i]与B[j]的大小关系。
实际上,一个字符串的最小表示可以再O(N)的线性时间内求出。我们首先把S复制一份接在它的结尾,得到的字符串记为SS。显然,B[i] = SS[i ~ i + n - 1]
对于任意的i,j,我们仔细观察B[i]与B[j]的比较过程:
如果在i + k与j + k处发现不相等,假设SS[i + k] > SS[j + k],那么我们当然可以得知B[i]不是S的最小表示。除此之外,我们还可以得知B[i + 1], B[i + 2], … ,B[i + k]也都不是S的最小表示。这是因为对于
1
≤
p
≤
k
1 \leq p \leq k
1≤p≤k,存在一个比B[i + p]更晓得循环同构串B[j + p]。
同理,如果SS[i + k] < SS[j + k],那么B[j], B[j + 1], …,B[j + k]都不是S的最小同构串,直接跳过这些位置,一定不会遗漏最小表示。
最小表示法:
1、初始化i = 1, j = 2
2、通过直接向后扫描的方法,比较B[i]与B[j]两个循环同构串
(1)如果扫描了n个字符后仍然相等,说明S有更小的循环元(例如catcat有循环元cat),并且该循环元已扫描完成,B[min(i, j)]即为最小表示,算法结束。
(2)如果在i + k与j + k处发现不相等:
(i)若SS[i + k] > SS[j + k],令i = i + k + 1,若此时i = j,再令i = i + 1
(ii)若SS[i + k] < SS[j + k],令j = j + k + 1,若此时i = j,再令j = j + 1
3、若i > n或j > n,则B[min(i, j)]为最小表示;否则重复第2步
该算法通过两个指针不断向后移动的形式,尝试比较每两个循环同构串的大小。利用上面的性质,及时排除掉不可能的选项。当其中一个移动到结尾时,就考虑过了所有可能的二元组(B[i], B[j]),从而得到了最小表示。
如果每次比较向后扫描了k的长度,则i或j二者之一会向后移动k,而i和j合计一共最多向后移动2n的长度,因此该算法的复杂度为O(N)
#include <iostream>
#include <cstring>
using namespace std;
const int N = 2e6 + 10;
int n;
char s[N];
int main() {
scanf("%s", s + 1);
n = strlen(s + 1);
for (int i = 1; i <= n; ++ i) s[i + n] = s[i];
int i = 1, j = 2, k;
while (i <= n && j <= n) {
for (k = 0; k < n && s[i + k] == s[j + k]; ++ k);
if (k == n) break; // s形如"catcat",它的循环元已扫描完成
if (s[i + k] > s[j + k]) {
i = i + k + 1;
if (i == j) ++ i;
} else {
j = j + k + 1;
if (i == j) ++ j;
}
}
int ans = min(i, j); // B[ans]是最小表示
printf("%d", ans);
}