题目地址:
给定一个串 s s s和一个模式串 p p p,要求输出所有 p p p作为子串在 s s s中出现的首字母的位置。下标从 0 0 0开始计数。
输入格式:
第一行输入整数
N
N
N,表示字符串
P
P
P的长度。第二行输入字符串
P
P
P。第三行输入整数
M
M
M,表示字符串
S
S
S的长度。第四行输入字符串
S
S
S。
输出格式:
共一行,输出所有出现位置的起始下标(下标从
0
0
0开始计数),整数之间用空格隔开。
数据范围:
1
≤
M
≤
1
0
5
1\le M\le 10^5
1≤M≤105
1
≤
N
≤
1
0
6
1\le N\le 10^6
1≤N≤106
可以用KMP算法。开两个指针,指针 i i i指向 s s s,另一个指针 j j j指向 t t t。我们想让 i i i不回退,而只让 j j j回退。考虑当发生不匹配的时候, j j j应该要回退到什么位置继续比较。例如 s [ a ] ≠ p [ b ] s[a]\ne p[b] s[a]=p[b],那么我们起码知道了 s [ a − b : a − 1 ] = p [ 0 : b − 1 ] s[a-b:a-1]=p[0:b-1] s[a−b:a−1]=p[0:b−1],而 j j j回退的位置应当使得 p [ 0 : j − 1 ] = p [ b − j : b − 1 ] p[0:j-1]=p[b-j:b-1] p[0:j−1]=p[b−j:b−1]第一次成立,即 j j j要取最大的满足这个条件的数(这里的第一次成立的意思是,从 j − 1 j-1 j−1向 0 0 0数的第一次,这样 j j j回退的最少。之所以这样做,是在模仿回退 1 , 2 , . . . 1,2,... 1,2,...步,第一次发现 s [ a − j : a − 1 ] = p [ 0 : j − 1 ] s[a-j:a-1]=p[0:j-1] s[a−j:a−1]=p[0:j−1]成立的情形)。对于每个 j j j,它失配后转移的位置即为 n [ j ] n[j] n[j](即为KMP算法的next数组),那么显然,这里 n [ j ] n[j] n[j]的含义就是,使得 p [ 0 : k − 1 ] = p [ j − k : j − 1 ] p[0:k-1]=p[j-k:j-1] p[0:k−1]=p[j−k:j−1]并且 k < j k<j k<j的最大的 k k k。直观理解就是, n [ j ] n[j] n[j]是使得 p [ 0 : j − 1 ] p[0:j-1] p[0:j−1]的前后缀相等的最大长度。这样在做双指针匹配的时候,当 s [ a ] ≠ p [ b ] s[a]\ne p[b] s[a]=p[b]的时候,可以查表,令 b = n [ b ] b=n[b] b=n[b],然后继续匹配 s [ a ] s[a] s[a]和 p [ b ] p[b] p[b]。当然如果 s [ a ] = p [ b ] s[a]= p[b] s[a]=p[b],那就直接两个指针同时后移。当发现 j = l p j=l_p j=lp说明找到了一次匹配,起始下标就是 i − j i-j i−j。
这里还有两个问题需要解决:
1、怎么计算
n
n
n数组。首先规定
n
[
0
]
=
−
1
n[0]=-1
n[0]=−1,这里表示的意思是,如果
p
[
0
]
p[0]
p[0]就直接不匹配
s
[
i
]
s[i]
s[i]了,那就将
j
j
j回退到
−
1
-1
−1,下一轮再去匹配
s
[
i
+
1
]
s[i+1]
s[i+1]和
p
[
0
]
p[0]
p[0],这样正好方便代码的书写。接下来我们考虑
n
n
n数组怎么递推。其实递推
n
n
n数组本质上也是在做字符串匹配,只不过是
p
p
p自己的后缀和自己的前缀匹配。我们考虑如果
p
[
i
]
p[i]
p[i]要和
p
[
j
]
p[j]
p[j]进行匹配,并且
p
[
i
−
j
:
i
−
1
]
=
p
[
0
:
j
−
1
]
p[i-j:i-1]=p[0:j-1]
p[i−j:i−1]=p[0:j−1],那么如果
p
[
i
]
=
p
[
j
]
p[i]=p[j]
p[i]=p[j],说明前后缀又多了一个字符可以匹配,所以
n
[
i
+
1
]
=
n
[
j
+
1
]
n[i+1]=n[j+1]
n[i+1]=n[j+1];否则
j
j
j回退到
n
[
j
]
n[j]
n[j],去匹配下一个前缀
p
[
0
:
n
[
j
]
]
p[0:n[j]]
p[0:n[j]]。这里的原理和匹配
s
s
s和
p
p
p的过程是一样的。同样如果
j
=
−
1
j=-1
j=−1了,说明此时不存在相等的前后缀,则下一次匹配位置就是
p
[
0
]
p[0]
p[0],即
n
[
i
+
1
]
=
0
n[i+1]=0
n[i+1]=0。
2、怎么找到满足条件的所有匹配。这个很容易处理,只需要在找到匹配的时候,先存下答案,然后让 i i i回退一步,让 j j j等于 n [ j − 1 ] n[j-1] n[j−1],假装 s [ i − 1 ] s[i-1] s[i−1]与 p [ j − 1 ] p[j-1] p[j−1]不匹配就行了。
代码如下:
#include <iostream>
#include <string>
#include <vector>
using namespace std;
string s, p;
int n, m;
vector<int> res;
void buildNext(string p, int ne[]) {
ne[0] = -1;
for (int i = 0, j = -1; i < m - 1;)
if (j < 0 || p[j] == p[i])
ne[++i] = ++j;
else
j = ne[j];
}
void kmp(string s, string p) {
int ne[m];
buildNext(p, ne);
for (int i = 0, j = 0; i < n;) {
if (j == -1 || s[i] == p[j]) {
i++, j++;
if (j == m) {
cout << i - j << " ";
i--;
j = ne[j - 1];
}
} else
j = ne[j];
}
}
int main() {
cin >> m;
cin >> p;
cin >> n;
cin >> s;
kmp(s, p);
}
时间复杂度 O ( N + M ) O(N+M) O(N+M),空间 O ( M ) O(M) O(M)。
下面考虑 n n n数组的优化(但是需要注意,优化后的 n n n数组是做不了这道题的,只能做到找到首次匹配的位置)。有一种情况是,如果 p [ j ] p[j] p[j]失配,然而 p [ n [ j ] ] = p [ j ] p[n[j]]=p[j] p[n[j]]=p[j],那么再比较一次必然还是不匹配的,这时候相当于又做了次无用功。这时我们可以在求 n n n数组的时候加一个判断,在执行 i i i和 j j j自增后,如果发现 p [ i ] ≠ p [ j ] p[i]\ne p[j] p[i]=p[j]才令 n [ i ] = j n[i]=j n[i]=j,表达的意思是如果不等,才可以像上面的做法那样前后缀匹配长度加 1 1 1;如果发现 p [ i ] = p [ j ] p[i]= p[j] p[i]=p[j]了,那像上面做法那样回退是没用的,因为回退了还是不匹配,必须从 j j j再回退一步到 n [ j ] n[j] n[j]才行。
之所以说优化后 n n n数组无法做这道题,原因是优化里考虑了转移之后依然不匹配的情况了。这就会造成,如果是匹配的,我们还像上面那样假装最后一个字符不匹配而调用优化版 n n n数组进行跳转,就会直接把明明匹配的字符给跳掉。这样就找不到所有的匹配情况了。换句话说,未优化的 n n n数组只考虑了当前不匹配的话, p p p要至少右移多少。而优化的 n n n还考虑了万一右移了还不匹配怎么办。这就是优化版无法做这道题的原因(当然也不是说做不了,只不过最坏情况会退化成暴力匹配,然后TLE)。
代码如下(找到第一次匹配位置):
void buildNext(string p, int ne[]) {
ne[0] = -1;
for (int i = 0, j = -1; i < m - 1;)
if (j < 0 || p[j] == p[i]) {
i++;
j++;
ne[i] = p[i] != p[j] ? j : ne[j];
} else j = ne[j];
}
void kmp(string s, string p) {
int ne[m];
buildNext(p, ne);
for (int i = 0, j = 0; i < n;) {
if (j == -1 || s[i] == p[j]) i++, j++;
else j = ne[j];
// 找到第一次匹配位置即输出答案并退出
if (j == m) {
cout << i - j << endl;
break;
}
}
}
提供一个字符串下标从 1 1 1开始的模板:
#include <iostream>
using namespace std;
const int N = 1e6 + 10;
int n, m;
char p[N], s[N];
int ne[N];
void build_ne() {
for (int i = 2, j = 0; i <= m; i++) {
while (j && p[i] != p[j + 1]) j = ne[j];
if (p[i] == p[j + 1]) j++;
ne[i] = j;
}
}
int main() {
cin >> m >> p + 1 >> n >> s + 1;
build_ne();
for (int i = 1, j = 0; i <= n; i++) {
while (j && s[i] != p[j + 1]) j = ne[j];
if (s[i] == p[j + 1]) j++;
if (j == m) {
printf("%d ", i - m);
j = ne[j];
}
}
}
时空复杂度与上同。
优化版:
void build_ne() {
for (int i = 2, j = 0; i <= m; i++) {
while (j && p[i] != p[j + 1]) j = ne[j];
if (p[i] == p[j + 1]) j++;
ne[i] = i < m && p[i + 1] != p[j + 1] ? j : ne[j];
}
}
主函数部分一样。
当然字符串哈希做法也可以,代码如下:
#include <iostream>
using namespace std;
using ll = long long;
const int N = 1e6 + 10;
const ll P = 131;
int n, m;
char s[N], p[N];
ll hashP, hashS, po = 1;
int main() {
scanf("%d", &m);
scanf("%s", p + 1);
scanf("%d", &n);
scanf("%s", s + 1);
for (int i = 1; i <= m; i++) {
hashP = hashP * P + p[i];
po = po * P;
}
for (int i = 1; i <= n; i++) {
hashS = hashS * P + s[i];
if (i >= m) {
hashS -= s[i - m] * po;
if (hashS == hashP) printf("%d ", i - m);
}
}
}
时间复杂度 O ( N + M ) O(N+M) O(N+M),空间 O ( 1 ) O(1) O(1)。