后缀自动机 学习笔记
后缀自动机 学习笔记
概述
后缀自动机(suffix automaton, SAM)是一个能解决许多字符串相关问题的有力的数据结构。
直观上,字符串的 SAM 可以理解为给定字符串的 所有子串 的压缩形式。
定义
字符串 \(s\) 的后缀自动机(SAM)是一个接受 \(s\) 的所有后缀的最小 DFA(确定性有限自动机或确定性有限状态机)。
对于一个字符串 \(s\),我们可以构建出它所有后缀的 AC 自动机,但是无论是复杂度还是实用程度都不合适。
在探究过程中,我们发现有很多性质相似的状态可以合并,这样就产生了 SAM,它是将所有后缀的 AC 自动机压缩到极致的产物。
记号约定
- \(s\):目标字符串。
- \(sta_0\):初始状态。
- \(\operatorname{tr}(u,c)\):状态 \(u\) 接受了字符 \(c\) 后转移到的状态。
- \(\operatorname{endpos}(t)\):一个集合,表示 \(s\) 中子串 \(t\) 的结束位置。
- \(\operatorname{link}(u)\):状态 \(u\) 对应的后缀链接(与 AC 自动机中的失配指针 \(\operatorname{fail}\) 相似)。
- \(\operatorname{longest}(u)\):状态 \(u\) 对应的字符串集合中最长子串。
- \(\operatorname{len}(u)\):状态 \(u\) 对应的字符串集合中最长子串长度。
大致原理:结点合并
首先明确同 ACAM 相同的是,把 SAM 上的从根开始的路径转移写下来都会形成一个字符串,在 ACAM 中是曾经加入的串及其前缀,而在 SAM 中则是 SAM 的某个子串。
但是不同之处在于,SAM 上的从根开始的路径对应的状态中包含的字符串可能有许多个,因为这个路径是多样的。
这是因为我们通过不停的将性质一样的节点不断合理地合并,最终构造出了 SAM。
构造
SAM Drawer (officeyutong.github.io)。
SAM 的构造算法是在线且线性的增量构造。
\(\operatorname{tr}(u,c)\)
定义:状态 \(u\) 接受了字符 \(c\) 后转移到的状态。
与字典树,ACAM 等相同。
\(\operatorname{endpos}(t)\)
定义:一个集合,表示 \(s\) 中子串 \(t\) 的结束位置。
而两个子串的 \(\operatorname{endpos}\) 可能是相同的,而我们就以此为分类依据,将所有字符串分为若干等价类,且每个等价类都对应 SAM 中的一个状态,即把 \(\operatorname{endpos}\) 相同的合并为一个状态,也就是上文说的“合理地合并”。
特别地我们规定 \(\operatorname{endpos}(sta_0)\) 为 \(\set{0,1,\ldots,n}\)。
性质
引理 1
字符串 \(s\) 的两个非空子串 \(a\) 和 \(b\) 的 \(\operatorname{endpos}\) 相同,且 \(| a | \le | b |\),则 \(a\) 在 \(s\) 中出现的每一次,都是某个 \(b\) 的后缀(证明略,感性理解一下,下同)。
引理 2
字符串 \(s\) 的两个非空子串 \(a\) 和 \(b\) 满足 \(| a | \le | b |\),则有:
- \(\operatorname{endpos}(b) \subseteq \operatorname{endpos}(a)\):当 \(a\) 是 \(b\) 的后缀。
- \(\operatorname{endpos}(a) \cap \operatorname{endpos}(b) =\varnothing\):其他情况。
引理 3
在以 \(\operatorname{endpos}\) 为划分依据的相同等价类中,满足:
- 对应的字符串长度连续。
- 长度小者为长度大者的后缀。
\(\operatorname{link}(u)\)
定义:状态 \(u\) 对应的后缀链接(与 AC 自动机中的失配指针 \(\operatorname{fail}\) 相似)。
在 ACAM 中,\(\operatorname{fail}(u)\) 指针指向本串最长的真后缀,而在 SAM 中,后缀链接 \(\operatorname{link}(u)\) 指向的是有包含 \(\operatorname{endpos}(u)\) 的最小的 \(\operatorname{endpos}\) 等价类,同时 \(\operatorname{link}(u)\) 集合中最长的串也是 \(u\) 中最长的串的真后缀,两者有异曲同工之妙。
性质
引理 4
所有后缀链接构成一棵以 \(sta_0\) 为根的树,称为 parent 树或后缀链接树。
其中 parent 树又有很多性质。
设前缀 \(s_{[1,i]}\) 对应的状态为 \(v_i\),其中 \(v_0 = sta_0\)。
那么 parent 树有如下性质:
- 祖先节点总是对应子孙节点的后缀。
- 每个节点处的 \(\operatorname{endpos}\) 就是它子树内 \(v\) 的下标集合。
- 每个节点处的 \(\operatorname{len}\) 的值就是它的子树内的所有 \(v\) 对应前缀的最长公共后缀的长度。
那么用途有:
- 求两个前缀 \(i,j\) 的最长公共后缀,那么就是 \(v_i,v_j\) 的 LCA 对应的最长字符串。
- 离线建后缀树,或者建立能在前端插入的后缀树。
\(\operatorname{len}(u)\)
定义:状态 \(u\) 对应的字符串集合中最长子串长度。
性质
引理 5
状态 \(u(u\neq sta_0)\) 对应的字符串集合中,有 \(\operatorname{len}(u)-\operatorname{len}(\operatorname{link}(u))\) 个字符串,字符串长度范围为 \((\operatorname{len}(\operatorname{link}(u)),\operatorname{len}(u)]\),且在整数域上连续。
状态 \(lst\)
用 \(lst\) 代表 SAM 中接受已经加入的 \(s\) 前缀后到达的状态,省去了我们重复遍历的时间。
性质
可以发现每次更新完的 \(lst'\) 是之前 \(lst\) 在 DAG 上的一个指向节点,转移为 \(\operatorname{tr}(lst,c)\),而所有曾经或现在为 \(lst\) 的状态形成了一条链,对应 \(s\) 的所有前缀。
构造完 SAM 后,从 \(lst\) 开始不断跳 \(\operatorname{link}\),遍历的所有状态都是终止状态,因为其对应的都是字符串的后缀。
具体实现
我们来明确一下算法的目的:
- 生成一张 DAG 和一棵树,两图结点完全重合。
- DAG 中从起点 \(sta_0\) 按字符转移可以保证 \(s\) 所有子串都有对应状态。
- 树上以 \(\operatorname{link}\) 作为边,父结点的 \(\operatorname{endpos}\) 包含子结点的 \(\operatorname{endpos}\),且是满足此条件中最小的,父结点集合中最长的串也是子结点集合中最长的串的真后缀。
- 要求生成的图是最小的(由 Myhill–Nerode 定理得出,不证)。
我们考虑不断给 SAM 加入一个字符 \(c\),然后进行更新的过程,即增量构造,一般称作 Extend。
加入新状态
更新 \(lst\) 为 \(lst'\),\(\operatorname{len}(lst') = \operatorname{len}(lst)+1\)。
初步更新 \(\operatorname{tr}\)
为了保证 SAM 中 \(\operatorname{tr}\) 的正确性,我们需要进行对其更新,也就是将转移后状态为 \(lst'\) 的进行更新,将其 \(\operatorname{tr}(p,c)\) 置为 \(lst'\)。
令 \(p=lst\)(注意不是 \(lst’\)),此时的 \(\operatorname{tr}(p,c)\) 肯定是要置为 \(lst'\) 的,且 \(p\) 对应的 \(\operatorname{len}\) 肯定是所有要更新的状态中最大的那个,那么类比 ACAM 或 KMP,我们就有了找到所有要更新结点的方案:不断将 \(p\) 向 \(\operatorname{link}(p)\) 转移,然后直接判断并更新。原因也很简单,要更新的肯定都是 \(lst\) 状态对应的最长字符串后缀,那么从 \(lst\) 开始往 \(\operatorname{link}\) 跳就是最长字符串后缀。
如果 \(p\) 最终跳到了 \(\varnothing\),那么直接退出即可,因为它被全部更新完了。
注意到在不断跳 \(p\) 的过程中,如果 \(\operatorname{tr}(p,c)=\varnothing\),那么我们可以直接置 \(\operatorname{tr}(p,c)\) 为 \(lst'\),但是如果 \(\operatorname{tr}(p,c) \neq \varnothing\) 呢?
状态存在
令 \(q=\operatorname{tr}(p,c)\),如果 \(\operatorname{len}(q)=\operatorname{len}(p)+1\),那么其实 \(\operatorname{longest}(q)\) 就是 \(\operatorname{longest}(lst')\) 的一个后缀,并且是最长的,那么此时可以直接置 \(\operatorname{link}(lst')\) 为 \(q\),并且结束更新。
状态分裂
若 \(\operatorname{len}(q)\neq \operatorname{len}(p)+1\),那么有 \(\operatorname{len}(q)>\operatorname{len}(p)+1\),此时若直接置 \(\operatorname{tr}(p,c)\) 为 \(lst'\),则会破坏 \(p,q\) 之间的关系,那么我们只能从 \(q\) 中分裂出一个状态 \(o\),使得它满足 \(\operatorname{len}(o)=\operatorname{len}(p)+1\),即 \(\operatorname{longest}(o)\) 是 \(\operatorname{longest}(lst'),\operatorname{longest}(q)\) 的一个公共后缀,并且都是是最长的。这样分裂对其它地方并无太大影响。
由于其实 \(\operatorname{longest}(o)\) 是 \(\operatorname{longest}(q)\) 的一个后缀,所以它们的 \(\operatorname{tr}\) 也是一样的,而 \(\operatorname{link}(o)\) 就置为原本的 \(\operatorname{link}(q)\),相当于在 parent 树上插入节点。
最后再把 \(\operatorname{link}(q),\operatorname{link}(lst')\) 置为 \(o\)。
进一步更新 \(\operatorname{tr}\)
注意到我们只在 parent 树上插入了 \(o\),在原本的 DAG 上 \(\operatorname{tr}(u,c)=q\) 的部分还没有更改,我们只要继续让 \(p\) 跳 \(\operatorname{link}(p)\),改 \(\operatorname{link}(p,c)\) 为 \(o\),直到 \(\operatorname{link}(p,c)\neq q\) 了就停止即可。
代码
template<const int N>s\operatorname{tr}uct SAM {
int tot,lst;
s\operatorname{tr}uct node {
int \operatorname{len},pa; //pa = \operatorname{link}
int \operatorname{tr}[C];
node(int \operatorname{len}=0,int pa=0):\operatorname{len}(\operatorname{len}),pa(pa) { RCL(\operatorname{tr},0,int,C); }
int &operator [](int i) { return \operatorname{tr}[i]; }
} \operatorname{tr}[N];
SAM():tot(0),lst(0) {}
node &operator [](int i) { return \operatorname{tr}[i]; }
#define pa(p) (\operatorname{tr}[p].pa)
#define \operatorname{len}(p) (\operatorname{tr}[p].\operatorname{len})
void Init() { \operatorname{tr}[tot=lst=1]=node(); }
void Extend(const int c) {
//加入新状态
int p(lst);
\operatorname{tr}[lst=++tot]=node(\operatorname{tr}[p].\operatorname{len}+1,1);
//初步更新 \operatorname{tr}
while(p&&!\operatorname{tr}[p][c])\operatorname{tr}[p][c]=lst,p=pa(p);
if(!p)return;
//状态存在
int q(\operatorname{tr}[p][c]);
if(\operatorname{len}(q)==\operatorname{len}(p)+1)return pa(lst)=q,void();
//状态分裂
int o(++tot);
\operatorname{len}(o)=\operatorname{len}(p)+1,pa(o)=pa(q),pa(q)=pa(lst)=o,CPY(\operatorname{tr}[o].\operatorname{tr},\operatorname{tr}[q].\operatorname{tr},int,C);
//进一步更新 \operatorname{tr}
while(p&&\operatorname{tr}[p][c]==q)\operatorname{tr}[p][c]=o,p=pa(p);
}
#undef pa
#undef \operatorname{len}
};
复杂度分析
状态数量
状态数量最多是 \(2n-1\) 个,级别为 \(O(n)\)。
注意 SAM 数组要开两倍。
转移边数量
转移边数量最多为 \(3n-4\) 条,级别为 \(O(n)\)。
粗略证明
构造 SAM 生成树,那么树边最多为 \(2n-2\) 条。
而每条非树边都关联一个后缀,不同非树边关联后缀不同,而后缀总数 \(\le n\),故总边数 \(\le 3n-2\)(更紧界为 \(3n-4\))。
时间复杂度
构造复杂度
那么增量构造中最难分析的是从 \(lst\) 往 \(\operatorname{link}\) 跳的复杂度,发现每次跳都要增加一条转移边,故复杂度就是与转移边数量相同。
初始化 & 转移复杂度
设 \(|\Sigma|\) 表示字符集大小。
- 如果直接选用数组存储 \(\operatorname{tr}\),那么初始化总复杂度为 \(O(n|\Sigma|)\),单次转移复杂度为 \(O(1)\)。
- 选用平衡树存储 \(\operatorname{tr}\),那么初始化总复杂度为 \(O(n)\),单次转移复杂度为 \(O(\log{|\Sigma|})\)。
- 选用 Hash Table 存储 \(\operatorname{tr}\),那么初始化复杂度为 \(O(n)\),单次转移复杂度为 \(O(1)\)(可能退化,且常数较大)。
应用
检查字符串是否出现
比较基本,一个文本串 \(T\) 和多个模式串 \(P\) 匹配,对 \(T\) 建立后缀自动机即可,同时可以找到 \(P\) 在 \(T\) 中出现的最大长度。
不同子串个数
例题:P2408 不同子串个数 - 洛谷 (luogu.com.cn)。
建立 SAM,统计即可。
多次区间查询不同子串个数
例题:
-
Reincarnation - HDU 4622 - Virtual Judge (vjudge.net)(\(O(n^2)\))。
-
P6292 区间本质不同子串个数 - 洛谷 (luogu.com.cn)(\(O(n\log_2^2{n})\))
UVA13023 Text Processor - 洛谷 (luogu.com.cn)(变式,\(O(n)\))
对于 \(O(n^2)\) 的弱化版,我们可以暴力建 \(n\) 次 SAM,然后前缀和统计。
对于 \(O(n\log_2^2{n})\) 的强化版,我们有 SAM 建后缀树 + 线段树 + LCT 或树剖的做法,或者可以看评论区的可持久化在线做法。
所有不同子串的总长度
与上一个问题没有本质区别。建立 SAM,统计即可。
最小循环移位
例题:P1368 工艺 - 洛谷 (luogu.com.cn)。
用最小表示法也可以做到 \(O(n)\),而且更快。
对 \(s+s\) 建立 SAM,然后从 \(sta_0\) 开始不断跳最小字符即可。
出现次数
例题:P3804 【模板】后缀自动机(SAM) - 洛谷 (luogu.com.cn),Oulipo - HDU 1686 - Virtual Judge (vjudge.net)。
即 \(\operatorname{endpos}\) 集合大小,用 parent 树的性质可做。
还有一些性质做法,见 后缀自动机 (SAM) - OI Wiki (oi-wiki.org),其实本质是相同的,理解之后也可以。
第一次出现位置
即 \(\operatorname{endpos}\) 集合最小值,用 parent 树的性质可做,甚至可以做成 k 小值,还有一些性质做法也在 OI Wiki 上可以找到。
所有出现位置
模版:P3375 【模板】KMP - 洛谷 (luogu.com.cn)(第一问,第二问无法使用 SAM)。
即 \(\operatorname{endpos}\) 集合最小值,用 parent 树的性质可做,还有一些性质做法也在 OI Wiki 上可以找到。
回文串计数
例题:P1872 回文串计数 - 洛谷 (luogu.com.cn),P3649 [APIO2014] 回文串 - 洛谷 (luogu.com.cn)。
本质不同的回文串数量 \(\le|S|\),那么 Manacher 求所有回文串,然后对于每个本质不同的回文串查询出现次数即可。
最短的没有出现的字符串
例题:AT_arc081_c [ARC081E] Don't Be a Subsequence - 洛谷 (luogu.com.cn)。
这个可以 DP 做:设 \(f_u\) 表示在状态 \(u\),想要找到不连续的转移添加字符的最小值。那么转移很显然:
如果输出方案的话就是老套路:倒推。
统计是一个串 \(S\) 的子串而不是另外一些串 \(t_i\) 的子串
例题:Good Article Good sentence - HDU 4416 - Virtual Judge (vjudge.net)。
直接对 \(S\) 建 SAM,把其余的插入 SAM 即可。
两个字符串的最长公共子串(LCS)
例题:SP1812 LCS2 - Longest Common Substring II - 洛谷 (luogu.com.cn)。
这个问题有 \(O(nm)\) 的 DP 做法,但是这次 SAM 的做法可以直接优化到 \(O(n+m)\)。
假设两串分别为 \(S,T\),首先对 \(S\) 建 SAM,然后直接让 \(T\) 在中间匹配,如果匹配不了就跳 \(\operatorname{link}\),取过程中间能达到的最大值即可。
多个字符串的最长公共子串(LCS)
例题:SP1812 LCS2 - Longest Common Substring II - 洛谷 (luogu.com.cn)。
首先还是对第一个串建 SAM,然后其余的都像上面的问题一样,插入时先在 SAM 上遍历一遍,然后对每个节点都记匹配到的最大长度 \(mx\),然后在 parent 树上用树上前缀最大值更新 \(mx\) 为子树内的 \(\max{mx}\)。
每次加串的最后统计一下 \(mn\) 这个是让所有串都满足条件 。
求母串的第 \(K\) 小子串
例题:P3975 [TJOI2015] 弦论 - 洛谷 (luogu.com.cn)。
建出 SAM 后按顺序遍历即可。
至少不重叠出现两次的子串数量
例题:Boring counting - HDU 3518 - Virtual Judge (vjudge.net)。
建 SAM,每次插入一个字符后,就暴力跳 parent 树,给每个跳到的节点都对字符位置取 \(\min,\max\),最后整个遍历统计一遍,将 \(\max\) 减去 \(\min\) 就是这个 \(\operatorname{endpos}\) 集合中最长的不重叠出现两次的子串。
字符串间的公共子串数量
例题:P3181 [HAOI2016] 找相同字符 - 洛谷 (luogu.com.cn)。
一个串建出 SAM,然后遍历另一个串记录次数,最后求值。
可以考虑扩展到多串。
求恰好出现 \(K\) 次的子串数量
例题:string string string - HDU 6194 - Virtual Judge (vjudge.net)。
模版题,SAM 建完之后 Topo Sort 或者深搜统计一下即可。
可以在串的末尾加入字符,动态维护出现 \(\ge K\) 次的子串数量
例题:K-string - HDU 4641 - Virtual Judge (vjudge.net)。
结合数据结构,那么这里可以离线后树剖、倍增、线段树合并,还有路径压缩并查集。
从两个串中选出子串,求组成本质不同的串的个数
例题:MZL's Circle Zhou - HDU 5343 - Virtual Judge (vjudge.net)。
首先对于一个组合出来的串,为了不重复统计,我们让第一个串的子串 \(x\) 取到最长。
统计第二个串以每个字符 \(c\) 开头的本质不同子串 \(y\) 数量,然后使 \(x\) 不存在 \(+c\) 的转移,那么对第一个串建 SAM,对第二个串的反串再建一个 SAM,统计即可。注意空串。
优化 DP
例题:Typewriter - HDU 5470 - Virtual Judge (vjudge.net)。
注:如果在指针移动时往 SAM 里插入字符,记得更新指针。
首先设 \(f_i\) 表示完成到 \(i\) 的最小代价和,那么很显然有两种转移:
-
\[f_i \gets f_{i-1}+ C_{s_i} \]
比较显然。
-
\[f_i \gets f_j + (i-j)A+2B \]
前提是 \(s[j+1,i]\) 是 \(s[1,j]\) 的一个子串,那么合法的 \(j\) 一定是连续的一段区间,且区间右端点为 \(i-1\),易证,此略。然后又发现,\(j\) 随着 \(i\) 增大而单调不减,那么我们直接维护单调队列就可以 DP。
那么我们设这个区间为 \([it,i)\),可以通过 SAM 上双指针来维护 \(it\),注意我们这里先不一开始就把 \(s\) 全部插入 SAM,而是随着 \(it\) 的增加而逐个插入。
维护指针 \(p\) 表示目前 \(s[it,i)\) 在 SAM 上对应的状态,那么我们跳到 \(i\) 的时候,可以尝试往 \(p\) 状态后面加入 \(s_i\),如果不能加,那么我们就让 \(it\gets it+1\),并将 \(s_{it}\) 插入 SAM。此时如果 \(p\) 能在 parent 树上往根节点跳,那么也让它跳,因为这样能够更容易匹配到 \(s_i\)。如果 \(it\) 一直到 \(i-1\) 依旧不能让 \(p\) 后面加入 \(s_i\),那么只好清空重来。

浙公网安备 33010602011771号