AC自动机 & 学习笔记

又一神秘字符串匹配算法。

前言

昨天晚上LZY发现自己的对拍有锅,急忙去修,最终成功将其倒退了一个版本。

介绍

AC 自动机是一种多模式串匹配算法,由 Alfred V. Aho 和 Margaret J.Corasick 发明,所以AC自动机的全名是:

\[\Large \mathcal{Aho–Corasick\space Automaton} \]

有A有C但没有AC

AC自动机的主要作用便是让多个模式串去匹配一个文本串
听起来有点像KMP?
确实用到了KMP的思想,此外也用到的Trie树,所以AC自动机是Trie树上的自动机
所以不会KMPTrie树的请掉头。
下面我们详细说明AC自动机的算法过程。

过程

回忆KMP

我们首先回忆一下你kmp是如何匹配的。
假设文本串为abacabababc,模式串为abab,假设预处理已完成,现在进行匹配。

首先我们很顺利的匹配了三个字符:

abacabababc
^^^!
abab

发现下一个字符匹配不上,KMP没有放弃所有已匹配的结果,既然已经匹配上了aba为什么不让a继续进行匹配呢?

abacabababc
  ^!
  ab

发现从a开始匹配还是匹配不上,于是只能从头开始了。

abacabababc
   !
   a

匹配不上,跳过!

abacabababc
    ^
    a
abacabababc
    ^^^^
    abab

成功匹上一个模式串!但模式串后面没有了,所以我们让模式串的公共前后缀ab继续匹配。

abacabababc
      ^^
      ab
abacabababc
      ^^^^
      abab

又成功匹配上一个模式串!后面就没有了,匹配完成。

可以发现KMP的思想就是能不省就不省,最大化利用已经匹配好的串继续向下匹配。

AC自动机上的匹配

AC自动机上的匹配与KMP的匹配大同小异,唯一不同的地方就是失配后的决策。

  • KMP在失配后会将当前最长公共前后缀移上来继续尝试匹配。
  • AC自动机在失配后会尝试通过去除已经匹配的部分的一段前缀继续尝试匹配。

失配决策不一样的原因就在于AC自动机处理的是多模式串匹配,在其中一个没匹配上的时候,AC自动机会尝试匹配别的串。
这样说好像有点不太对,举个例子就明白了:
有五个模式串:hershesheighterhterthought,与一个文本串:shersheighthoughter

shersheighthoughter
^^^
she
sheighter

首先shesheighter都匹配上了sheshe完成了匹配,但sheighter将在下一步失配。
于是考虑去除shes前缀。

hersheighthoughter
^^
her

取出后sheightershe由于没有开头的s无法继续匹配,但her可以继续尝试匹配。

hersheighthoughter
^^^
her

her匹配成功,由于无论尝试去除任何her的前缀对无法尝试新的匹配,所以直接去除整串整串也是前缀的一种

sheighthoughter
^^^
she
sheighter

she再次完成匹配,由于sheighter还可以继续尝试匹配,所以我们先不去除前缀。

sheighthoughter
^^^^^^^!
sheighter

sheighter无法继续匹配,尝试去除前缀sheig

hthoughter
^^
hter

去除后hter尝试匹配。

hthoughter
^^!
hter

匹配失败尝试去除前缀h

thoughter
^^
thought

去除后thought,尝试匹配。

thoughter
^^^^^^^
thought

匹配成功,尝试去除前缀thoug继续匹配。

hter
^^
hter

去除后hter尝试匹配。

hter
^^^^
hter

匹配成功,算法结束。
以上就是AC自动机多模式串匹配的步骤,先别说你会不会写,但你肯定大概理解了。

Trie树优化匹配

上面那一堆找了一堆前缀,找前缀这活得找“专业人士”Trie树来干。
我们将上面一堆模式串扔进一颗Trie树里,就会得到这样的东西:
字典树
怎么画出来这么一个玩意
我习惯将字符标到节点上。
我们都知道,Trie树上的每一个点都表示一个字符串,那么我们完全可以整一个失配指针(\(fail\))指向每一个节点失配后去除前缀后的后缀部分
就拿上面这一步举例:

sheighthoughter
^^^^^^^!
sheighter

sheighter无法继续匹配,尝试去除前缀sheig
我们完全可以将sheight节点的 \(fail\) 指向ht
失配
这样就可以快速找到下一个要匹配的串。

但说这么多,这个失配指针该这么指呢?
在Trie树中,每一个节点都有一堆指针,表示下一个字符,像sheigh这个节点,只有一个指向下一个节点sheigh的指针,为什么我们不尝试将sheigh指针在指向heht呢?
假设sheigh已经指向了heht,那么在找sheight的失配指针的时候,直接找它父亲所指向t的节点就找到了。
为什么可以让sheigh指向heht呢?
观察一下如果将sheigh的前缀扔掉,剩下h那么h这个字符串在往后接就可以接成heht也就是说sheigh的后缀与heht的前缀有公共部分,这样如果sheight这个t失配了,sheigh可以通过去除前缀到达ht这个串。
具体过程请看代码实现:

struct node{
	int son[26];                    //子节点指针
	int fail;                       //失配指针
	void init(){                    //初始化
		memset(son,0,sizeof(son));
		ans=fail=id=0;
	}
}T[NUM];
void insert(string s){              //插入一个节点,Trie树操作
	int u=0;
	for(char i:s){
		int &son=T[u].son[i-'a'];
		if(!son) son=++tot,T[son].init();
		u=son;
	}
}
void build(){                        //构建fail指针
	queue<int> q;                    
	for(int i=0;i<26;++i){
		if(T[0].son[i]) q.push(T[0].son[i]);  //先将跟的子节点入队
	}
	while(!q.empty()){
		int u=q.front();             //取出队首
		q.pop();
		for(int i=0;i<26;++i){
			if(T[u].son[i]){         //如果有这个子节点
				T[T[u].son[i]].fail=T[T[u].fail].son[i];  //子节点的fail就指向当前节点fail的对应节点。
				q.push(T[u].son[i]);
			}else{
				T[u].son[i]=T[T[u].fail].son[i];  //将子节点指向fail的对应子节点
			}
		}
	}
}

另附一张来自oi-wiki的图:

可以手摸一下理解其过程。

统计答案

先附上一张来自oi-wiki的图:

可以结合前面说过的AC自动机上的匹配理解其过程。
我们总不能在这些节点间跳来跳去把,这也太耗时间了。
我们可以发现,若只保留fail指针,那么剩余的图就是一颗树。
这是很显然的,所有节点的fail都指向比自身深度浅的节点,总共有节点数减一个加点,就是一棵树。
这样AC自动机上的问题就可以转移成子树求和的问题。

【模板】AC 自动机\(^{luoguP5357}\)

三倍经验题
子树求和问题可以使用拓扑排序或者树上dp(dfs),拓扑排序需要维护一下每个节点的度数。
下面使用拓扑排序过这道题

#include<bits/stdc++.h>
using namespace std;
const int NUM=2e5+10;
const int NUMM=2e6+10;

int n;
namespace AC{
	struct node{
		int son[26];
		int ans;
		int fail;
		int deg;
		int id;
		void init(){
			memset(son,0,sizeof(son));
			ans=fail=id=0;
		}
	}T[NUM];
	int tot;
	int ans[NUM],pid;
	void init(){
		tot=pid=0;
		T[0].init();
	}
	void insert(string s,int &idx){
		int u=0;
		for(char i:s){
			int &son=T[u].son[i-'a'];
			if(!son) son=++tot,T[son].init();
			u=son;
		}
		if(!T[u].id) T[u].id=++pid;
		idx=T[u].id;
	}
	void build(){
		queue<int> q;
		for(int i=0;i<26;++i){
			if(T[0].son[i]) q.push(T[0].son[i]);
		}
		while(!q.empty()){
			int u=q.front();
			q.pop();
			for(int i=0;i<26;++i){
				if(T[u].son[i]){
					T[T[u].son[i]].fail=T[T[u].fail].son[i];
					T[T[T[u].fail].son[i]].deg++; //记录度数
					q.push(T[u].son[i]);
				}else{
					T[u].son[i]=T[T[u].fail].son[i];
				}
			}
		}
	}
	void query(string t){
		int u=0;
		for(char i:t){
			u=T[u].son[i-'a'];
			T[u].ans++;
		}
	}
	void topu(){                      //拓扑排序
		queue<int> q;
		for(int i=0;i<=tot;++i){
			if(T[i].deg==0) q.push(i);
		}
		while(!q.empty()){
			int u=q.front();
			q.pop();
			ans[T[u].id]=T[u].ans;
			int v=T[u].fail;
			T[v].ans+=T[u].ans;
			if(!--T[v].deg) q.push(v);
		} 
	}
}
using namespace AC;

string s;
int idx[NUM];

int main(){
	init();
	cin>>n;
	for(int i=1;i<=n;++i){
		cin>>s;
		insert(s,idx[i]);
		ans[i]=0;
	}
	build();
	cin>>s;
	query(s);
	topu();
	for(int i=1;i<=n;++i){
		cout<<ans[idx[i]]<<'\n';
	}
	return 0;
}

不写DFS的原因:

  1. 我能力不足或是说我懒不想花时间去找根。
  2. 这篇其实是我贺的oi-wiki上的。

后记

AC自动机其实并不是很难,但出题人总是出一些奇奇怪怪的题。
反正就是不能让你好受。

posted @ 2026-07-29 22:05  LZYXT  阅读(7)  评论(0)    收藏  举报