题解 P7114 【[NOIP2020] 字符串匹配】
泥土笨笨
2021-02-16 15:35:09
我有个梦想,我想写一篇多图的,讲解细的,注释多的,$O(nlog26)$复杂度的题解。
# 前置知识
1. 树状数组 这个相信能参加NOIP的同学都会。
2. 扩展KMP算法 模板题:[P5410 【模板】扩展 KMP(Z 函数)](https://www.luogu.com.cn/problem/P5410) 这道题我也写了[题解](https://www.luogu.com.cn/blog/nitubenben/solution-p5410),欢迎评论点赞围观。这道模板题是紫色的,其实一点也不难,我觉得难度有点虚标了,相信你看了我的题解很快就可以学会的。
如果你不会扩展KMP算法,其实也不影响你阅读本文,只需知道这个算法提供了一个z函数的计算方式。
不妨设输入的字符串是$s$,下标从$0$开始,长度是$n$。用$s[i,j]$表示$s$中从下标$i$位置到下标$j$位置的子串,包括$i$和$j$。那么$z[i]$表示$s[i,n-1]$与$s$本身的最长公共前缀的长度。
比如$z[0]$表示$s$与$s$的公共子串的长度,那么$z[0]=n$
$z[1]$表示$s$去掉开头第一个字符以后,和自己的最长公共前缀的长度,依次类推。扩展KMP算法可以在$O(n)$时间内求出$z$数组的所有位置。
# 枚举循环节长度
先不考虑题中的出现奇数次这个条件。先考虑把$s$拆成$(AB)^kC$的方案数,其中$AB$合起来是一个循环节,我们先考虑一个循环节有多长。
不妨设循环节长度为$i$,第一个发现是,只要满足$2 \leq i \leq n-1$都可以。因为$A$和$B$都不能为空,所以循环节长度至少是$2$。循环节长度最多是$n-1$,因为$C$也不能为空。
当循环节长度是$i$的时候,至少$k$可以取到$1$,就是只循环一次,后面剩下的都给$C$。除此之外,k还可以取多少呢?不妨以$i=3$为例,画一个图看看
![](https://cdn.luogu.com.cn/upload/image_hosting/16k5feoc.png)
假设$z[3]=7$,也就是说,字符串从$3$位置开始,连续$7$个长度的子串,和字符串刚开头的$7$个字符相等。那么我们把$s$再画一遍,去掉前$3$个位置,抄在下面,如图所示,那么画蓝线的部分是相等的。
现在考虑长度为$3$的循环节,首先红色的$3$个位置,和下面橙色的三个位置肯定是相等的,因为它们都是蓝线部分开头的$3$个。而橙色的部分上下是对应相等的,于是红色和橙色相等。
同理,由于橙色和绿色相等,那么红,橙,绿都相等,也就是说长度为3的循环节,至少可以循环3次。这里可以看出,蓝线的长度,除以循环节的长度再加1,就可以算出来最多可以循环几次,如果不能整除,向下取整。这个例子里面就是$\lfloor 7/3 \rfloor +1$,那么$k$可以取$3$种。
那么第一个结论就出来了,枚举一下循环节的长度i,总的方案数就是
$$\lfloor \frac{z[i]}{i} \rfloor +1$$
之和。
# 考虑字符出现的次数
题目中还有一个条件我们没有考虑,为了说话方便,我们定义$f(i,j)$表示$s[i,j]$中出现奇数次的字符的个数。
假设现在枚举到循环节长度为$i$,此时根据前面的结论,我们算出来$k$的方案数有$t$种。其中一半$k$是奇数,一半$k$是偶数。当然奇数可能比偶数多一个。不妨设$k$是奇数的取值方案数是$tj$,那么有$tj = t-t/2$,同理设偶数的$k$的个数是$to$,有$to = t/2$
考虑$k$的取值。如果$k$的值是奇数:
![](https://cdn.luogu.com.cn/upload/image_hosting/iv72qpb3.png)
如图所示,$k=1$时候,$C$的长度是从$i$开头的后缀的长度。$k=3$的时候,$C$是更短的红线表示的范围。这两种情况下,$C$当中出现次数为奇数的字符的个数是一样多的,因为如果循环节多出现了两次,相当于循环节里面的每个字符都出现了偶数次,不影响$C$当中出现次数为奇数次的字符的个数。
所以,当$k$是奇数的时候,我们只需计算出来,以$i$为开头的后缀当中出现奇数次的字符的个数就行了。然后在长度为i的子串的前缀$s[0,j]$当中,找出来满足:
$$f(0,j) \leq f(i,n-1)$$
的$j$的个数,记为$t1$。而对于每个合法的$j$,我们发现,只要是奇数的$k$,都可以是一种合法方案,所以这里要乘法原理一下,把$t1$和$tj$的个数相乘。
再来考虑k是偶数的情况。
![](https://cdn.luogu.com.cn/upload/image_hosting/6pxq01fv.png)
跟前面情况类似,当k是偶数的时候,后缀C里面的只出现奇数次的字符的个数,和整个串里面只出现奇数次的字符个数是相等的。在长度为i的子串的前缀$s[0,j]$当中,找出来满足:
$$f(0,j) \leq f(0,n-1)$$
的$j$的个数,记为$t2$。那么这种情况下的方案数,就是$t2$和$to$相乘。
# 具体实现
首先前面的算法中用到了$f(0,n-1)$,这个好办,直接预处理算出来就行了。
在从左往右扫字符串$s$的过程中,假设我们目前枚举到位置$i$,此时我们计算循环节长度为$i+1$的情况,此时$A$最远可以取到$i-1$位置,把$i$位置留着给$B$,保证$B$不为空。我们可以用一个桶,维护$i+1$开头的后缀里面每个字母出现的次数,当$i$向右循环的时候,每次只改一个字符,所以在桶的对应位置减$1$,然后看看出现奇数次的字符数量如何变化就行了。
不妨设这个桶叫$after$,一共有$26$个格,分别表示每个字符出现的次数。起始的时候,先扫一遍s,把整个字符串都统计一遍,这时候可以顺手求出整个串里面只出现奇数次的字符的个数,记到$all$变量里面。然后当后面循环的时候,循环到$i$位置,就把$after[s[i]-'a']$位置减$1$。设变量$suf$表示$f[i+1,n-1]$,如果在$after[s[i]-'a']$减1之前,$suf$是奇数,那么减完以后,后缀里面的奇数就会少一个,那么$suf$减$1$,否则$suf$加一。这样每次$i$移动,$suf$都可以$O(1)$维护。
那么循环节里面的每个前缀中出现奇数次的字符个数,可以放到一个树状数组里面,这样这个树状数组一共有$26$个位置,每个位置$p$保存出现奇数次的字符有$p$个的前缀有多少个。$i$每向右循环$1$位,就在计算完结果以后,把当前前缀扔到树状数组里面。
其他细节可以参考代码
# 代码时间
```cpp
#include <iostream>
#include <cstdio>
#include <cstring>
using namespace std;
const int MAXN = (1 << 20) + 5;
char s[MAXN];//输入的字符串
int n, z[MAXN];//字符串长度,以及z函数的值
int before[30], after[30];//两个桶,分别统计当前枚举到的位置左侧和右侧每个字符出现的频次
int pre, suf, all;//当前枚举到的位置的前缀、后缀、整个串里面出现奇数次的字符的个数
//树状数组,对于s的某个前缀si,如果它里面出现奇数次的字符有m个,则在树状数组m+1位置+1
int c[30];
inline int lbt(int x) {
return x & -x;
}
void update(int x) {
while (x <= 27) {
c[x]++;
x += lbt(x);
}
}
int sum(int x) {
int r = 0;
while (x > 0) {
r += c[x];
x -= lbt(x);
}
return r;
}
//扩展KMP算法,计算z函数的值
//可以参考良心博客 https://www.luogu.com.cn/blog/nitubenben/solution-p5410
void Z() {
z[0] = n;
int now = 0;
while (now + 1 < n && s[now] == s[now + 1]) {
now++;
}
z[1] = now;
int p0 = 1;
for (int i = 2; i < n; ++i) {
if (i + z[i - p0] < p0 + z[p0]) {
z[i] = z[i - p0];
} else {
now = p0 + z[p0] - i;
now = max(now, 0);
while (now + i < n && s[now] == s[now + i]) {
now++;
}
z[i] = now;
p0 = i;
}
}
}
int main() {
int T;
cin >> T;
while (T--) {
cin >> s;
n = strlen(s);
memset(before, 0, sizeof(before));
memset(after, 0, sizeof(after));
memset(c, 0, sizeof(c));
all = pre = suf = 0;
Z();
//如果发现循环节可以到结尾,减1,空至少一个位置给C
for (int i = 0; i < n; ++i) {
if (i + z[i] == n) z[i]--;
}
//先把字符串过一遍,频次统计到after数组里面
for (int i = 0; i < n; ++i) {
after[s[i] - 'a']++;
}
//扫一下每个字母,计算整个串中出现奇数次的字符的个数
for (int i = 0; i < 26; ++i) {
if (after[i] & 1) {
all++;
}
}
suf = all;//后缀中的值暂时和整个串一致
long long ans = 0;
for (int i = 0; i < n; ++i) {
//再扫一次字符串,当循环到i位置的时候,循环节长度是i+1
//s[i]要从右边去掉,维护after数组和suf变量
if (after[s[i] - 'a'] & 1) {
//之前是奇数,现在变成偶数了
suf--;
} else {
suf++;
}
after[s[i] - 'a']--;
//s[i]加到左边,维护before和pre变量
if (before[s[i] - 'a'] & 1) {
pre--;
} else {
pre++;
}
before[s[i] - 'a']++;
if (i != 0 && i != n - 1) {
//循环节大于1,才能对答案有贡献,因为题中说ABC都不为空
int t = z[i + 1] / (i + 1) + 1;
ans += 1LL * (t / 2) * sum(all + 1) + 1LL * (t - t / 2) * sum(suf + 1);
}
update(pre + 1);
}
cout << ans << endl;
}
return 0;
}
```