后缀自动机
sto 辰星凌 command_block orz
概念
后缀自动机是一种用于处理子串相关问题的字符串算法。
通常可以做到线性时间内处理一些复杂的子串问题。
可以和 线段树合并/LCT 等等的数据结构一起出。
有时候可以用 SA/后缀树 一类的东西代替。
概述
对于一个字符串 \(S\) 的子串 \(s\),\(s\) 的 endpos 集合表示: \(s\) 每次在 \(S\) 中出现时最后一个字符的下标构成的整数集。
约定 endpos 相同的子串为一个 endpos 等价类。
关于 endpos 集合有一些有用的性质:
-
两个子串的 endpos 集合要么交集为空,要么其中长串的 endpos 是短串 endpos 的子集。
-
如果两个子串的 endpos 相同,那么其中短串必定是长串的后缀。
-
对于一个 endpos 等价类,其中的子串长度构成一个连续的区间,并且其中长度为 \(x\) 的子串必定是长度为 \(x + 1\) 的子串(如果有)的后缀。
-
endpos 等价类的个数为 \(O(n)\) 级别。
事实上 endpos 集合的包含关系可以构成一棵树,称之为 parent tree。
注意 parent tree 的结点表示的是一个 endpos 等价类而非字符串。
记 \(fa(S)\) 表示 parent tree 中等价类 S 的父结点,\(minlen(S)\) 表示 endpos 等价类 S 中最短的字符串的长度,\(len(S)\) 表示 endpos 等价类 S 中最长的字符串的长度。
有性质 5:\(minlen(S) = len(fa(S)) + 1\)
存在构造方式使得 SAM 的结点和 parent tree 的结点相同,于是只需要考虑如何构造边集使得其满足 SAM 的性质:
-
父结点的 endpos 包含子结点的 endpos
-
父结点等价类中的所有子串都是当前等价类中任意子串的后缀
-
SAM 是一张 DAG
-
原串的所有子串都可以被 SAM 上的一条路径表示
事实上可以证明存在边数不超过 \(O(n)\) 的构造方法。
构造
实际上 SAM 的构造和前面的推导没太大关系(笑
采用增量构造。
SAM 可以表示原串的所有子串,等价于可以表示每个前缀的任意后缀。因此可以考虑把每个长度的前缀的任意后缀都插入 SAM,实际上从 \(i - 1\) 长度到 \(i\) 长度的增量就是若干 \(i - 1\) 长度前缀的后缀加上第 \(i\) 个字符形成的新后缀。
对 parent tree 结点的定义:
struct node
{
int len, fa, son[26];
node() { memset(son, 0, sizeof(son)); fa = len = 0; }
} nd[sz];
其中 len 表示当前结点代表的等价类中最长字符串的长度。
假设要插入的字符为 c
,定义增量函数为:
void insert(int c)
对于 SAM 中的结点,其中存在一些 endpos 中含有已插入最长前缀长度的结点,不妨称这些结点为终止结点。
考虑在增量的同时维护变量 lst
表示上一次插入的字符所处的终止结点。
int p = lst, np = lst = ++cur;
首先字符串总长增加 \(1\):
nd[np].len = nd[p].len + 1;
对于此前 SAM 中 np
的祖先结点,它们会代表一些长度为 \(i - 1\) 的前缀的后缀。假设其中有一字符串 \(s\),如果 \(s + c\) 不能被 SAM 中的结点表示,那么那么就从该结点到插入的结点连一条边,代表插入这个新后缀:
for ( ; p && (!nd[p].son[c]); p = nd[p].fa) nd[p].son[c] = np;
判断 p != 0
是防止跳出起始结点。
情况 1:如果没有其他结点可以表示产生的新后缀,根据定义有:
if (!p) nd[np].fa = 1;
情况 2:存在其他结点可以表示产生的新后缀
如果这个结点的等价类里最长的串是新后缀:
int q = nd[p].son[c];
if (nd[q].len == nd[p].len + 1)
那么这个结点的等价类里所有的字符串都是产生的新后缀,发现此时 \(endpos(np) \subseteq endpos(q)\),于是:
nd[np].fa = q;
反之,进入情况 3。此时 q 的等价类里有更长的串,因此它的 endpos 不包含 n,所以此时直接连边不满足 parent tree 的定义。
那么考虑将 q 拆分成两个结点,一个结点表示原等价类中长度在 \([minlen, len(p) + 1]\) 的字符串,另一个表示原等价类中剩下的子串。注意此时两个结点表示的 endpos 不相等。
int nq = ++cur;
nd[nq] = nd[q], nd[nq].len = nd[p].len + 1;
那么根据定义有 \(endpos(q) \subseteq endpos(nq), endpos(p) \subseteq(nq)\),直接连边:
nd[q].fa = nd[np].fa = nq;
然后相应地把祖先结点的连边也一起修改:
for ( ; p && (nd[p].son[c] == q); p = nd[p].fa) nd[p].son[c] = nq;
具体正确性我不会证,之后再填坑吧。
void insert(int c)
{
int p = lst, np = lst = ++cur;
cnt[cur] = 1, nd[np].len = nd[p].len + 1;
for ( ; p && (!nd[p].son[c]); p = nd[p].fa) nd[p].son[c] = np;
if (!p) nd[np].fa = 1;
else
{
int q = nd[p].son[c];
if (nd[q].len == nd[p].len + 1) nd[np].fa = q;
else
{
int nq = ++cur;
nd[nq] = nd[q], nd[nq].len = nd[p].len + 1;
nd[q].fa = nd[np].fa = nq;
for ( ; p && (nd[p].son[c] == q); p = nd[p].fa) nd[p].son[c] = nq;
}
}
}
采用这种方法构造的 SAM 点数不超过 \(2n - 1\),边数不超过 \(3n - 4\)(\(n \geq 3\))
广义 SAM
普通 SAM 维护的是字符串,广义 SAM 维护的是 Trie。
也可以理解成是维护多个字符串。
网上有大量盗版广义 SAM,常见的是每次插入后直接令 lst = 1
或者用奇怪符号处理原串。这些在大部分情况下是对的,但是可以被卡。@辰星凌 的博客详细解释过这些问题。
定义增量函数为:
int insert(int c, int lst)
实际上正确的在线写法只需要添加两个特判:
- 如果该结点已经存在,则不用新建结点。
if (nd[lst].son[c])
int p = lst, q = nd[lst].son[c];
if (nd[p].len + 1 == nd[q].len) return q;
- 如果
nd[p].son[c]
已经存在且不为上面的情况,那么如果新建结点,则该结点为空(不保存任何信息)。此时也按照不新建结点的情况处理。
else
{
int nq = ++cur;
nd[nq] = nd[q], nd[nq].len = nd[p].len + 1;
nd[q].fa = nq;
for ( ; p && (nd[p].son[c] == q); p = nd[p].fa) nd[p].son[c] = nq;
return nq;
}
证明略,见参考博客。
于是得到广义 SAM 的增量函数。
int insert(int c, int lst)
{
if (nd[lst].son[c])
{
int p = lst, q = nd[lst].son[c];
if (nd[p].len + 1 == nd[q].len) return q;
else
{
int nq = ++cur;
nd[nq] = nd[q], nd[nq].len = nd[p].len + 1;
nd[q].fa = nq;
for ( ; p && (nd[p].son[c] == q); p = nd[p].fa) nd[p].son[c] = nq;
return nq;
}
}
int p = lst, np = ++cur;
nd[np].len = nd[p].len + 1;
for ( ; p && (!nd[p].son[c]); p = nd[p].fa) nd[p].son[c] = np;
if (!p) nd[np].fa = 1;
else
{
int q = nd[p].son[c];
if (nd[q].len == nd[p].len + 1) nd[np].fa = q;
else
{
int nq = ++cur;
nd[nq] = nd[q], nd[nq].len = nd[p].len + 1;
nd[q].fa = nd[np].fa = nq;
for ( ; p && (nd[p].son[c] == q); p = nd[p].fa) nd[p].son[c] = nq;
}
}
return np;
}
注意插入新串时要重新将 lst
设为 \(1\)
如果要对多个字符串维护信息,需要对每个字符串分别存储信息,例如求出现次数不能共用一个变量而是要开数组。
用结构体实现可能会被 卡常,写数组会快很多,但数组写法先咕了
套路
子串出现次数
结论:SAM 中结点的出现次数 等价于 子树大小。
考虑 parent tree,对于子结点出现过的位置,其父结点(后缀)一定也出现过。因此每次插入字符时将其出现次数设为 \(1\),然后在 parent tree 上求子树和即可。
本质不同子串个数
-
做法一:等价于求 \(\sum\limits maxlen(p) - minlen(p) = maxlen(p) - maxlen(fa(p))\)
做法二:注意到 SAM 是 DAG,所以等价于路径计数。
-
P4070 [SDOI2016]生成魔咒
动态求不同子串个数。
每次新建 SAM 结点的时候加入其贡献就行,注意
nq
没有贡献。
最长公共子串
SP1811 LCS - Longest Common Substring
考虑字符串所有的子串都可以表示成:原串某个前缀的后缀。
于是考虑对其中一个串建立 SAM,在另一个串上跑匹配。
枚举匹配串的前缀,如果 SAM 中有当前字符的转移则转移,反之一直向上跳,直到存在转移为止。得到的是此前缀的某一个后缀,也就是这个前缀和另一个串的 LCS.
第 k 小子串
跑一遍路径计数,然后类似于平衡树地递归求即可。
求等价类的 endpos
考虑线段树合并。
考虑遍历原串的每一个前缀所在的等价类,将这些结点作为初始条件,暂时令其 endpos 为自身长度。
然后将所有等价类按照长度排序,然后倒序线段树合并。
具体的以后再补,咕咕咕。
parent tree 相关
多串匹配
SP8093 JZPGYZ - Sevenk Love Oimaster
考虑对所有模板串建立广义 SAM,插入每个前缀时在相应结点打上标记。
查询时找到该串对应的 SAM 结点,转化成在该结点的子树内数不同的标记数量。线段树合并维护。
动态维护出现次数
P5212 SubString
每次给定一个字符串,将其 接在已有串的末尾 或者 询问其在已有串的出现次数。
考虑到某个串的出现次数可以转化成 parent tree 上的子树和,于是考虑转化成动态加点,维护子树和。
注意到增加一个结点的贡献等价于:其到根的路径上每个结点的子树和加一。用 LCT 维护动态链加,单点查权值就行。
parent tree 上倍增
CF666E Forensic Examination
类似求 LCS,考虑维护 \(S\) 的每个前缀 \([1, r]\) 和 \(T\) 匹配的最长长度 \(mch(r)\) 和此时对应的 SAM 结点 \(nd(r)\)。
对于询问 \([pl, pr]\),考虑从 \(nd(r)\) 向上倍增,直到找到等价类中包含长度 \([pr - pl + 1]\) 的结点。
此时的问题是该结点对应的字符串在 \(T_l, \cdots, T_r\) 中出现次数最多的串。
考虑用线段树合并维护,插入时对于前缀结点 \(p\),令其对应的字符串编号为 \(x\),在线段树上 \(x\) 处加一。
后缀树
反串的 parent tree.
性质:\(lcp(a, b)\) 等于 \(a, b\) 在后缀树上 LCA 的深度.
资料
史上最通俗的后缀自动机详解 - KesdiaelKen 的博客 - 洛谷博客