字符串匹配问题,即求出字符串 A A A 在字符串 B B B 中各次出现的位置。
普通的暴力求解,其时间复杂度就是 O ( N M ) O(NM) O(NM) ,字符串哈希可以 O ( N ) O(N) O(N) 处理,而 K M P KMP KMP 算法能更高效、更准确地处理这类问题。
KMP 模式匹配
K M P KMP KMP 算法分两步:
- 对于字符串
A
A
A 进行自我 “匹配” ,求出一个
next
数组,其中next[i]
表示 “ a a a 中以 i i i 结尾的非前缀子串” 与 “ A A A 的前缀” 能够匹配的最大长度,即:
n e x t [ i ] = m a x { j } , j < i a n d A [ i − j + 1 ∼ i ] = A [ 1 ∼ j ] next[i] = max\{j\},j<i ~and~A[i-j+1\thicksim i] = A[1\thicksim j] next[i]=max{j},j<i and A[i−j+1∼i]=A[1∼j]
当不存在能匹配的前缀时, n e x t [ i ] = 0 next[i] = 0 next[i]=0 。 - 对字符串
A
A
A 与
B
B
B 进行匹配,求出一个
f
f
f 数组,其中
f
[
i
]
f[i]
f[i] 表示 “
B
B
B 中以
i
i
i 结尾的子串” 与 “
A
A
A 的前缀” 能够匹配的最大长度,即:
f [ i ] = m a x { j } , j < i a n d B [ i − j + 1 ∼ i ] = A [ 1 ∼ j ] f[i] = max\{j\},j<i ~and~B[i-j+1\thicksim i] = A[1\thicksim j] f[i]=max{j},j<i and B[i−j+1∼i]=A[1∼j]
最后在 f [ i ] = n f[i] = n f[i]=n 的位置就是字符串 A A A 与 B B B 完美匹配的地方。
现在看如何计算 n e x t next next 数组,第一种方法肯定就是暴力。
枚举 j ∈ [ 1 , i − 1 ] j\in [1,i -1] j∈[1,i−1] ,检查 A [ i − j + 1 ∼ i ] A[i-j+1\thicksim i] A[i−j+1∼i] 与 A [ 1 ∼ j ] A[1\thicksim j] A[1∼j] 是否相等。在枚举中所有满足条件的最大值就是 n e x t [ i ] next[i] next[i] 了。但是时间复杂度是 O ( n 2 ) O(n^2) O(n2) ,显然不行。如何优化呢?
引理
若 j 0 j_0 j0 是 n e x t [ i ] next[i] next[i] 的一个 “候选项”,即 j 0 < i j_0 < i j0<i 且 A [ i − j 0 + 1 ∼ i ] = A [ 1 ∼ j 0 ] A[i - j_0 + 1 \thicksim i] = A[1\thicksim j_0] A[i−j0+1∼i]=A[1∼j0] ,则小于 j 0 j_0 j0 的最大的 n e x t [ i ] next[i] next[i] 的 “候选项” 是 n e x t [ j 0 ] next[j_0] next[j0] 。换而言之, n e x t [ j 0 ] + 1 ∼ j 0 − 1 next[j_0] + 1 \thicksim j_0 - 1 next[j0]+1∼j0−1 之间的数都不是 n e x t [ i ] next[i] next[i] 的 “候选项” 。
书上证明是采用反证法。就是假设中途有一个 j 1 j_1 j1 也是 “候选项” ,但根据推理,不可能存在这个 j 1 j_1 j1 ,所以矛盾了,因此证毕。
使用引理优化 n e x t next next 数组求法
根据引理,当 n e x t [ i − 1 ] next[i -1] next[i−1] 计算完毕时,我们就已经得知了 n e x t [ i − 1 ] next[i - 1] next[i−1] 的所有 “候选项” 从大到小依次是 n e x t [ i − 1 ] next[i - 1] next[i−1] , n e x t [ n e x t [ i − 1 ] ] next[next[i - 1]] next[next[i−1]] ,··· 而如果整数 j j j 是 n e x t [ i ] next[i] next[i] 的 “候选项” 的话,那么 j − 1 j - 1 j−1 肯定是 n e x t [ i − 1 ] next[i - 1] next[i−1] 的 “候选项” 了,这个可以画图理解。因此在计算 n e x t [ i ] next[i] next[i] 时,只需将 n e x t [ i − 1 ] + 1 next[i - 1] + 1 next[i−1]+1 , n e x t [ n e x t [ i − 1 ] ] + 1 next[next[i - 1]] + 1 next[next[i−1]]+1 ,··· 作为 “候选项” 就行了,如果 A [ i ] = = A [ j + 1 ] A[i] == A[j + 1] A[i]==A[j+1] 的话,显然 n e x t [ i ] = n e x t [ j ] + 1 next[i] = next[j] + 1 next[i]=next[j]+1 了。
因此 K M P KMP KMP 算法求 n e x t next next 数组 :
- 初始化 n e x t [ 1 ] = j = 0 next[1] = j = 0 next[1]=j=0 ,假设 n e x t [ 1 ∼ i − 1 ] next[1\thicksim i - 1] next[1∼i−1] 已求出,下面求解 n e x t [ i ] next[i] next[i] 。
- 不断扩展匹配长度 j j j ,如果扩展失败(下一个字符不相等),令 j j j 变为 n e x t [ j ] next[j] next[j] ,直到 j = 0 j = 0 j=0 。
- 如果扩展成功,匹配长度 j j j 就增加 1 1 1 。 n e x t [ i ] = j next[i] = j next[i]=j 。
代码如下:
next[1] = 0;
for(int i = 1, j = 0; i <= n; ++i) {
while(j > 0 && a[i] != a[j + 1]) j = next[j];
if(a[i] == a[j + 1]) j++;
next[i] = j;
}
因为定义相似,所以求解 f f f 与求解 n e x t next next 基本一致。
代码如下:
for(int i = 1, j = 0; i <= n; ++i) {
while(j > 0 && (j == n || b[i] != a[j + 1])) j = next[j];
if(b[i] == a[j + 1]) j++;
f[i] = j;
//if(f[i] == n) 此时就是匹配成功的位置。
}
【例题】周期
一个字符串的前缀是从第一个字符开始的连续若干个字符,例如 abaab
共有
5
5
5 个前缀,分别是 a
,ab
,aba
,abaa
,abaab
。
我们希望知道一个 N N N 位字符串 S S S 的前缀是否具有循环节。
换言之,对于每一个从头开始的长度为 i i i( i > 1 i>1 i>1)的前缀,是否由重复出现的子串 A A A 组成,即 A A A … A AAA…A AAA…A ( A A A 重复出现 K K K 次, K > 1 K>1 K>1)。
如果存在,请找出最短的循环节对应的 K K K 值(也就是这个前缀串的所有可能重复节中,最大的 K K K 值)。
数据范围
2
≤
N
≤
1000000
2≤N≤1000000
2≤N≤1000000
分析:
因为是循环节,所以肯定与 K M P KMP KMP 的 n e x t next next 数组有关。而且也确实有这么个性质,即 S [ 1 ∼ i ] S[1\thicksim i] S[1∼i] 具有长度为 l e n < i len < i len<i 的循环元的充要条件是 l e n len len 能整除 i i i 并且 S [ l e n + 1 ∼ i ] = S [ 1 ∼ i − l e n ] S[len + 1\thicksim i] = S[1\thicksim i - len] S[len+1∼i]=S[1∼i−len] (即 i − l e n i - len i−len 是 n e x t [ i ] next[i] next[i] 的 “候选项” )。
书上证明是先从必要性,显然具有循环元的,自然为候选项。然后是充分性,可以通过画图推导。这个 n e x t next next 数组里的 l e n len len 如果不能被 i i i 整除则表示是一个不完整的长度为 l e n len len 的循环元,后面的习题会用上这个性质。
因此我们求出 n e x t [ i ] next[i] next[i] 后,判断 i % ( i − n e x t [ i ] ) = = 0 a n d i / ( i − n e x t [ i ] ) > 1 i \% (i - next[i]) == 0 ~and ~i/(i-next[i]) > 1 i%(i−next[i])==0 and i/(i−next[i])>1 就行了。
代码如下:
#include <bits/stdc++.h>
using namespace std;
const int N = 1000010;
char a[N];
int ne[N];
int n;
void init() {
ne[1] = 0;
for(int i = 2, j = 0; i <= n; ++i) {
while(j > 0 && a[i] != a[j + 1]) j = ne[j];
if(a[i] == a[j + 1]) j ++;
ne[i] = j;
}
}
int main()
{
int tt = 1;
while(scanf("%d", &n) && n) {
scanf("%s", a + 1);
init();
printf("Test case #%d\n", tt++);
for(int i = 2; i <= n; ++i) {
int m = ne[i];
if(i % (i - m) == 0 && i / (i - m) > 1) {
cout << i << ' ' << i / (i - m) << endl;
}
}
puts("");
}
return 0;
}
最小表示法
给定一个字符串 S [ 1 ∼ n ] S[1\thicksim n] S[1∼n] ,如果不断把它最后一个字符放在开头,最终会得到 n n n 个字符串,称这 n n n 个字符串是循环同构的。而其中字典序最小的一个,称为字符串的最小表示。
朴素求法很容易求,不过时间复杂度为 O ( n 2 ) O(n^2) O(n2) 。所有产生了一种 O ( n ) O(n) O(n) 算法。
其算法过程就是,先将字符串 S S S 变成两倍,这样我们就只需要在这个新串上找到字典序最小的、长度为 n n n 的子串就行了,而在找的过程中使用双指针可以发现如果 i + k i + k i+k 的字符 和 j + k j + k j+k 字符不一样,那么如果 S S [ i + k ] > S S [ j + k ] SS[i +k] > SS[j+ k] SS[i+k]>SS[j+k] ,就说明 i + 1 ∼ i + k i + 1\thicksim i+k i+1∼i+k 开头的所有子串都不可能是最小的啦,那么根据这个单调性,就可以优化到 O ( n ) O(n) O(n) 了。
代码如下:
#include <bits/stdc++.h>
using namespace std;
string get_min(string str) {
string S = str + str;
int n = str.size();
int i = 0, j = 1, k;
while (i < n && j < n) {
for(k = 0; k < n && S[i + k] == S[j + k]; ++k);
if(k == n) break;
if(S[i + k] > S[j + k]) {
i = i + k + 1;
if(i == j) i ++ ;
} else {
j = j + k + 1;
if(i == j) j++;
}
}
return S.substr(min(i, j), n);
}
int main() {
string str;
cin >> str;
cout << get_min(str) << endl;
return 0;
}