后缀自动机学习笔记
定义与性质
后缀自动机(Suffix Automaton,简称 SAM)是用于解决字符串问题的强有力的工具。它是一个能够接受字符串所有后缀的最小 DFA。
SAM 是一张有向无环图,每个节点叫做状态,每条边被称作转移。
SAM 存在一个源点 \(t\),称为初始状态。任何状态均可从初始状态到达。
每一个转移都对应于一个字母,每个状态的转移都各不相同。
存在若干个终止状态。从 \(t\) 出发走到一个终止状态,则这条路径对应原串的一个后缀。同样的,任意一个后缀都可以通过 SAM 上从初始状态到终止状态的一条路径来表示。
SAM 上每一条路径对应原串的一个子串。换句话说, SAM 上的路径和原串上的子串一一对应。
到达某个状态的路径不止一条,所以一个状态表示的实际上是一个集合。
构造 SAM
在构造 SAM 之前,我们需要了解一些前置知识。
结束位置集合 endpos
对于串 \(s\) 的任意一个非空的子串 \(t\),我们定义 \(\operatorname{endpos}(t)\) 为字符串 \(t\) 在 \(s\) 中出现的末尾位置的集合。
例如,有 \(s="abcaabcabca"\),\(t="abc"\) 的 \(\operatorname{endpos}(t)=\{3, 7,10\}\)(下标从 \(1\) 开始)。
两个非空子串 \(t_1,t_2\) 的 \(\operatorname{endpos}\) 可能相等,我们称这两个 \(\operatorname{endpos}\) 是等价的,它们同属于同一个等价类。原串 \(s\) 的所有子串被这样划分为若干个等价类。
引理1:同属于同一个等价类的串之间必然有后缀关系。如果两个字符串 \(t_1,t_2\ (|t_1| \leqslant |t_2|)\) 的 \(\operatorname{endpos}\) 相同,当且仅当 \(t_1\) 在 \(s\) 中每次出现,都是 \(t_2\) 的后缀。
由定义,引理显然成立。
引理2:考虑两个 \(s\) 的子串 \(u,v \ (|u| \leqslant |v|)\),有
\[\begin{cases} \operatorname{endpos}(v) \subseteq \operatorname{endpos}(u) & \text{if}\ u\ \text{is a suffix of}\ v\\ \operatorname{endpos}(u) \cap \operatorname{endpos}(v) = \varnothing & \text{otherwise} \end{cases} \]
如果 \(u\) 是 \(v\) 的后缀,那么每一次 \(v\) 出现 \(u\) 都会出现,所以 \(\operatorname{endpos}(v) \subseteq \operatorname{endpos}(u)\)。否则,两者绝对不会有相同的 \(\operatorname{endpos}\)。
引理3:每一个 \(\operatorname{endpos}\) 类都是由若干个长度连续,并且具有后缀关系的子串构成的。
上面引理的推论
引理4:同一个 \(\operatorname{endpos}\) 类中的子串同时加上同一个字符后,它们仍然属于同一个 \(\operatorname{endpos}\) 类。
同时加上一个字符,仍然满足后缀关系。
后缀链接和 parent 树
对于一个不是初始状态的状态 \(v\),我们将其中的串按照长度从大到小排序,由引理3,除去最大的以外,其余每个子串都是最大串的后缀。记这个最大串为 \(\operatorname{longest}(v)\),其长度为 \(\text{len}(v)\)。
我们找到第一个 \(\operatorname{longest}(v)\) 的后缀 \(t\) 使得 \(v\subsetneqq\operatorname{endpos}(t)=u\)。我们就将 \(\operatorname{fail}(v) \rightarrow u\)。
引理5:所有后缀链接构成了一棵树。
每个状态的出度和入度都为 \(1\)。我们称这棵树为 parent 树。
引理6:由后缀链接构成的 parent 树与后缀树相同。
不知道。
构造方法
我们定义在插入某个字符 \(c\) 前的状态为 \(last\),新插入的状态为 \(now\)。
我们沿着 \(last\) 的 \(fail\) 边向上跳,设每次跳到状态 \(p\)。如果 \(p\) 没有连向 \(c\) 的边,那么就讲它直接连向 \(now\)。
如果最后跳到了 \(t\) 就将 \(\text{fail}(now) \rightarrow t\)。
否则分成两种情况:
我们定义状态 \(q\) 为 \(p\) 转移为 \(c\) 的状态。
-
\(\text{len}(q) = \text{len}(p) + 1\)
说明 \(q\) 和 \(p\) 之间只差了一个字符。此时可以直接 \(\text{fail}(now) \rightarrow q\)。
-
否则,我们需要新建一个节点来辅助建边。
我们新建状态 \(clone = q\),复制其除了 \(len\) 的所有信息。
让 \(\text{len}(clone)=\text{len}(p)+1\)。
此时我们让 \(\text{fail}(q) \rightarrow clone, \text{fail}(now) \rightarrow clone\)。
正确性证明
不会。
代码实现
class Suffix_Automaton {
struct node {
int len, fail;
int ch[26];
} tr[N << 1];
int tot = 1, last = 1;
public:
void ins(int x) {
int p = last, q, now = ++tot;
siz[now] = 1;
tr[now].len = tr[p].len + 1;
for (; p && !tr[p].ch[x]; p = tr[p].fail)
tr[p].ch[x] = now;
if (!p) {
tr[now].fail = 1;
} else {
q = tr[p].ch[x];
if (tr[q].len == tr[p].len + 1) {
tr[now].fail = q;
} else {
int cl = ++tot;
tr[cl] = tr[q];
tr[cl].len = tr[p].len + 1;
tr[q].fail = tr[now].fail = cl;
for (; p && tr[p].ch[x] == q; p = tr[p].fail)
tr[p].ch[x] = cl;
}
}
last = now;
}
} SAM;
应用
自动机
SAM 接收了关于一个字符串的所有信息。每一条从原点出发的路径都代表着原串的一个子串,并且不是原串子串的一定不是从原点出发的路径。
和一般的自动机一样,我们可以在上面进行字符串的匹配与查找。
parent树
本质上是被压缩过的后缀树。后缀自动机的独特之处在于 \(\rm len\) 和 \(\rm fail\) 。
上面已经说过,同一个状态中的串连续并满足后缀关系,是它的 \(\rm fail\) 的真子集。
我们可以在 parent 树上进行差分,\(\rm len(i) - len(fail(i))\) 即为同等价类的串的个数。
不同子串个数
统计自动机上路径条数即可。
或者统计每个等价类包含的串的个数。
如果要统计总长度,改一下求和就行了。
子串出现次数
利用树形结构,我们定义 \(\rm siz(t)\) 为状态 \(t\) 的 \(\rm endpos\) 集合大小。
由于 \(\rm fail\) 满足后缀关系。所以,如果一个串在某个地方被匹配到了,那么它将在它的所有子节点(parent 树上)的同样位置出现。
所以对子树的 \(\rm siz\) 求和即可。
void dfs(int x) {
// siz 在构建的时候为1
for (int i = head[x]; i; i = e[i].next) {
const int y = e[i].to;
dfs(y);
siz[x] += siz[y];
}
}
本质不同子串个数
\(\rm siz\) 设为 1 就无了。
区间本质不同子串个数
好玩 SAM + LCT + 线段树。码量就 150 行。