aho-corasick automaton AC自动机的理解
最近在学编译原理, 里面在3.3节词法单元识别后面就提到了这个算法然后根据网上资料自己做了一遍,只支持英文字母
可能最后的效果没有oi-wiki上的效率高
1. 背景
大概涉及到的知识:
- Trie 树,一种字典树,可以看这里做的挺直观的
- BFS 广度优先搜索Trie树
- 状态压缩,随便做的小优化,可能有负效果对于数据量小(x
主要思想就是用int/long这种数据类型的每一位通过位运算当作bool而不是单独声明bool类型, 主要的算法就(n>>k)&1
取出n的第k位的数据n^(1<<k)
n的第k位取反
- AC自动机的失配(失效)算法
2. 思想
主要流程就, 先构造一棵trie树,然后用bfs构造每个节点的失配位置(最后效果就类似于DFA确定有限状态自动机),然后再遍历以匹配出结果
3. 代码
3.1 Trie树
比较简易的做法就是
struct Node{
Node* next[26];
bool end;
};
用数组的下标表示对应边(anscii, 比如 char - 'a'
)和对应的下一个节点
这里优化一下, 不然内存地址太分散然后其实这个数据结构不是很有必要
所以本文用一个二维数组代替这一套(nodes[x][y]
x是节点的编号,y是y+'a'
的边指向的节点 0<=y<=25, 比如a的边就是'a'-'a'=0
), 具象化表示就参考oi-wiki
constexpr int s = 50;
int nodes[s][26]{
0};
int now = 1;
bool end[s];
s
是数组大小,因为后面状态压缩的时候还要用到就提取出来,作用和#define
一样end
是代表对应下标的节点是不是单词的结尾(判断匹配是否成功)now
是下一个节点应该是哪个下标
然后就是写add()
或者insert
方法
void trieAdd(const std::string &text) {
// 插入单词
int p = 0; // 当前插入到哪个节点, 一开始从根节点开始
for (char each: text) {
// 循环每个字符
if (nodes[p][each - 'a'] == 0) // 如果没有对应的字符节点就新建一个
nodes[p][each - 'a'] = now++; // 存入对应字符节点的下标
p = nodes[p][each - 'a']; // 把目前插入到的节点指向到对应字符的子节点
}
end[p] = true; // 单词结尾
}
3.1.1 优化endNodes
这里做了一个可有可无的优化, 就把代表end节点从一个bool数组改成一个int32_t数组, 然后用32位中每一位表示一个节点是否为接受节点(0或1)
为了使数据方便取余, 最好用2的整次方为数位长度(如8, 16, 32等)
constexpr int s = 50;
int nodes[s][26]{
0};
int now = 1;
constexpr int bitW = 32; // 必须是2的n次方
int32_t endNodes[s / bitW + (s % bitW > 0 ? 1 : 0)]{
0}; // 该下标对应的节点是否为单词的结尾
所以上面的声明代码就变成这样, 和add方法
void trieAdd(const std::string &text) {
// 插入单词
int p = 0; // 当前插入到哪个节点, 一开始从根节点开始
for (char each: text) {
// 循环每个字符
if (nodes[p][each - 'a'] == 0) // 如果没有对应的字符节点就新建一个
nodes[p][each - 'a'] = now++; // 存入对应字符节点的下标
p = nodes[p][each - 'a']; // 把目前插入到的节点指向到对应字符的子节点
}
endNodes[p / bitW] = endNodes[p / bitW] ^ (1 << (p & (bitW - 1))); // 表明该节点是单词的结尾, endNodes[p/bitW]^(1<<n)是对n位取反, 后面的p&63为:当取余的除数为2^n时,可以用p&(k-1)代替,也就是p&63
}
然后当要去第p个节点是否是接受(结束)节点时:
if ((endNodes[p / bitW] >> (p & (bitW - 1))) & 1)
3.2 失配算法
主要就先准备一个fail数组(在这里我设立的是从1开始, 就不用提前赋值全部元素为-1, 因为元素可以是0), 下标代表对应的节点失配后跳转到哪个节点
然后用一个队列(queue)确保BFS因为先入先出
然后就循环每一个节点和子节点找失配位置
具体流程:
先从根节点开始 -> 依循bfs也就是宽度(广度)优先顺序搜索每个子节点->先遍历每个子节点的每条边->当边不为空(指向的子节点!=0, 因为边是不可能指向根节点), 对于每个子节点的边有3种情况:
- 如果父节点是0也就是根节点, 那当前边指向的子节点的失配位置就是根节点也就是0
- 如果父节点是失配位置有当前边, 那当前边指向的子节点的失配位置就是父节点的失配位置
- 如果以上都不是, 把父节点的失配位置看作这条边的父节点然后继续上面的流程知道父节点是根节点
执行上面的流程找到失配位置后把当前边对应的子节点压入队列然后开始下一条边
int fail[s]{
0};
void trieFail() {
// 失配跳转配置
int n; // now, 也就是当前节点的下标
queue<int> ns; // 待处理的节点下标
ns.push(0); // 一开始从根节点开始
while (true) {
if (ns.empty()) break; // 如果没有待处理的就推出
n = ns.front(); // 取出最上面待处理的并弹出
ns.pop();
for (int i = 0; i < 26; i++) {
// 循环该节点下全部子节点
if (nodes[n][i] != 0) {
{
// 求失配跳转位置
int pp