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 Link.\)

初始思路仍然是在 \(\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\)

\[\sum_{i=1}^{\log n}f(2^i)\cdot \frac{n}{2^{i+1}}=\sum_{i=1}^{\log n}13n=n\log^2 n \]

总时间复杂度 \(\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!!!
posted on 2020-03-26 16:31  Oxide  阅读(13)  评论(0编辑  收藏  举报