AC 自动机

前言

\(\text{AC}\) 自动机(\(\text{Aho-Corasick Automaton}\))在一定程度上可以看作 \(\text{KMP}\) 的升级版,\(\text{KMP}\) 是单模匹配算法,在一个文本串中查找一个模式串;\(\text{AC}\) 自动机是多模匹配算法,在一个文本串中同时查找多个不同的模式串。

多模匹配问题:给定一个长度为 \(n\) 的文本 \(S\),以及 \(k\) 个平均长度为 \(m\) 的模式串 \(P_1\)\(P_2\),···,\(P_k\),要求搜索这些模式串出现的位置。

在解析 \(\text{AC}\) 自动机之前,先思考如何用暴力法解决多模匹配问题。

用暴力法求解多模匹配问题,就是对每个 \(P_1\)\(P_2\),···,\(P_k\) 分别在 \(S\) 上做一次匹配。可以用 \(\text{KMP}\) 对每个 \(P\) 分别做一次,总复杂度为 \(O((n+m)k)\)

这种方法的效率很低,问题出在需要按部就班地一 \(S\) 的每个字符为开头检查是否匹配每个 \(P\),指向 \(S\)\(i\) 指针发生了多次回溯。

在文本串 \(S=\)abcde 中匹配了 \(3\) 个模式串 \(P_1=\) abcd\(P_2=\)b\(P_3=\)cd。若一个个地匹配,第 \(1\) 次匹配了 \(S_0\)~\(S_3\)abcd\(i\) 移动到了 \(3\) 位置;第 \(2\) 次从 \(S_1\) 开始匹配,\(i\) 需要回溯到 \(1\) 位置,匹配了 \(P_2\) 等等。

暴力法是低效的,对于这种复杂的多模匹配问题,需要用复杂的算法 \(\text{AC}\) 自动机来解决。

正文

用一句话概括 \(\text{AC}\) 自动机的思想:

\[\text{AC 自动机 = 用字典树组织多个模式串 + KMP 避免回溯} \]

有没有优化的方法,是 \(i\) 不用回溯?若能在 \(i\) 移动过程中同时匹配多个 \(P\),就能避免回溯 \(i\)。例如 \(i=1\) 时,同时匹配了 \(P_1\)\(P_2\)

考虑一下两个问题:

  1. 如何同时处理多个串?字典树能高效的处理多个字符串,可以应用在这里。先把 \(k\)\(P\) 建成一个普通的字典树,然后把 \(S\) 看作很多单词的组合体,在字典树上找这些单词。
    在字典树上匹配多个 \(P\) 的最简单方法是:从 \(S_1\) 开始,到字典树上找以 \(S_0\) 开头的模式串,无论是否找到以 \(S_1\) 开头的模式串,下一步再从 \(S_2\) 开始,找以 \(S_2\) 开头的模式串;下一步 \(S_3\) 开始,一直到 \(S_n\)。共找 \(n\) 次,每次平均匹配 \(m\) 个字符,总复杂度为 \(O(nm)\) ,和简单地在 \(S\) 上做 \(k\)\(\text{KMP}\) 差不多。
  2. 如何避免回溯 \(S\)\(i\) 指针?回忆 \(\text{KMP}\) 加快匹配的思路,\(\text{KMP}\) 的根本思想是利用 \(\text{Next}\) 数组避免回溯 \(S\)\(i\) 指针。那么在字典树上,是否能有一个类似 \(\text{Next}\) 数组的数据,避免回溯 \(i\) 指针?

以上两点就是 \(\text{AC}\) 自动机算法的思路。

单模匹配 暴力法:\(S\) 的 \(i\) 指针需要回溯 \(\text{KMP}\):用基于模式串的 \(\text{Next}\) 数组避免 \(i\) 回溯
多模匹配 暴力法:\(S\) 的 \(i\) 指针需要回溯 \(\text{AC}\) 自动机:用基于字典树的 \(\text{Fail}\) 指针避免 \(i\) 回溯

\(\text{AC}\) 自动机的构造

首先把模式串 abcd b cd 建成一棵字典树。

模拟 \(S=\)abcde 在字典树上的匹配过程:

  1. 从字符 a 出发,匹配到 \(2\) 号点 b 时,查询到它的 \(\text{Fail}\) 指针指向 \(5\) 号点的 b,且 \(5\) 号点是一个终点,则找到了一个匹配 \(P_2=\)bab 的一个后缀。

  2. 继续匹配到 \(3\) 号点 c,它的 \(\text{Fail}\) 指针指向 \(6\) 号点 c,但 \(6\) 号点不是一个终点。c 也是 abc 的一个后缀。

  3. 匹配到 \(4\) 号点 d\(4\) 号点自己是终点,找到了一个匹配 \(P_1=\)abcd。另外, \(4\) 号点的 \(\text{Fail}\) 指针指向 \(7\) 号点,且 \(7\) 号点是一个终点,找到了一个匹配 \(P_3=\)cdcdabcd 的一个后缀。

以上匹配过程,只做了 \(5\) 次比较操作。

事实上,每一个节点都有一个 \(\text{Fail}\) 指针,若上层没有同字符是,\(\text{Fail}\) 指向根节点。

若上层的同字符节点不在后缀中,则无法完成同时匹配多个模式串的功能。

\(\text{Fail}\) 指针的计算

如何编码求得 \(\text{Fail}\) 指针?每个节点都有 \(\text{Fail}\) 指针,而指针指向上层的某个节点,这显然是一个 \(\text{BFS}\) 过程:求得一层所有节点的 \(\text{Fail}\) 指针后,在继续求下一层所有节点的 \(\text{Fail}\) 指针。

一个节点 \(x\)\(\text{Fail}\) 指针指向的节点是父节点的 \(\text{Fail}\) 指针所指向的节点的与 \(x\) 节点同字符的子节点。通过这样的赋值,\(x\) 得到了这个同字符节点的后缀关系。

然而有时候父节点的 \(\text{Fail}\) 指针所指向的节点并没有一个与 \(x\) 节点同字符的子节点。这里用到一个关键技巧:在处理父节点的 \(\text{Fail}\) 指针指向的节点时,虚拟一个子节点直接等同于与该点同层的与 \(x\) 节点的同字符点,此时,\(x\) 点的父节点的 \(\text{Fail}\) 指针指向的节点就有了一个与 \(x\) 同字符的子节点,而这个子节点就是与该子节点的父节点同层的与 \(x\) 节点的同字符点。最终的结果是 \(x\) 节点的 \(\text{Fail}\) 指针指向与 \(x\) 点的父节点 \(Fail\) 指针指向的节点同层的与 \(x\) 同字符的节点,实现了跨层计算。

\(\text{AC}\) 自动机的复杂度

\(k\) 个模式串,平均长度为 \(m\);文本串长度为 \(n\)。建立 \(Trie\) 树复杂度为 \(O(km)\);求 \(km\) 个节点的 \(\text{Fail}\) 指针,复杂度为 \(O(km)\);模式匹配复杂度为 \(O(nm)\),乘以 \(m\) 的原因是 \(S\) 的每个字符找 \(Fail\) 可能需要检查 \(m\) 个模式串,如 \(4\) 个模式串 abcd bcd cd d 匹配到 d 时,\(\text{Fail}\) 指针需要操作 \(4\) 次。总时间复杂度为 \(O(km+km+nm)=O(km+nm)\),看起来似乎仍然很高。不过模式匹配的效率与模式串的特征有关,一般情况下 \(O(nm)\) 接近于 \(O(n)\)。只有在恶劣的情况下,即 \(k\) 个模式串是上述例子这种奇怪的后缀包含关系,此时复杂度才退化到了 \(O(nm)\)

实现

\(\text{AC}\) 自动机的代码并不长,请对照前面的解释仔细理解:

#include<bits/stdc++.h>
#define int long long
#define endl '\n'
using namespace std;
typedef pair<int,int> pii;
const int N=1e6+10;
const int M=1e3+10;
const int inf=1e17;
const int mod=1e9+7;
struct node{
    int son[26];
    int end;
    int fail;
}; 
int m;
node t[N];
queue<int> q;
void insert(string s){
    int now=0;
    for(int i=0;i<s.size();i++){
        int ch=s[i]-'a';
        if(t[now].son[ch]==0){
            t[now].son[ch]=++m;
        }
        now=t[now].son[ch];
    }
    t[now].end++;
    return ;
}
void get_fail(){
    for(int i=0;i<26;i++){
        if(t[0].son[i]){
            q.push(t[0].son[i]);
        }
    }
    while(!q.empty()){
        int now=q.front();
        q.pop();
        for(int i=0;i<26;i++){
            if(t[now].son[i]){
                t[t[now].son[i]].fail=t[t[now].fail].son[i];
                q.push(t[now].son[i]);
            }else{
                t[now].son[i]=t[t[now].fail].son[i];
            }
        }
    }
    return ;
}
int query(string s){
    int ans=0;
    int now=0;
    for(int i=0;i<s.size();i++){
        int ch=s[i]-'a';
        now=t[now].son[ch];
        int tmp=now;
        while(tmp&&t[tmp].end!=-1){
            ans+=t[tmp].end;
            t[tmp].end=-1;
            tmp=t[tmp].fail;
        }
    }
    return ans;
}
int n;
string s;
signed main(){
    ios::sync_with_stdio(false);
    cin.tie(0),cout.tie(0);
    cin>>n;
    m=1;
    for(int i=1;i<=n;i++){
        string x;
        cin>>x;
        insert(x);
    }
    get_fail();
    cin>>s;
    cout<<query(s)<<endl;
    return 0; 
}

注释版:

#include<bits/stdc++.h>
#define int long long
#define endl '\n'
using namespace std;
typedef pair<int,int> pii;
const int N=1e6+10;
const int M=1e3+10;
const int inf=1e17;
const int mod=1e9+7;
struct node{
	int son[26];								//26个字母 
	int end;									//字符串结尾标记 
	int fail;									//失配指针 
}; 
int m;											//新分配的存储空间 
node t[N];										//Trie[],字典树 
queue<int> q;
void insert(string s){							//在字典树上插入单词 s  
	int now=0;									//字典树上当前匹配到的节点,从 root=0 开始 
	for(int i=0;i<s.size();i++){				//逐一在字典树上查找 s[] 的每个字符 
		int ch=s[i]-'a';
		if(t[now].son[ch]==0){					//如果这个字符还没有存过 
			t[now].son[ch]=++m;					//把 m 位置分配给这个字符 
		}
		now=t[now].son[ch];						//沿着字典树往下走 
	}
	t[now].end++;								//end>0,是字符串的结尾,同时表示个数 
	return ;
}
void get_fail(){								//用 BFS 构建每个节点的 Fail 指针 
	for(int i=0;i<26;i++){						//把第 1 层入队,即 root 的子节点 
		if(t[0].son[i]){						//这个位置有字符 
			q.push(t[0].son[i]);
		}
	}
	while(!q.empty()){
		int now=q.front();				//队首的 Fail 指针以求得,下面求他孩子的 Fail 指针 
		q.pop();
		for(int i=0;i<26;i++){					//遍历 now 的所有孩子 
			if(t[now].son[i]){					//若这个位置有字符 
				t[t[now].son[i]].fail=t[t[now].fail].son[i];
				//这个孩子的 Fail=“父节点的 Fail 指针所指向的节点的与 x 同字符的子节点” 
				q.push(t[now].son[i]);			//这个孩子入队,后面在处理它的孩子 
			}else{								//若这个位置无字符 
				t[now].son[i]=t[t[now].fail].son[i];
												//虚拟节点,由于底层的 Fail 指针计算 
			}
		}
	}
	return ;
}
int query(string s){							//在文本串 s 中找有多少个 模式串 
	int ans=0;
	int now=0;									//从 root=0 开始找 
	for(int i=0;i<s.size();i++){				//对文本串进行遍历 
		int ch=s[i]-'a';
		now=t[now].son[ch];
		int tmp=now;
		while(tmp&&t[tmp].end!=-1){				//利用 Fail 指针找出所有匹配的模式串 
			ans+=t[tmp].end;					//累加到答案中,若不是结尾,end=0 
			t[tmp].end=-1;			//以这个字符为结尾的模式串已经统计,后面不再统计 
			tmp=t[tmp].fail;		//Fail 指针跳转 
		}
	}
	return ans;
}
int n;
string s;
signed main(){
	ios::sync_with_stdio(false);
	cin.tie(0),cout.tie(0);
	cin>>n;
	m=1;										//把 m=0 留给 root 
	for(int i=1;i<=n;i++){
		string x;								//输入模式串 
		cin>>x;
		insert(x);								//插入到字典树中 
	}
	get_fail();									//计算字典树上每个结点的 Fail 指针 
	cin>>s;
	cout<<query(s)<<endl;
	return 0; 
}
posted @ 2026-06-13 13:22  fede  阅读(3)  评论(0)    收藏  举报
联系我们