Loading

AC自动机总结

AC自动机(AC automaton)

  • AC自动机,用于处理多模匹配的字符串算法。
  • 可以看作字典树trie和KMP的结合(一句对会AC自动机和不会的人都没用的话)

1.trie

每条边代表一个字符.
有一源点,该点到另外某一点的路径(路径也对应唯一结点)即构成一个字符串,且为曾经插入过trie的某一字符串的前缀.

2.AC自动机

对于trie上每一结点,建立一个指向代表其最长后缀的结点的指针,姑且称为失配(fail)指针. 假设已有一颗trie,接下来讨论如何弄出fail指针.
举个例子,如果有一个由A(代表字符串agriculture),指向B(代表字符串culture)的指针,则不难发现,A在trie上的父节点\(A^{\prime}\)(agricultur)也应指向B的父节点\(B^{\prime}\)(cultur).
由此,不难想到一个基于bfs的建立方式:

插入字符串&建立fail代码
//tot[MAXN]:以该结点结尾的模式串数
//tr[MAXN][26]: 边
void insert(char *s)//插入字符串建立trie
{
	int cur = 0;
	for (int i = 0; s[i]; i++) {
		if (!tr[cur][s[i] - 'a'])
		tr[cur][s[i] - 'a'] = ++cnt;
		cur = tr[cur][s[i] - 'a'];
	}
	tot[cur]++;
}
void build()//构建fail指针
{
	std::queue<int> q;
	memset(fail, 0, sizeof fail);
	for (int i = 0; i < 26; i++)
		if (tr[0][i])
			q.push(tr[0][i]);
	while (!q.empty()) {
		int x = q.front();
		q.pop();
		for (int i = 0; i < 26; i++)
			if (tr[x][i])
				fail[tr[x][i]] = tr[fail[x]][i], q.push(tr[x][i]);
			else//这部分会在下面提及
				tr[x][i] = tr[fail[x]][i];
	}
}

其中,第25行tr[x][i] = tr[fail[x]][i];令人疑惑.
但事实上,可以发现在23行中可能遇到问题:fail[x]并没有子节点i,这时候,就要继续查找fail[fail[x]]是否有子节点i……导致效率变低.
所以,类似于路径压缩并查集,便可以让没有子节点i的x也指向fail[x]的子节点i. 而且与此同时,在之后的匹配中我们也可以更加方便,而不需要到了叶子结点跳fail.

至此,AC自动机的基本操作————构建fail指针完成.

3.应用

I.多模匹配

洛谷P3808 AC 自动机 简单版
最基础的应用,直接在AC自动机上爬就行了. 当走到一个结点时,沿着它的fail一直跳,统计上它所有后缀的贡献,然后打上标记(因为一个模式串不能算多次),之后跳到标记就停止这样便可以保证复杂度为\(O(模式串总长)\).

匹配代码
int match(char *s)
{
	int cur = 0, res = 0;
	for (int i = 0; s[i]; i++) { 
		cur = tr[cur][s[i] - 'a'];
		for (int j = cur; j && ~tot[j]; j = fail[j])
			res += tot[j], tot[j] = -1;//直接把以这个结点结尾的模式串个数改为-1当作标记
	}
	return res;
}

II.拓扑排序优化

洛谷P5357 AC 自动机 二次加强版
类似于上面的匹配,每到一个结点时,就把它和通过fail能到的结点所对应字符串(如果是某个模式串结尾)的答案+1.
然鹅,上一题的复杂度保证,来源于通过标记使每个结点至多被访问一次,但在这题中没有了这样的保证(不能打标记),所以暴力跳fail会TLE(不然加强了个寂寞)
继续观察fail,发现每次fail,深度不然减少,所以如果把所有fail和结点单独抽出来,可以得到一张DAG.
所以,可以在爬trie中先将贡献记在一个结点,最后再通过拓扑排序将贡献传给所有通过fail能到达的结点.

主要代码
void topo()
{
	static std::queue<int> q;
	for (int i = 0; i <= cnt; i++)
		if (!inDeg[i])
			q.push(i);
	while (!q.empty()) {
		int x = q.front();
		ans[sid[x]] = rec[x];//统计答案,sid指结点对应字符串的编号
		q.pop();
		rec[fail[x]] += rec[x];
		if (!--inDeg[fail[x]])
			q.push(fail[x]);
	}
}
void match()
{//比较难看,边读入边处理了
	char ch = getchar();
	while (!(ch >= 'a' && ch <= 'z'))
		ch = getchar();
	int cur = 0;
	for (; ch >= 'a' && ch <= 'z'; ch = getchar())
		cur = tr[cur][ch - 'a'], rec[cur]++;
	topo();
}

III.fail树

洛谷P3966 [TJOI2013]单词
此题中,问题变成了考虑一个字符串在自己和其他字符串中总出现次数,即trie中的一个字符串为另外多少个字符串的子串.
对于字符串\(S\)\(T\),考察\(S\)\(T\)的子串的条件,可以发现子串即为“前缀的后缀”,而在AC自动机中:

  • 前缀:即\(T\)所代表结点与trie源结点的路径上某一结点
  • 后缀:即结点通过跳fail所能达到的所有结点
    再次考虑fail指针的性质,可以发现,所有结点fail指针都终将直接/间接指向源结点. 所以,若将所有fail反向,将得到一颗树,一般称为fail树.
    所以,此题中,不难发现对于一个字符串,答案即为其在fail树上的子树中字符串结尾的个数.
主要代码
struct AC_automaton
{
	int fail[MAXN], tr[MAXN][26], siz[MAXN], tot = 0, q[MAXN];
	int insert(char *s) 
	{//插入一个字符串
		int cur = 0;
		for (int i = 0; s[i]; i++) {
			if (!tr[cur][s[i] - 'a'])
				tr[cur][s[i] - 'a'] = ++tot;
			cur = tr[cur][s[i] - 'a'];
			siz[cur]++; 
		}
		return cur;
	}
	void getFail()
	{//构建fail指针
		int fr = 1, ba = 0;
		for (int i = 0; i < 26; i++)
			if (tr[0][i])
				q[++ba] = tr[0][i];
		while (fr <= ba) {
			int x = q[fr++];
			for (int i = 0; i < 26; i++)
				if (tr[x][i])
					fail[tr[x][i]] = tr[fail[x]][i], q[++ba] = tr[x][i];
				else
					tr[x][i] = tr[fail[x]][i];
		}
	}
	void calc()
	{//统计答案
		for (int i = tot; i; i--)
			siz[fail[q[i]]] += siz[q[i]];
		for (int i = 1; i <= n; i++)
			printf("%d\n", siz[a[i]]);
	}
} acam;

另一题 洛谷P2414 [NOI2011] 阿狸的打字机 也是用fail树,只要再用数据结构(比如树状数组)简单维护一下子树信息即可.


IV.DP

洛谷P4052 [JSOI2007]文本生成器
考虑反面情况,即总数减去不包含可读串的个数.

  • 总数:\(26^m\)
  • 不包含可读串:记\(dp[i][j]\)为串长为\(i\),当前在AC自动机编号为\(j\)的结点,则结果为\(\max_{i = 0}^{tot}\{{dp[i][j]}\}\)
    考虑\(dp[i][j]\)能转移到的状态,可以发现,对与下一状态\((i + 1, tr[j][k])(0\leq k\lt26)\)能转移当且仅当加上第k个字母时不会出现某一单词的结尾,即\(tr[j][k]\)不能经过fail跳到一字符串的结尾结点,而这可以预处理.
    所以,对于所有这样合法的\(k\),都可以\(dp[i + 1][tr[j][k]] += dp[i][j]\).
  • 最终结果为 \(26^m-\max_{i = 0}^{tot}\{{dp[i][j]}\}\).
代码
#include <bits/stdc++.h>
const int MAXN = 5005, MOD = 10007;
int n, m;
char s[105];
int fpw(int x, int y) {
	int res = 1;
	for (; y; y >>= 1, x = x * x % MOD)
		if (y & 1)
			res = res * x % MOD;
	return res;
}
inline int fplus(int x, int y)
{ return (x + y) >= MOD ? x + y - MOD : x + y; }
inline int fminus(int x, int y)
{ return x >= y ? x - y : x - y + MOD;}
struct AC_automaton
{
	int fail[MAXN], tr[MAXN][26], tot;
	bool ending[MAXN];
	void insert(char *s) 
	{
		int cur = 0;
		for (int i = 0, u; s[i]; i++) {
		    u = s[i] - 'A';
			if (!tr[cur][u])
				tr[cur][u] = ++tot;
			cur = tr[cur][u];
		}
		ending[cur] = true;
	}
	void getFail()
	{
		static std::queue<int> q;
		for (int i = 0; i < 26; i++)
			if (tr[0][i])
				q.push(tr[0][i]);
		while (!q.empty()) {
			int x = q.front();
			q.pop(), ending[x] |= ending[fail[x]];
			for (int i = 0; i < 26; i++)
				if (tr[x][i])
					fail[tr[x][i]] = tr[fail[x]][i], q.push(tr[x][i]);
				else
					tr[x][i] = tr[fail[x]][i];
		}
	}
} ac;
int dp[105][MAXN];//dp[i][j]: 结点j 长度i 的不合法方案数
int main()
{
	scanf("%d %d", &n, &m);
	for (int i = 1; i <= n; i++)
		scanf("%s", s), ac.insert(s);
	ac.getFail();
	dp[0][0] = 1;
	for (int i = 0; i < m; i++)
		for (int j = 0; j <= ac.tot; j++)
			if (dp[i][j])
				for (int k = 0; k < 26; k++)
					if (!ac.ending[ac.tr[j][k]])
						dp[i + 1][ac.tr[j][k]] = fplus(dp[i + 1][ac.tr[j][k]], dp[i][j]);
	int ans = fpw(26, m);
	for (int i = 0; i <= ac.tot; i++)
		ans = fminus(ans, dp[m][i]);
	printf("%d\n", ans);
	return 0;
}

另一题洛谷P3311 [SDOI2014] 数数 类似,是在AC自动机上进行数位dp.

posted @ 2022-05-06 21:33  complexor  阅读(81)  评论(0编辑  收藏  举报