思考一个经典问题:求模式串 T T T在文本串 S S S中第一次出现的位置。
考虑朴素做法:枚举
S
S
S的每一位考虑将其设为
T
T
T开头所处的位置,依次判断,复杂度
O
(
∣
S
∣
×
∣
T
∣
)
O(|S|\times |T|)
O(∣S∣×∣T∣)。
换句话说,若匹配,比较下位;否则,回到上次的下一个位置位置继续匹配,即
S
i
≠
T
j
S_i \neq T_j
Si=Tj时,
i
→
i
−
j
+
1
,
j
→
0
i\rightarrow i-j+1,j\rightarrow 0
i→i−j+1,j→0。
能否减少跳转,使
i
i
i线性,
j
j
j尽量少跳转,达到
O
(
n
+
m
)
O(n+m)
O(n+m)的时间复杂度?请看下文KMP算法。
考虑失配情况。虽然当前为不匹配,但之前位是完全匹配的,即
S
i
−
j
.
.
.
i
−
1
=
T
0...
j
−
1
S_{i-j...i-1}=T_{0...j-1}
Si−j...i−1=T0...j−1,这也正是我们所遗漏的条件。
令此时
j
j
j跳转到
k
k
k继续和
S
i
S_i
Si匹配,即:
图中的两次等于列成式子就是:
{
S
i
−
j
.
.
.
i
−
1
=
T
0...
j
−
1
S
i
−
k
.
.
.
i
−
1
=
T
0...
k
−
1
这个式子是关于两个字符串的,有些复杂,可否将其化成仅关于
T
T
T的等式,即消掉等式左边的
S
S
S?由第一个等式进一步推得
S
i
−
k
.
.
.
i
−
1
=
T
j
−
k
.
.
.
j
−
1
S_{i-k...i-1}=T_{j-k...j-1}
Si−k...i−1=Tj−k...j−1,将第二个式子带入,得
T
0...
k
−
1
=
T
j
−
k
.
.
.
j
−
1
T_{0...k-1}=T_{j-k...j-1}
T0...k−1=Tj−k...j−1。
也就是
T
T
T的前缀和后缀相同,因为我们要尽量少往回跳,所以
k
k
k要尽量大;而且,这个前缀和后缀不能是其本身,所以
k
<
j
k
举个栗子,求字符串"qwqwqawawa"的 f a i l fail fail。
字符 | fail |
---|---|
q | -1 |
w | -1 |
q | 0 |
w | 1 |
q | 2 |
a | -1 |
w | -1 |
a | -1 |
w | -1 |
a | -1 |
如何求出 f a i l fail fail数组?不着急,先看如何利用求好的 f a i l fail fail匹配。
int KMP(char *s, char *t) {
int n = strlen(s), m = strlen(t), j = -1;
for (int i = 0; i < n; ++i) {
while (~j && t[j + 1] != s[i]) { // 失配:跳转
j = fail[j];
}
if (t[j + 1] == s[i]) { // 匹配:下一位
++j;
if (j + 1 == m) { // 第一次匹配完所有字符:返回
return i - m + 1;
}
}
}
return -1;
}
我们对着这个代码想,就可以得出,求解 f a i l fail fail的过程即是 T T T自身匹配的过程,用前面的 f a i l fail fail算出后面的 f a i l fail fail,代码类似。
void get_fail(char *s) {
int n = strlen(s);
fail[0] = -1;
for (int i = 1; i < n; ++i) {
int j = fail[i - 1];
while (~j && s[j + 1] != s[i]) {
j = fail[j];
}
if (s[j + 1] == s[i]) {
++j;
}
fail[i] = j;
}
}
关于kmp的习题,不仅是关于其主要求解问题——字符串匹配,有部分利用了其 f a i l fail fail数组。
这里就充分利用的 f a i l fail fail数组的特性, f a i l i fail_i faili为前 i i i个字符的最短公共前后缀的长度,既是最短,又是公共,不就是要求的循环节吗?这里我们需要判断 n n n是否可以被 f a i l n fail_n failn整除:若整除,答案为 n f a i l n \frac{n}{fail_n}% failnn;否则,答案为 1 1 1( s s s本身)。
这道题需要对 f a i l fail fail做二次处理,即长度减去重叠部分长度(长度-最小周期=最大周期),最后再将长度减去处理后 f a i l fail fail求和即可。
我们可以从 s s s中得到 0 0 0和 1 1 1的数量,则考虑填充 t t t。注意因为前缀后缀可能相同,所以不需要填充整个 t t t,而公共前后缀正好匹配 f a i l fail fail的性质,则每次填充完跳转到 f a i l fail fail处即可。
这里应用没有kmp算法广泛,本文中适当略写。
求 S S S所有后缀与 T T T的最长公共前缀(lcp),要求时间复杂度 O ( ∣ S ∣ + ∣ T ∣ ) O(|S|+|T|) O(∣S∣+∣T∣)。
思想和kmp有些类似,将其 f a i l fail fail数组改为 n e x t next next,含义由字符串前缀和此位置结束子串相等转变为和此位置开始子串相等,还是基于 T T T构造。
还是递推。设
n
e
x
t
0...
x
−
1
next_{0...x-1}
next0...x−1均已求得,求
n
e
x
t
x
next_x
nextx。
定义
p
p
p为之前所有位置能匹配到的最远位置,即
m
a
x
{
i
+
n
e
x
t
i
−
1
}
max\{i + next_i - 1\}
max{i+nexti−1},令
k
k
k为此时的
i
i
i,则根据
n
e
x
t
next
next的定义,红色部分相等:
从两部分各取长度
p
−
x
+
1
p-x+1
p−x+1后缀,则绿色部分相等:
令
y
=
n
e
x
t
x
−
k
y=next_{x-k}
y=nextx−k,则与红色部分相同,蓝色部分相等:
又因红色部分相等,三处蓝色部分相等:
所以,如果一个蓝色的长度比一个绿色的长度小,也就是说后面位置的lcp不能比前面的还大或者等于,形式化的,
y
<
p
−
x
+
1
→
x
+
y
≤
p
y
void get_next(char *s) {
int n = strlen(s), p = 0, k = 1;
next[0] = n;
while (p < n - 1 && s[p] == s[p + 1]) {
++p;
}
next[1] = p; // next[0 & 1]: 手动计算
for (int i = 2; i < n; ++i) {
p = k + next[k] - 1;
int y = next[i - k];
if (i + y <= p) { // 分类讨论
next[i] = y;
} else {
int j = max(p - i + 1, 0); // 注意特判
while (i + j < n && s[i + j] == s[j]) {
++j;
}
next[i] = j, k = i; // k直接更新,最新匹配一定比之前的p大
}
}
}
刚才说的这么多,有童鞋会问,整个
n
e
x
t
next
next都是基于
T
T
T后缀与
T
T
T匹配的,如何用来进行
S
S
S后缀与
T
T
T的匹配呢?
我们定义
e
x
i
ex_i
exi为
S
S
S的后缀
i
i
i与
T
T
T的lcp,还是设
e
x
0...
x
−
1
ex_{0...x-1}
ex0...x−1均已求出,求
e
x
x
ex_x
exx。
同理,已知
S
k
.
.
.
p
=
T
1...
e
x
k
S_{k...p}=T_{1...ex_k}
Sk...p=T1...exk,
p
=
m
a
x
{
i
+
n
e
x
t
i
−
1
}
p=max\{i + next_i - 1\}
p=max{i+nexti−1}。
我们可以先利用
n
e
x
t
k
next_k
nextk求出红色部分,再利用
n
e
x
t
x
−
k
next_{x-k}
nextx−k求出蓝色部分,其余过程一样。
void get_ex(char *s, char *t) {
int n = strlen(t), m = strlen(s), p = 0, k = 0;
while (p < min(n, m) && s[p] == t[p]) {
p = 0;
}
ex[0] = p;
for (int i = 1; i < m; ++i) {
p = k + ex[k] - 1;
int y = next[i - k];
if (i + y <= p) {
ex[i] = y;
} else {
int j = max(p - i + 1, 0);
while (i + j < m && j < n && s[i + j] == t[j]) {
++j;
}
ex[i] = j, k = i;
}
}
}
今天复习内容就到这里,感谢收看~