AC 自动机套路题
这两天刚学了 AC 自动机,写了一些练习题,总结一下,顺便与大家分享一下经验。
下面一共有 A-G 七道题目,大致综合考虑思维难度+代码难度进行由低到高排序(尽管洛谷上的评级都是 省选/NOI- )。
套路类型 1 :标记法优化跳 fail 树的复杂度
这个包括上篇博客中提到的用打标记的方式防止重复访问路径和树形 DP 等其他手段。
Problem A 【模板】AC 自动机(二次加强版)
题目链接:Link
-
给你一个文本串 (S) 和 (n) 个模式串 (T_{1...n}) ,求出每个 (T_i) 在 (S) 中出现的次数(不保证没有相同模式串,所有串仅含小写字母)。
-
(1leq |S|leq 2 imes 10^6,1leq n,sum |T_i|leq2 imes 10^5)
显然如果直接扫描文本串,然后暴力跳 fail 的复杂度应该是 (O(|S|sum|T_i|)) 级别的,这样一定会超时。
考虑 ACAM 寻找模式串是否在文本串中出现的本质是什么:每次从一个结点跳 fail 一直到根,如果遇到了模式串的末尾结点,那么这个模式串就出现了一次 。
如果我们对每个结点建立一个 cnt 值,表示这个结点在跳 fail 的时候访问了几次的话,上面的操作实际上就是把当前结点到根路径上的结点的 cnt 值 +1 。因为 fail 是一棵树,那么每个结点到根的路径一定是唯一的,于是我们没有必要一直访问到根,只需要在结点上打一个标记,表示“这个结点到根的路径都被访问了一次”。
像上面那样做,考虑每个结点实际上被访问了多少次,显然一个结点子树内的点想要访问根,那么一定会经过这个点,而非该结点子树内的点想要访问根则一定不经过这个点,于是我们可以从 fail 树的叶子结点向上转移统计答案,转移式为:
利用这个式子,进行一发树形 DP 即可求出每个结点被访问了多少次,也就求出了 Trie 中以这个结点为结尾的前缀在文本串中的出现次数。只需要输出每一个模式串的末尾结点被访问的次数即可,总复杂度 (O(|S|+sum|T_i|))。
Code
const int maxn=200005;
int n,tot;
char ch[maxn*10];
std::vector<int>to[maxn];//记录fail树上的边
struct Trie{
int fail,num,cnt;
std::vector<int>mark;
int ch[28];
}tr[maxn];
void Build(const int x){
int len=std::strlen(ch),u=0;
for(int i=0;i<len;++i){
int s=ch[i]-'a';
if(tr[u].ch[s]==0) tr[u].ch[s]=++tot;
u=tr[u].ch[s];
}
tr[u].mark.push_back(x);//处理相同模式串的情况
}
void make_fail(){
std::queue<int>q;
for(int i=0;i<26;++i)
if(tr[0].ch[i]!=0){
tr[tr[0].ch[i]].fail=0;
to[0].push_back(tr[0].ch[i]);
q.push(tr[0].ch[i]);
}
while(q.size()){
int u=q.front();
q.pop();
for(int i=0;i<26;++i){
int v=tr[u].ch[i];
if(v){
tr[v].fail=tr[tr[u].fail].ch[i];
to[tr[tr[u].fail].ch[i]].push_back(v);
q.push(v);
}else tr[u].ch[i]=tr[tr[u].fail].ch[i];
}
}
}
int mx[maxn],sum[maxn];//这里的sum和上面转移式中的sum不同,这里的sum表示答案,mx才是每个结点出现次数
void get_sum(const int x){
mx[x]=tr[x].cnt;
for(int i=0;i<to[x].size();++i){
int y=to[x][i];
get_sum(y);
mx[x]+=mx[y];
}
for(int i=0;i<tr[x].mark.size();++i)
sum[tr[x].mark[i]]=mx[x];
}//树形DP
void AC_automaton(){
int len=std::strlen(ch),u=0;
for(int i=0;i<len;++i){
int s=ch[i]-'a';
u=tr[u].ch[s];
tr[u].cnt++;
}
get_sum(0);
}
signed main(){
// freopen("simple.txt","r",stdin);
read(n);
for(int i=1;i<=n;++i){
scanf("%s",ch);
Build(i);
}
tr[0].fail=0;
make_fail();
scanf("%s",ch);
AC_automaton();
for(int i=1;i<=n;++i)
write(sum[i]),putchar('
');
return 0;
}
Problem B [TJOI 2013] 单词
题目链接:Link
- 给你 (N) 个模式串 (T_{1...n}),求出每一个模式串在其他模式串中出现的次数(所有串仅含小写字母)。
- (nleq 200,sum |T_i|leq 10^6)
众所周知,TJOI 喜欢出模板题。
这个题的优化和上题一样,都是标记法+树形 DP ,但是我们不可能拿每个模式串单独跑一遍。
小小的处理一下,我们把每个模式串都插入 Trie 中,然后把所有模式串拼接起来(两个模式串之间要用一个没出现过的字符隔开)作为文本串跑 AC 自动机即可。
总复杂度 (O(sum |T_i|))
Code
const int maxn=1000005;
int n,tot,it;
char ch[maxn],st[maxn+300];
std::vector<int>to[maxn];
struct Trie{
int fail,cnt;
int ch[27];
std::vector<int>ed;
}tr[maxn];
void Build(const int x){
int len=std::strlen(ch),u=0;
for(int i=0;i<len;++i){
int s=ch[i]-'a';
st[it++]=ch[i];
if(tr[u].ch[s]==0) tr[u].ch[s]=++tot;
u=tr[u].ch[s];
}.
st[it++]='{';//因为左花括号的 ASCII 在小写字母 z 之后,用起来比较方便,我就用它填充两个模式串之间的空
tr[u].ed.push_back(x);
}
void make_fail(){
std::queue<int>q;
for(int i=0;i<27;++i)
if(tr[0].ch[i]){
tr[tr[0].ch[i]].fail=0;
q.push(tr[0].ch[i]);
to[0].push_back(tr[0].ch[i]);
}
while(q.size()){
int u=q.front();
q.pop();
for(int i=0;i<27;++i){
int v=tr[u].ch[i];
if(v){
tr[v].fail=tr[tr[u].fail].ch[i];
to[tr[tr[u].fail].ch[i]].push_back(v);
q.push(v);
}else tr[u].ch[i]=tr[tr[u].fail].ch[i];
}
}
}
int cnt[maxn],ans[305];
void get_sum(const int x){
cnt[x]=tr[x].cnt;
for(int i=0;i<to[x].size();++i){
int y=to[x][i];
get_sum(y);
cnt[x]+=cnt[y];
}
for(int i=0;i<tr[x].ed.size();++i){
int y=tr[x].ed[i];
ans[y]=cnt[x];
}
}
void AC_automaton(){
int u=0;
for(int i=0;i<it;++i){
int s=st[i]-'a';
u=tr[u].ch[s];
tr[u].cnt++;
}
get_sum(0);
}
signed main(){
// freopen("1.in","r",stdin);
// freopen("my.out","w",stdout);
read(n);
for(int i=1;i<=n;++i)
scanf("%s",ch),Build(i);
tr[0].fail=0;
make_fail();
AC_automaton();
for(int i=1;i<=n;++i)
write(ans[i]),putchar('
');
return 0;
}
套路类型 2 :构建 ACAM 之后转图论问题
这类问题一般都是在 fail 树或者 Trie 图上跑 dfs 求出答案。
Problem C [JSOI2012] 玄武密码
题目连接:Link
-
给出一个文本串 (S),然后给出 (m) 个模式串 (T_{1...m}) (所有字符串都由大写字母 ( ext{ESWN}) 构成)。
对于每个 (T_i) ,求出它最长的前缀 (p) ,使得 (p) 是 (S) 的子串,输出 (p) 的长度。
-
(1leq |S|leq 10^7,1leq mleq 10^5,1leq |T_i|leq 100)
首先不难想到 AC 自动机,现在考虑答案是怎么求出来的。
把所有模式串插入 Trie 中,建好 ACAM ,然后拿文本串跑一遍(这里要用到防止重复访问的标记优化) 。
因为要使得 (p) 最长,我们可以想到一个简单的贪心:每次取 (T_i) 的前 (j) 个字符,看看这个前缀有没有出现在文本串中,如果出现了,那么答案为 (j) 。
Trie 树已经帮我们存好了每个模式串的所有前缀,我们每次只需要访问当前结点在 Trie 树上的父亲就可以从长度为 (j) 的前缀转移到长度为 (j-1) 的前缀。
ACAM 在跳 fail 边的时候已经把每个结点是否出现在文本串中标记好了,我们对于一个结点,只需要查看它是否有标记,就可以判定它是否出现在文本串里。
从每个模式串的末尾不停访问它的父亲,直到遇到一个有标记的结点,此时这个结点所代表的前缀就是所求的 (p) 。
所以问题转化为这样:给你一棵树,树上若干个结点有标记,求 (m) 个指定结点到根路径上遇到的第一个标记结点的深度。
因为这个题的模式串长度很小,所以暴力递归的执行上面的操作即可,时间复杂度 (O(|S|+sum|T_i|)) ,可以通过。
另外 ESWN 这四个字符的 ASCII 不连续,我用了 map 存,总复杂度应该乘 (log(|sum|)) ,其中 (|sum|) 为字符集大小,因为这里只有 4 ,所以忽略掉了。
Code
const int maxn=10000005;
std::map<char,int>map;
//ESWN 这四个字符的 ASCII 不连续,我用了 map 存,总复杂度应该乘 log(|sigma|)
int n,m,tot;
char ch[1500],st[maxn];
int s[100005],dis[100005],le[100005];//表示这个节点到最近的标记的距离
struct Trie{
int fail,cnt;
int ch[4],fa;//fa 记录一个结点在 Trie 中的父亲
}tr[maxn];
void Build(const int x){
int len=std::strlen(ch),u=0;
le[x]=len;
for(int i=0;i<len;++i){
int s=map[ch[i]];
if(tr[u].ch[s]==0) tr[u].ch[s]=++tot;
tr[tr[u].ch[s]].fa=u;
u=tr[u].ch[s];
}
s[x]=u;//表示第 x 个字符串的结尾是 u
}
void make_fail(){
std::queue<int>q;
for(int i=0;i<4;++i)
if(tr[0].ch[i]!=0){
tr[tr[0].ch[i]].fail=0;
q.push(tr[0].ch[i]);
}
while(q.size()){
int u=q.front();
q.pop();
for(int i=0;i<4;++i){
int v=tr[u].ch[i];
if(v){
tr[v].fail=tr[tr[u].fail].ch[i];
q.push(v);
}else tr[u].ch[i]=tr[tr[u].fail].ch[i];
}
}
}
void AC_automaton(){
int u=0;
for(int i=0;i<n;++i){
int s=map[st[i]];
u=tr[u].ch[s];
int v=u;
while(v && tr[v].cnt!=1){
tr[v].cnt=1;
v=tr[v].fail;
}
}
}
int get_dis(const int x){
if(tr[x].cnt==1){ return 0; }
return get_dis(tr[x].fa)+1;
}//暴力递归跳父亲
signed main(){
map['E']=0,map['S']=1,map['W']=2,map['N']=3;
read(n),read(m);
scanf("%s",st);
for(int i=1;i<=m;++i){
scanf("%s",ch),Build(i);
}
tr[0].fail=0;
make_fail();
AC_automaton();
for(int i=1;i<=m;++i)
get_dis(s[i]);
for(int i=1;i<=m;++i)
printf("%d
",le[i]-get_dis(s[i]));
return 0;
}
Problem D [POI 2000] 病毒
题目链接:Link
-
给你 (n) 个只包含 01 的模式串 (T_{1...n}) ,要求构造一个无限长的文本串,使得任意 (T_i) 不是这个文本串的子串。
若存在这样的一个构造,输出 TAK ,否则输出 NIE 。
-
(nleq 2000,sum|T_i|leq 3 imes 10^4)
波兰题,数据范围可能是受到了 2000 年计算机计算速度的限制,所以比较小,不过我们依旧考虑线性的 ACAM 做法。
OI 中一个经典的套路是“正难则反”(参考 WC 2006 水管局长),考虑如果我们拿着这样一个无限长的合法文本串扔到 ACAM 上匹配模式串会发生什么。
如果我们在程序中加上一句如果匹配到模式串就 return 0 的话,那么显然这个程序永远不会结束。
为什么呢?因为它卡死了,永远也匹配不上任何一个模式串。
然而 ACAM 内的结点是有限的,我们不可能沿着某一条链一直跑下去。
那么什么情况呢?显然是出现了一个环,我们的 ACAM 一直在这个环上打转,永远出不去。
也就是说,我们需要在建好 ACAM 的 Trie 图中在不访问模式串末尾结点的情况下找到一个从根结点能访问到的环(如果找到了环,但是从根结点访问不到也没意义)。如果找到了,说明存在符合条件的构造,如果没找到,则不存在这样的构造。
图上找环当然是 Tarjan 算法的专长了,不过这里我们用的是阉割版的 Tarjan 算法,因为我们只要找到一个环就行了。
从根出发,记录每个结点是否被访问过,每个结点是否在搜索栈中(类似于求 SCC 的那个栈)。访问过的结点不再访问,如果访问到的结点在搜索栈中,说明找到了一个环(也就是找到了一个 SCC ,一般的 Tarjan 这时候会弹栈求解 SCC ,我们直接退出程序就可以了)。
总复杂度 (O(sum|T_i|)) 。
Code
const int maxn=30005;
int n,tot;
char ch[maxn];
struct Trie{
int fail,num;
int ch[3];
}tr[maxn];
void Build(){
int len=std::strlen(ch),u=0;
for(int i=0;i<len;++i){
int s=ch[i]-'0';
if(tr[u].ch[s]==0) tr[u].ch[s]=++tot;
u=tr[u].ch[s];
}
tr[u].num=1;//标记模式串末尾不能走
}
void make_fail(){
std::queue<int>q;
for(int i=0;i<2;++i)
if(tr[0].ch[i]!=0){
tr[tr[0].ch[i]].fail=0;
q.push(tr[0].ch[i]);
}
while(q.size()){
int u=q.front();
q.pop();
for(int i=0;i<2;++i){
int v=tr[u].ch[i];
if(v){
tr[v].fail=tr[tr[u].fail].ch[i];
//别忘了我们的 ACAM 还会跳 fail 树,如果一个结点跳 fail 的时候会访问到模式串的末尾结点,那么这个结点也不能访问!
if(tr[tr[v].fail].num) tr[v].num=1;
q.push(v);
}else tr[u].ch[i]=tr[tr[u].fail].ch[i];
}
}
}
int init[maxn],vis[maxn];//init 记录是否在搜索栈中,vis 表示是否已经访问过
void dfs(const int x){
init[x]=vis[x]=1;
for(int i=0;i<2;++i){
int y=tr[x].ch[i];
if(init[y]){//找到环,输出 TAK 直接退出
puts("TAK");
exit(0);
}
if(vis[y]||tr[y].num) continue;//不走已经访问过的或者模式串末尾的结点
dfs(y);
}
init[x]=0;
}
signed main(){
// freopen("P2444_4.in","r",stdin);
read(n);
for(int i=1;i<=n;++i)
scanf("%s",ch),Build();
tr[0].fail=0;
make_fail();
dfs(0);
puts("NIE");//不存在满足条件的构造,输出 NIE
return 0;
}
套路类型 3: ACAM+DP
这类题更套路了,甚至状态设计都大同小异:设 (f[i][j]) 表示文本串长为 (i) ,当前在 ACAM 的结点 (j) 的情况。
Problem E [JSOI 2007] 文本生成器
题目链接:Link
- 给出 (n) 个模式串 (T_{1...n}),求在所有长度为 (m) 的文本串中,有多少至少有一个模式串为子串,对 (10007) 取模(文本串和模式串仅包含小写字母)。
- (nleq 60,mleq 100,|T_i|leq 100)
依旧是正难则反,用小写字母构成一个长度为 (m) 的串,一共有 (26^m) 种构造方法。于是我们可以求出有多少种构造使得没有一个模式串为它的子串,最后减一减就能得到答案。
按照上面的套路设计状态,设 (f[i][j]) 表示文本串长为 (i) ,当前在 ACAM 的结点 (j) 的情况。
那么转移方程很好写,从父亲转移到儿子,长度从 (i-1) 转移到 (i) 。
现在要解决如何枚举状态的问题,首先长度肯定要从 1 枚举到 (m) 。
注意到我们的状态表示长度为 (i) ,当前结点为 (j) ,所以实际上我们在 ACAM 的任意结点其实都有一个符合定义的状态,所以还要枚举所有点。
枚举每个点的每个儿子,注意判断一下是不是出现了模式串的结尾,如果出现了模式串结尾那么不能转移。
类似 D 题,如果一个结点跳 fail 的时候会访问某个模式串的结尾,那么这个结点也不能访问,要做好标记。
总时间复杂度 (O(msum |T_i|*26)) 。
其他部分大同小异,这里只给出核心的 DP 代码吧:
int AC_automaton(){
f[0][0]=1;
for(int i=1;i<=m;++i)
for(int j=0;j<=tot;++j)
for(int k=0;k<26;++k){
int v=tr[j].ch[k];
if(tr[v].tag) continue;
f[i][v]=(f[i][v]+f[i-1][j])%mod;
}
int tmp=1;
for(int i=1;i<=m;++i)
tmp*=26,tmp%=mod;
for(int i=0;i<=tot;++i)
tmp=(tmp-f[m][i]+mod)%mod;
return tmp;
}
因为我不是很擅长 DP ,所以只做了一个题,毕竟太菜了。
套路类型 4:ACAM+毒瘤数据结构
这可能是最毒瘤的一类题了,它们需要用一些数据结构维护 fail 树,有时候还需要一些依靠 ACAM 性质的转化。
Problem F CF547E Mike and Friends
题目链接:Link
- 给你 (n) 个仅小写字母组成的字符串 (S_{1...n}) ,按输入顺序编号 (1...n) 。(q) 次询问,每次询问编号为 (k) 的字符串在编号在 ([l,r]) 之间的字符串中出现了几次。
- (n,sum |s_i|leq 2 imes 10^5,qleq 5 imes 10^5)
我才不告诉你这题大部分人的解法是广义后缀自动机+线段树合并呢。
我们来看看 ACAM 的解法,先对这 (n) 个串构建 ACAM 。
我们先考虑这样一个简化问题:如果要求尽快查询 (S_i) 在所有串中出现了多少次,怎么搞?
还记 A 题中的标记法吗?一个结点的被访问的次数之和可以用这个方程求出来:
这其实等价于查询一个结点的子树和,可以离线 DP 预处理,(O(1)) 查询或者 (O(logn)) 用树链剖分实现在线查询。
因为所求区间可能会变,DP 就无用武之地了,我们对 fail 树进行树链剖分,这样我们的答案就可以用一次子树求和得出。
现在处理那个蛋疼的区间,一次查询实际上可以差分一下,变成查询一个串在编号 ([1,l-1]) 和在编号 ([1,r]) 之间的串的出现次数,二者作差得到答案。
拿出所有的 (S_i) 跑 ACAM ,在 Trie 图上边跳边打路径 +1 的标记,如果我们加入了第 (i) 个串,根据 ACAM 的性质,此时拿出任意一个串的末尾结点查询子树和,就可以得到在 ([1,i]) 中,这个串出现了几次。
于是可以把询问离线下来,当在 ACAM 完整的加入了某一个串后,树剖处理出与区间 ([1,i]) 有关的所有询问的答案,最后作差输出。
如果这个题强制在线呢?
那就要求我们记录下所有 ([1,1...n]) 区间的信息,最后分别查询两次作差得到答案。我们可以把每一个的加入操作当作“创建第 (i) 个版本”,于是这个东西可以主席树维护。需要注意的是,加入串时涉及了多次修改操作,每次加入一个新字符都要在主席树中创建一个新版本,最后插入完一个字符串才能真正对应上我们需要查询的“第 (i) 个版本”。
因为 (sum |s_i|leq 2 imes 10^5) ,我们最多会创建这么多版本的主席树,所以空间是 OK 的。
总时间复杂度 (O((q+sum |s_i|)log(sum |s_i|))) ,空间复杂度 (O(sum |s_i|)) 。
描述比较苍白,相信代码能带来更好的理解(我写的是在线的主席树版本)。
#include<bits/stdtr1c++.h>
const int maxn=300005;
int n,k;
char ch[maxn];
std::vector<char>str[maxn];
std::vector<int>to[maxn];
int tot,ed[maxn];
struct Trie{
int fail,cnt;
int ch[27];
}tr[maxn];
void Build_trie(const int x){
int len=std::strlen(ch),u=0;
for(int i=0;i<len;++i){
int s=ch[i]-'a';
str[x].push_back(ch[i]);
if(!tr[u].ch[s]) tr[u].ch[s]=++tot;
u=tr[u].ch[s];
}
ed[x]=u;
}
void make_fail(){
std::queue<int>q;
for(int i=0;i<26;++i)
if(tr[0].ch[i]){
tr[tr[0].ch[i]].fail=0;
to[0].push_back(tr[0].ch[i]);
q.push(tr[0].ch[i]);
}
while(q.size()){
int u=q.front();
q.pop();
for(int i=0;i<26;++i){
int v=tr[u].ch[i];
if(v){
tr[v].fail=tr[tr[u].fail].ch[i];
to[tr[v].fail].push_back(v);
q.push(v);
}else tr[u].ch[i]=tr[tr[u].fail].ch[i];
}
}
}
int hea[maxn],fa[maxn],dep[maxn],siz[maxn];
void dfs1(const int x,const int f){
dep[x]=dep[f]+1;
fa[x]=f;
siz[x]=1;
int M=-1;
for(int i=0;i<to[x].size();++i){
int y=to[x][i];
if(y==f) continue;
dfs1(y,x);
siz[x]+=siz[y];
if(siz[y]>M) hea[x]=y,M=siz[y];
}
}
int id[maxn],cnt,top[maxn];
void dfs2(const int x,const int topf){
id[x]=++cnt;
top[x]=topf;
if(!hea[x]) return;
dfs2(hea[x],topf);
for(int i=0;i<to[x].size();++i){
int y=to[x][i];
if(y==fa[x]||y==hea[x]) continue;
dfs2(y,y);
}
}
struct chairman_tree{
int l,r,sum;
chairman_tree *ls,*rs;
}node[maxn*19],*root[maxn],*pool=node;
chairman_tree *New(){ return ++pool; }
inline void update(chairman_tree *x){ x->sum=x->ls->sum+x->rs->sum; }
inline bool in_range(chairman_tree *x,const int L,const int R){ return (L<=x->l)&&(x->r<=R); }
inline bool outof_range(chairman_tree *x,const int L,const int R){ return (x->r<L)||(R<x->l); }
chairman_tree *Build_chairman_tree(const int L,const int R){
chairman_tree *u=New();
u->l=L,u->r=R;
if(L==R){
u->ls=u->rs=NULL;
u->sum=0;
}else{
int Mid=(L+R)>>1;
u->ls=Build_chairman_tree(L,Mid);
u->rs=Build_chairman_tree(Mid+1,R);
update(u);
}
return u;
}
void Modify(chairman_tree *pre,chairman_tree *now,const int p){
*now=*pre;
if(pre->l==p && pre->r==p) now->sum++;
else if(!outof_range(pre->ls,p,p)) Modify(pre->ls,now->ls=New(),p),update(now);
else Modify(pre->rs,now->rs=New(),p),update(now);
}
int Query(chairman_tree *Ltree,chairman_tree *Rtree,const int L,const int R){
if(in_range(Ltree,L,R)) return Rtree->sum-Ltree->sum;
if(outof_range(Ltree,L,R)) return 0;
return Query(Ltree->ls,Rtree->ls,L,R)+Query(Ltree->rs,Rtree->rs,L,R);
}
int R[maxn],it;//记录第i个字符串末尾对应的版本
void AC_automaton(const int x){
int u=0;
for(int i=0;i<str[x].size();++i){
int s=str[x][i]-'a';
u=tr[u].ch[s];
++it;
Modify(root[it-1],root[it]=New(),id[u]);
}
R[x]=it;
}
signed main(){
// freopen("simple.txt","r",stdin);
read(n),read(k);
for(int i=1;i<=n;++i)
scanf("%s",ch),Build_trie(i);
tr[0].fail=0;
make_fail();
dfs1(0,0);
dfs2(0,0);
root[0]=Build_chairman_tree(1,cnt);
for(int i=1;i<=n;++i)
AC_automaton(i);
while(k--){
int l,r,k_;
read(l),read(r),read(k_);
write(Query(root[R[l-1]],root[R[r]],id[ed[k_]],id[ed[k_]]+siz[ed[k_]]-1)),putchar('
');
}
return 0;
}
Problem G CF163E e-Government
题目链接:Link
-
给定包含 (k) 个字符串的集合 (S) ,有 (n) 个操作,操作有三种类型:
以 '?' 开头的操作为询问操作,询问当前字符串集 (S) 中的每一个字符串匹配询问字符串 (T) 的次数之和;
以 '+' 开头的操作为添加操作,表示将编号为 (i) 的字符串加入到集合中;
以 '-' 开头的操作为删除操作,表示将编号为 (i) 的字符串从集合中删除。
注意当编号为 (i) 的字符串已经在集合中时,允许出现操作:添加编号为 (i) 的字符串,删除亦然。你只需要忽略这条指令即可。
-
(n,kleq 10^5,sum |S|,sum |T|leq 10^6),
与其他 ACAM 的题目不同的是,这题要求我们求字符集匹配询问串的次数,而不是询问模式串匹配字符集的次数。
一个暴力的思路显然是维护字符集中每个模式串的结尾,然后 ACAM 跳 fail 的时候路径求和。
树剖可以做到 (O(logn)) 修改,(O(log^2n)) 查询,用 LCT 可以做到 (O(logn)) 修改 (O(logn)) 查询。
然而这题树剖会 MLE ,LCT 因为常数的关系会 TLE ,别问我怎么知道的,我 3 天狂码 12K 的数据结构才过掉这题。
发现每次暴力回答查询需要 (sum|T|) 次操作,但是时间上只允许我们最多带一个 log 的做这些操作。
如果这个题不用动态修改字符集的状态的话,我们可以修改一下 A 题中的 cnt 标记,让每个结点的 cnt 表示这个结点到根路径上有多少个模式串。这可以通过一次简单的 dfs ,从父亲转移到儿子求出。于是我们查询每个串就是线性 (sum |T|) 的。
现在考虑一次修改对 cnt 标记有何影响。
还是利用 fail 的树形结构,即每个结点子树内的结点到根的路径必然经过该点,非该结点子树内点到根的路径必然不经过该点。
如果我们让一个字符串消失,也就是说从这个结点子树内的点跑到根,统计不到这个字符串结尾的贡献。
如果我们让一个字符串出现,也就是说从这个结点子树内的点跑到根,统计得到这个字符串结尾的贡献。
这是什么?赤裸裸的子树修改!于是我们用“树剖分”维护 cnt 。
-
Q:为什么是“树剖分”?
A:因为我们不用求路径,所以不需要剖分链,如果像我一开始一样傻傻的记录重链的话,会 MLE 的。
我们只需要求出每个结点的子树大小,然后像树链剖分一样维护子树就行了。
查询的时候,暴力跳 ACAM ,查询线段树,把跳到结点的 cnt 计入答案,这部分的复杂度是 (O(logsum|T|)) 的。
修改的时候,调用线段树修改子树,这部分的复杂度是 (O(log sum |S|)) 的。
这里设 (sum |T|) 与 (sum |S|) 同数量级(实际上应该就是这样),总复杂度 (O(klogsum|S|)) 。
Code
const int maxn=1000005;
int n,k,tot;
char ch[maxn];
struct Trie{
int fail,cnt;
int ch[27];
}tr[maxn];
std::vector<int>to[maxn];
bool in[100005];//表示每个串现在是否在字符集里,防止非法操作
int ed[100005];
void Build(const int x){
int len=std::strlen(ch),u=0;
for(int i=0;i<len;++i){
int s=ch[i]-'a';
if(!tr[u].ch[s]) tr[u].ch[s]=++tot;
u=tr[u].ch[s];
}
tr[u].cnt++;
in[x]=1,ed[x]=u;
}
void make_fail(){
std::queue<int>q;
for(int i=0;i<26;++i)
if(tr[0].ch[i]){
tr[tr[0].ch[i]].fail=0;
to[0].push_back(tr[0].ch[i]);
q.push(tr[0].ch[i]);
}
while(q.size()){
int u=q.front();
q.pop();
for(int i=0;i<26;++i){
int v=tr[u].ch[i];
if(v){
tr[v].fail=tr[tr[u].fail].ch[i];
to[tr[v].fail].push_back(v);
q.push(v);
}else tr[u].ch[i]=tr[tr[u].fail].ch[i];
}
}
}
int AC_automaton();
int id[maxn],cnt,w[maxn],siz[maxn];
void dfs(const int x){
id[x]=++cnt;
siz[x]=1;
w[id[x]]=tr[x].cnt;
for(int i=0;i<to[x].size();++i){
int y=to[x][i];
dfs(y);
siz[x]+=siz[y];
}
}//只修改子树,只预处理dfs序和子树大小
struct segment_tree{
int l,r,sum,tag;
segment_tree *ls,*rs;
}*root,node[maxn<<1],*pool=node;
inline void update(segment_tree *x){ x->sum=x->ls->sum+x->rs->sum; }
inline void make_tag(segment_tree *x,const int w){ x->sum+=(x->r-x->l+1)*w,x->tag+=w; }
inline void push_down(segment_tree *x){ if(x->tag) make_tag(x->ls,x->tag),make_tag(x->rs,x->tag),x->tag=0; }
inline bool in_range(segment_tree *x,const int L,const int R){ return (L<=x->l)&&(x->r<=R); }
inline bool outof_range(segment_tree *x,const int L,const int R){ return (R<x->l)||(x->r<L); }
void modify(segment_tree *x,const int L,const int R,const int w){
if(in_range(x,L,R)) make_tag(x,w);
else if(!outof_range(x,L,R)){
push_down(x);
modify(x->ls,L,R,w);
modify(x->rs,L,R,w);
update(x);
}
}
int query(segment_tree *x,const int L,const int R){
if(in_range(x,L,R)) return x->sum;
if(outof_range(x,L,R)) return 0;
push_down(x);
return query(x->ls,L,R)+query(x->rs,L,R);
}
segment_tree *Build_tree(const int L,const int R){
segment_tree *u=++pool;
u->l=L,u->r=R,u->tag=0;
if(L==R){
u->sum=w[L];
u->ls=u->rs=NULL;
}else{
int Mid=(L+R)>>1;
u->ls=Build_tree(L,Mid);
u->rs=Build_tree(Mid+1,R);
update(u);
}
return u;
}
void get_cnt(const int x,const int f){
tr[x].cnt+=tr[f].cnt;
for(int i=0;i<to[x].size();++i){
int y=to[x][i];
get_cnt(y,x);//预处理初始 cnt
}
}
int AC_automaton(){//自动机yyds!
int len=std::strlen(ch),u=0,ans=0;
for(int i=0;i<len;++i){
// putchar(ch[i]);
int s=ch[i]-'a';
u=tr[u].ch[s];
ans+=query(root,id[u],id[u]);//查询这个节点到根的路径和(线段树单点查询)
}
return ans;
}
char get_opt(){
char opt=getchar();
while(opt!='?' && opt!='+' && opt!='-')
opt=getchar();
return opt;
}
signed main(){
read(n),read(k);
for(int i=1;i<=k;++i){
scanf("%s",ch),Build(i);
}
make_fail();
get_cnt(0,1000004);
dfs(0);
root=Build_tree(1,cnt);
while(n--){
int k_;
char opt=get_opt();
if(opt=='+'){
read(k_);
if(in[k_]==0){
in[k_]=1;
modify(root,id[ed[k_]],id[ed[k_]]+siz[ed[k_]]-1,1);
}
}else if(opt=='-'){
read(k_);
if(in[k_]){
in[k_]=0;
modify(root,id[ed[k_]],id[ed[k_]]+siz[ed[k_]]-1,-1);
}
}else{
scanf("%s",ch);
write(AC_automaton()),putchar('
');
}
}
return 0;
}
后记
如果您觉得这篇博客加深了您对 ACAM 的理解的话,是我莫大荣幸。
如果您能高台贵手,给我点个赞,那么博主感激不尽。
最后放一个 G 题的提交记录吧,遇到困难不要放弃,今日且与诸君共勉!