算法学习笔记(20): AC自动机

算法,学习,笔记,ac,自动机 · 浏览次数 : 140

小编点评

**代码** ```cpp #include #include #include #include #include using namespace std; void insert(string &s, int i) { int p = 0; for (int c : s) { if (!kids[p][(c -= 'a')]) kids[p][c] = newNode(); p = kids[p][c]; } pos[i] = p; } void ACOutput(int n) { for (int i = 1; i < n; ++i) { cout << cnt[pos[i]] << '\'; } } void ACprepare(int * q) { for (int i = 1; i < usage; ++i) { len[q[i]] = max(len[q[i]], len[fail[q[i]]]); } } void ACclean(string &s) { int p = 0; for (unsigned i(1, r); i <= L; ++i) { if (s[i - 1] == T[i - 1]) { p = i - 1; break; } } if (p) { s[i - 1] = '\0'; } } void solve() { int T = sqrt(L); if (T <= L) { map M; for (int i = 1; i <= L; ++i) { M[i][i] = T[i]; } for (int i = 1; i <= L; ++i) { int J = i * (i - 1); if (M[J][i]) { s[i - 1] = T[i - 1]; break; } } } else { ACOutput(L); } } int main() { solve(); return 0; } ``` ****** ****说明** * 代码中使用了一些优化方法,例如树状数组、map 和预处理等。 * 针对长度小于定值的情况,使用了根号分治的技巧。 * 代码中使用了一些简单的排版技巧,例如在字符串操作中使用 pos 和 T 等变量。

正文

AC自动机

前置知识

使用场景

AC自动机是一种著名的多模式匹配算法。

可以完成类似于KMP算法的工作,但是由单字符串的匹配变成了多字符串的匹配。

一般来说,会有很多子串,和一个母串。问题常是求字串在母串中的出现情况(包括位置,次数,等等)

算法思想与流程

我在Trie树一文中提到过这样一句话

而AC自动机的核心就在于通过对Trie树进行处理,使得在处理母串的信息时可以快速的进行状态转移。

可以类比KMP的算法流程,但是这不重要

例如子串有 aa, ab, abc, b。母串为 ababcba

由于我们是通过母串进行状态转移,所以需要先把所有字串的信息搞定

我们可以先处理子串,建一棵Trie树

明显,对于一个字串的匹配,是不可能在树上一路到底的,所以要构建匹配失败时的回退机制。也就是需要构建失配指针。

那么失配指针是干什么的?也就是用来在 Trie 树上向上跳,找到可以转移的一个节点,进行状态转移。

假如我现在在3号节点,并且我下一个需要转移的状态是 b,很明显,我此时应该回退到1节点(其上第一个可以通过 b 转移的节点)并转移到4节点。如果再来一个 b,也只能向上走到0号节点,然后转移到2号节点。

如此看来,我们完全可以暴力向上跳找到可转移的状态或者到达根为止。但是,这明显不够优秀,我们完全可以继承其子节点的。也就是继承 fail 的子节点。使得不需要暴力向上跳。

那说了半天,fail 到底指向啥?

假设父节点到当前节点转移的状态为 x,父节点之上第一个可以通过 x 转移到下一个节点的节点为 u,则 fail 指向 u 通过 x 转移过后的节点。

其实还有另一种解释的方法

fail 指向 p 代表当前串的最长已知后缀。

例如 aa 的最长已知后缀为 a,所以 3号节点的 fail 指向 1号节点;abc 的最长已知后缀为空,所以 5 号节点的 fail 指向根节点。

好混乱,我尽力了……

那么核心代码……就是利用 BFS 来处理

void procFail(int * q) {
    int head(0), tail(0);
    for (int i(0); i < 26; ++i) {
        if (kids[0][i]) q[tail++] = kids[0][i];
    }

    while (head ^ tail) {
        int x = q[head++];
        for (int i(0); i < 26; ++i) {
            if (kids[x][i]) {
                fail[kids[x][i]] = kids[fail[x]][i];
                q[tail++] = kids[x][i];
            } else kids[x][i] = kids[fail[x]][i];
        }
    } // procFail end
}

注意事项:一般来说,把 0 号作为根节点会比较方便。反正 0 上不可能有信息保存。

插入部分我就不需要讲了

匹配的判断

如何判断当前状态有没有匹配任何一个字串,只需要不断向上跳 fail,看跳到的节点是不是代表着字串。

拿模板:【模板】AC 自动机(简单版) - 洛谷 为例。

插入的时候在最后标记一下有没有匹配:

void insert(string &s) {
    int p(0);
    for (int c : s) {
        if (!kids[p][(c -= 'a')]) kids[p][c] = ++usage;
        p = kids[p][c];
    }
    ++cnt[p];
}

在匹配的时候暴力跳就是了:

int ACMatch(string & s) {
    int p(0), ans(0);
    for (int c : s) {
        p = kids[p][(c -= 'a')];
        for (int t(p); t && ~cnt[t]; t = fail[t]) {
            ans += cnt[t], cnt[t] = -1;
        }
    }
    return ans;
}

由于每一个串只能匹配一次,所以这里采用的清空的策略。并且标记清空,以免重复搜索。

失配树的应用

就拿模板题来说吧:【模板】AC 自动机(二次加强版) - 洛谷

他是要求所有字串的出现情况。

那么,我们先把每一个到达的状态计数。再通过 fail 指针向上跳求和。

但毕竟不能每一个节点都暴力跳,所以考虑在 fail 树上求和。

但是,我们不是有一个 qBFS 吗?其中的 fail 是有序的:对于一个节点 x,其 fail 一定在 x 之前被遍历到。

所以我们直接使用 q 即可。

那么合起来大概也就是这样:

inline void ACMatch(string &s) {
    int p(0);
    for (char c : s) {
        p = kids[p][c - 'a'];
        ++cnt[p];
    }
}

inline void ACCount(int * q) {
    for (int i = usage; i; --i) {
        cnt[fail[q[i]]] += cnt[q[i]];
    }
}

但是每一个特定的字串出现的次数呢?

在插入时记住字串对应的节点,输出即可。

void insert(string &s, int i) {
    int p(0);
    for (int c : s) {
        if (!kids[p][(c -= 'a')]) kids[p][c] = newNode();
        p = kids[p][c];
    }
    pos[i] = p;
}


inline void ACOutput(int n) {
    for (int i = 1; i <= n; ++i) {
        cout << cnt[pos[i]] << '\n';
    }
}

有这么一道题:

很明显,对于每一个位置,我们需要清理能匹配到的最长长度,所以我们需要预处理出最长长度:

inline void ACprepare(int * q) {
    for (int i = 1; i <= usage; ++i) {
        len[q[i]] = max(len[q[i]], len[fail[q[i]]]);
    }
}

在清理时:

inline void ACclean(string &s) {
    int p(0);
    for (unsigned i(0), ie = s.size(); i < ie; ++i) {
        p = kids[p][discrete(s[i])];
        if (len[p]) for (unsigned j = i - len[p] + 1; j <= i; ++j)
            s[j] = '*';
    }
}

由于是引用的字符串,所以可以直接修改。

对状态的理解

在我们考试的时候有这么一道题:

这道题说难也难,说不难也不难。主要是看对于 AC自动机 状态转移的理解到不到位。

在匹配过程中,如果匹配到了出现的 w,那么就要回到 len(w) 个状态前,继续匹配下一个字符。

很明显,需要用栈,并且由于需要一次弹出多个,所以最好用手写的栈。

核心代码如下:

string sub, pat;
cin >> sub >> pat;
insert(sub), procFail(Q);

int p = 0;
for (int i(0), ie = pat.size(); i < ie; ++i) {
    p = kids[cps[ci]][pat[i] - 'a'];
    cps[++ci] = p, ccs[ci] = pat[i];
    if (match[p]) ci -= sub.size();
}

for (int i = 1; i <= ci; ++i) {
    putchar(ccs[i]);
}

这里没有用到 fail,那么为什么还要构建失配树?

这是个好问题,因为,构建失配树的过程不仅仅构建了失配树,同时还令节点继承了其 fail 的子节点,所以需要构建的过程。

这道题其实完全可以用 KMP 做,但是显然的,用 AC 自动机做可以做到多匹配串的删除,其实也就变成了上一题和本题的融合,这是 KMP 无法完成的。


最后附上模板题【模板】AC 自动机(二次加强版) - 洛谷的代码:云剪贴板 - 洛谷


原树与失配树

在串 \(S\)\(T\) 匹配的时候,一般会有两个问题:出现没有?出现了多少次?

而这两个问题换一换主语又可以产生新的问题,谁在谁中出现?

假定 \(S\) 为给定的一些串构成的序列:

  • 多次查某个 \(T\) 在其中某个区间有多少包含它(不重复计算)。

  • 多次查某个 \(T\) 在其中某个区间出现的次数。

  • 多次查某个 \(T\) 在其中某个区间它包含了哪些(不重复计算)。

  • 多次查某个 \(T\) 在其中某个区间出现的在它中的次数。

好吧,自然而然的转化为差分,也就是 \(T\)\(S\)\([1, r]\) 中的出现情况。

对于第一问,考虑 \(T\) 在什么时候被包含,也就是存在某个 \(S\) 的某个节点的 \(fail\) 可以跳到它。

自然的,想到在原树上将 \(S\) 一个一个加进去(在失配树上单点加),最终在失配树上求一个子树和。这容易利用树状数组维护。

但是发现这其实是第二问的答案,考虑如何不重复的计算。

此时考虑一个差分,将加入的结点按照 dfs 序排序,然后在两两 LCA 处 -1 即可做到全不重不漏覆盖。

至于正确性可以考虑虚树,此处不展开。

那么对于后面两个问题,\(T\) 包含的也就是其原树上通过失配树可以跳到的一些点。

发现一个串对它有贡献,那么这个串的末尾一定是 \(T\) 某点在失配树上的祖先,那么此时查询 \(T\) 原树路径点在失配树到根的贡献和即可。这也容易利用树状数组维护。

如果是不重复的,也可以小小的考虑一个差分,与上面同理。

但是注意到这个复杂度是 \(O(|T|)\) 的,在多次询问的时候非常不友好。

但是一般来说,存在 \(\sum |T| \le L\) 的限制(不然连读入都困难),所以此时可以小小的考虑根号分治的做法。

对于长度 \(\le \sqrt L\) 的串,完全可以暴力扫,对于长度 \(\gt \sqrt L\) 的串,它最多只有 \(\sqrt L\) 个,所以完全支持 \(O(\sum |S|)\) 的预处理它,也就是将每一个所有串重新插入 AC 自动机中,然后利用失配树 \(O(|T|)\) 的做一次求出每一个串对它的贡献,那么最后维护一下区间和即可。

这其实就是 【模板】AC 自动机(二次加强版) - 洛谷

如果是不重复的话,也就是 【模板】AC 自动机(简单版) - 洛谷


一些小套路

对于多次询问,\(\sum |S| \le L\),然而看到总长小于定值,你会想到?

  • 本质不同的长度至多只有 \(O(\sqrt L)\) 个。

那么这自然而然的带来的就是根号分治

对于一些稍微难一点的东西,很可能会用到这个性质,但是我声称其仁者见仁智者见智。或许根本也不会遇到……

单个串的时候其实完全可以使用 KMP,并且你会发现 KMP 在单串可扩展性更强,因为其不依赖于字符集的大小,也就是说可以扩展到实数集,而不是单纯的字符,这是 AC 自动机所不能拥有的优势。

但是并不意味这 AC 自动机就无法做到扩展字符集的行为。如果能够支持 \(O(\log n)\) 的代价,那么完全可以利用 map 之类的结构维护其子节点。

这基于失配树是一颗的性质,也就是失配指针只会指向一个,这与 KMP 是相似的。

但是在预处理时需要注意,不存在的节点不能指向失配点的子节点,否则时空复杂度都是不正确的。在使用的时候判断一下即可。

与算法学习笔记(20): AC自动机相似的内容:

算法学习笔记(20): AC自动机

# AC自动机 **前置知识**: - 字典树:可以参考我的另一篇文章 [算法学习笔记(15): Trie(字典树)](https://www.cnblogs.com/jeefy/p/17101290.html) - ~~KMP~~:可以参考 [KMP - Ricky2007](https://ww

算法基础(一):串匹配问题(BF,KMP算法)

好家伙,学算法, 这篇看完,如果没有学会KMP算法,麻烦给我点踩 希望你能拿起纸和笔,一边阅读一边思考,看完这篇文章大概需要(20分钟的时间) 我们学这个算法是为了解决串匹配的问题 那什么是串匹配? 举个例子: 我要在"彭于晏吴彦祖"这段字符串中找到"吴彦祖"字符串 这就是串匹配 这两个算法太抽象了

算法学习笔记(6): 树链剖分

树链剖分 树链剖分是一个很神奇,但是在树上可以完成一些区间操作问题 简单来说,就是把一棵树分成一条条的链,通过维护链上的信息来维护整棵树的信息 基础知识可以参考我的另外一篇博客:算法学习笔记(5): 最近公共祖先(LCA) 这里假设你已经掌握了上述博客中的所有相关知识,并清晰了其背后的原理 性质?发

算法学习笔记(11): 原根

原根 此文相对困难,请读者酌情食用 在定义原根之前,我们先定义其他的一点东西 阶 通俗一点来说,对于 $a$ 在模 $p$ 意义下的阶就是 $a^x \equiv 1 \pmod p$ 的最小正整数解 $x$ 或者说,$a$ 在模 $p$ 意义下生成子群的阶(群的大小) 再或者说,是 $a$ 在模

算法学习笔记(30):Kruskal 重构树

Kruskal 重构树 这是一种用于处理与最大/最小边权相关的一个数据结构。 其与 kruskal 做最小生成树的过程是类似的,我们考虑其过程: 按边权排序,利用并查集维护连通性,进行合并。 如果我们在合并时,新建一个节点,其权值为当前处理的边的权值,并将合并的两个节点都连向新建的节点,那么就可以得

算法学习笔记(3.1): ST算法

ST表 在RMQ(区间最值)问题中,著名的ST算法就是倍增的产物。ST算法可以在 \(O(n \log n)\) 的时间复杂度能预处理后,以 \(O(1)\) 的复杂度在线回答区间 [l, r] 内的最值。 当然,ST表不支持动态修改,如果需要动态修改,线段树是一种良好的解决方案,是 \(O(n)\

C++算法之旅、09 力扣篇 | 常见面试笔试题(上)算法小白专用

算法学习笔记,记录容易忘记的知识点和难题。详解时空复杂度、50道常见面试笔试题,包括数组、单链表、栈、队列、字符串、哈希表、二叉树、递归、迭代、分治类型题目,均带思路与C++题解

C++算法之旅、08 基础篇 | 质数、约数

算法学习笔记,记录容易忘记的知识点和难题。试除法、分解质因数、筛质数、约数个数、约数之和、最大公约数

算法学习笔记(1): 欧几里得算法及其扩展

扩展欧几里得算法详解 在了解扩欧之前我们应该先了解欧几里得算法 欧几里得算法 这是一个递归求最大公约数(greatest common divisor)的方法 $$ gcd(a, b) = gcd(b, a % b) $$ 可以通过一个类似的简单公式推导而来 好像叫做辗转相减法来着? $$ gcd(

算法学习笔记(3): 倍增与ST算法

倍增 目录倍增查找 洛谷P2249重点变式练习快速幂ST表扩展 - 运算扩展 - 区间变式答案倍增更多的用法优化矩形查询优化建图优化 DP作者有话说 倍增,字面意思即”成倍增长“ 他与二分十分类似,都是基于”2“的划分思想 那么具体是怎么样,我们以一个例子来看 查找 洛谷P2249 依据题面,我们知