1. \(\text{ac}\) 自动姬
因为这是一篇水文,所以直接:传送门。
1.1. 模板
-
给定文本串 \(T\),求集合 \(S\) 中有多少字符串在 \(T\) 中出现。
-
传送门。复杂度 \(\mathcal O(n\cdot |\sum|)\).
一个 \(\rm detail\):在 GetFail()
中不能直接将 \(0\) 压进队列,否则你会发现 \(0\) 的儿子的 \(\rm fail\) 变成了它本身,就会出现环的情况。
void Insert() {
int p=0,len=strlen(s+1);
rep(i,1,len) {
int d=s[i]-'a';
if(!t[p][d]) t[p][d]=++tot;
p=t[p][d];
}
++cnt[p];
}
void GetFail() {
rep(i,0,25)
if(t[0][i]) q.push(t[0][i]),fa[t[0][i]]=0;
while(!q.empty()) {
int u=q.front(); q.pop();
rep(i,0,25)
if(t[u][i]) fa[t[u][i]]=t[fa[u]][i],q.push(t[u][i]);
else t[u][i]=t[fa[u]][i];
}
}
int Query() {
int len=strlen(s+1),p=0,ans=0;
rep(i,1,len) {
int d=s[i]-'a';
for(int j=t[p][d];j&&cnt[j]!=-1;j=fa[j]) ans+=cnt[j],cnt[j]=-1;
// 每个节点只用访问一次
p=t[p][d];
}
return ans;
}
int main() {
n=read(9);
rep(i,1,n) {
scanf("%s",s+1);
Insert();
}
GetFail();
scanf("%s",s+1);
print(Query(),'\n');
return 0;
}
1.2. 优化
1.2.1. 拓扑排序
给定文本串 \(T\),求集合 \(S\) 中字符串在 \(T\) 中出现次数。
初始思路仍然是在 \(\rm ac\) 自动机上跑 \(T\),然后在得到的点上跳 \(\rm fail\). 但这题有个致命的算次数的问题,也就是说跳到已遍历的点上还要继续跳,这显然就不能保证复杂度了,最劣的情况是 \(\mathcal O(dL)\),其中 \(d\) 是模式串最长长度,\(L\) 是文本串长度。
优化的思路是延迟上传,如果能保证每个点只被更新一次,那么复杂度仍然是正确的。因为是一个 \(\rm dag\),那就直接用拓扑排序即可。
int n,In[Siz],t[Siz][26],to[Siz],co[Siz],tot,fa[Siz],ans[Siz],res[Siz];
queue <int> q;
char s[maxl];
void Insert(int now) {
int p=0,len=strlen(s+1);
rep(i,1,len) {
int d=s[i]-'a';
if(!t[p][d]) t[p][d]=++tot;
p=t[p][d];
}
if(!to[p]) to[p]=now;
co[now]=to[p];
}
void GetFail() {
rep(i,0,25)
if(t[0][i]) q.push(t[0][i]),fa[t[0][i]]=0;
while(!q.empty()) {
int u=q.front(); q.pop();
rep(i,0,25)
if(t[u][i]) fa[t[u][i]]=t[fa[u]][i],q.push(t[u][i]),++In[t[fa[u]][i]];
else t[u][i]=t[fa[u]][i];
}
}
void topol() {
rep(i,1,tot) if(!In[i]) q.push(i);
while(!q.empty()) {
int u=q.front(); q.pop();
ans[to[u]]=res[u];
int v=fa[u];
--In[v]; res[v]+=res[u];
if(!In[v]) q.push(v);
}
}
void Query() {
int p=0,len=strlen(s+1);
rep(i,1,len) p=t[p][s[i]-'a'],++res[p];
}
int main() {
n=read(9);
rep(i,1,n) {
scanf("%s",s+1);
Insert(i);
}
GetFail();
scanf("%s",s+1);
Query();
topol();
rep(i,1,n) print(ans[co[i]],'\n');
return 0;
}
1.3. 例题
例 1.
\(\text{CF856B Similar Words}\)
首先建出一棵 \(\rm trie\) 树,那么有且只有上面的节点表示的前缀可以被选择。现在考虑 "去掉首字母相同" 的条件,发现这很像 \(\rm ac\) 自动姬上节点与 \(\rm fail\) 指针的关系,将这两个点连边,由于连接时保证深度至少减一,所以不会出现环。最后跑一边儿子父亲不能同时选的树形 \(\mathtt{dp}\) 即可。
例 2.
\(\text{[COCI 2015] Divljak}\)
首先肯定是对 \(S\) 建立 \(\text{ac}\) 自动机。对于每一个询问 \(P\),用它在自动机里跑的每一个点为末尾的后缀都是可行的。另外插一嘴,你会发现这题就是 1.2.1. 拓扑排序
那道题加上了多个 \(T\).
考虑我们最基础的问题是 "给定文本串 \(T\),求集合 \(S\) 中字符串在 \(T\) 中出现次数",所以我们不妨这样思考:每次新插入一个 \(P\) 就在某个结构上保存集合 \(S\) 中字符串在 \(T\) 中是否出现,查询 \(\text{S}_x\) 就可以直接在这个结构上查询。
所以建出 \(\text{fail}\) 树(注意建树的时候最好用 for
来建),考虑 \(P\) 包含的字符串就相当是许多个点(在 \(\rm ac\) 自动机上跑出点)到根的链的并。这就相当于一个 "路径加" + "单点求值" 的问题,这个可以在 \(\text{dfs}\) 序上差分,转化成 "单点加" + "子树求和":将某点在树状数组上修改后,如果 \(x\) 包含这个点,就可以在 \([\text{dfn}_x,\text{dfn}_x+\text{siz}_x-1]\) 这个区间找到它。
问题是可能会算重。将所有点按 \(\text{dfs}\) 序排序之后,减去相邻两点的 \(\text{lca}\) 的贡献即可。
例 3.
\(\text{[SCOI 2012] }\)喵星球上的点名
第一问和 \(\text{[COCI 2015] Divljak}\) 是一毛一样的,第二问看似是经典问题,但是由于每个点不能保证只遍历一次,所以复杂度无法保证。但是有了上题的铺垫,我们可以将其转化成 "子树加" + "单点求和" 的问题,不过还是要减去相邻两点的 \(\rm lca\).
不过本题最重要的一点是字符集大小为 \(10^4\),这显然是开不下的,我们用 \(\text{unordered_map}\) 来存储实边。但这仍然面临一个问题:在朴素的 getFail()
中,如果点 \(u\) 没有边权为 \(c\) 到达的儿子,就直接赋值成点 \(u\) 的 \(\rm fail\) 的边权为 \(c\) 到达的儿子,以避免求解实儿子的 \(\rm fail\) 时,我们要一层一层地去跳 \(\rm fail\). 但现在我们只能存下实儿子,这个优化就不能使用了。咋办捏?其实暴力跳 \(\rm fail\) 的时间复杂度是正确的,为 \(\mathcal O(L)\),其中 \(L\) 为插入 \(\rm ac\) 自动机的串的总长。
考虑为节点 \(u\) 定义势能函数 \(h(u)=\text{dep}_{\text{fail}_u}\),那么对于 \(u\) 的实儿子 \(v\),我们有一个神奇的限制:\(h(v)\le h(u)+1\). 证明是简单的,考虑 \(\text{fail}_v\) 深度最深的情况也就是跳到 \(\text{fail}_u\) 时发现其有边权为 \(c\) 到达的儿子,就直接接上去。
接下来,我们先考虑一条 由实边 构成的链上的点暴力跳 \(\rm fail\) 的复杂度。从 \(u\) 走到 \(v\) 势能至多加一,如果 \(v\) 跳一次 \(\rm fail\)(可能比较粗略),那么势能至少减一。所以暴力跳 \(\rm fail\) 的复杂度为这条链的长度。
现在我们再考虑树形结构,其实你可以将每个叶子节点到根的链抽离出来,这条链的复杂度已经被证明是这条链的长度,而这个长度也就是插入串的长度。那么所有叶子节点加起来就是插入 \(\rm ac\) 自动机的串的总长。
另外需要特别注意一下暴力跳的写法,不要把 if(!u) return 0;
写在最前面。
int getFail(int u,int c) {
if(t[u].to.count(c))
return t[u].to[c];
if(!u) return 0;
return t[u].to[c]=getFail(t[u].fa,c);
}
例 4.
\(\text{CF696D Legen...}\)
先考虑依次在一个串上添加字母,我们该如何计算增加的贡献。显然就是增加字母形成的后缀中模式串的数量。这个东西可以通过 \(\rm fail\) 递推得到。
但是字符串长度有 \(10^{14}\),所以可以矩阵加速。令 \(dp_{i,j}\) 为以 \(i\) 为起点,\(j\) 为终点形成的字符串中最大的开心度,相当于将矩乘的加法改成 \(\max\),乘法改成加法。
需要注意的是矩阵初始值要赋值为极小值,不然会出现 \(i\rightarrow j\) 但实际上不能转移的情况。还有就是单位矩阵的初值也需要注意。
例 5.
\(\text{CF86C Genetic engineering}\)
对每个点找过此点的模式串是不易的,我们不妨只考虑用模式串的末尾字符 "覆盖" 在此之前未被覆盖的字符。
那么对于当前构造出 \(\rm dna\) 序列的末尾字符的贡献,实际上是拿这个序列在 \(\rm ac\) 自动机上跑到的节点 \(u\) 以及 \(u\) 能跳到的所有 \(\rm fail\) 中的最长覆盖长度。考虑 \(u\) 一定代表 \(\rm dna\) 序列的最长后缀。
令 \(dp_{i,j,k}\) 为构造到第 \(i\) 位,有 \(j\) 位未被覆盖,跑到节点 \(k\) 的序列之中满足条件的个数。转移长这样:
if(val[to]>j) add(dp[i+1][0][to],dp[i][j][k]);
else add(dp[i+1][j+1][to],dp[i][j][k]);
很妙的一点是如果不能覆盖完,就干脆不要覆盖了。
例 6.
\(\text{CF710F String Set Queries}\)
首先可以将删除转化为减去删除字符串的贡献,于是维护两类 \(\rm ac\) 自动机,直接二进制分组即可。代码:\(\text{Link.}\)
询问、插入字符串(不建 \(\rm ac\) 自动机)都是一只 \(\log\) 的。最主要的还是分析 reset()
函数复杂度,将其表示为 \(f(2^i)=26\cdot \sum_{j=0}^{i-1}2^j\approx 26\cdot 2^i\).
枚举每个 \(2\) 的幂作为 \(\rm lowbit\):
总时间复杂度 \(\mathcal O(n\log^2 n)\).
注意多个根节点时 getFail()
中的初始化:
for(int i=0;i<26;++i)
if(t[rt][i]) q.push(t[rt][i]), fail[t[rt][i]]=rt;
else t[rt][i]=rt; // important!!!