后缀自动机(SAM)学习笔记
后缀自动机(SAM)学习笔记
前言
如读者们所见,可以看到我的专栏中从来没有过学习笔记,这是第一次。
写这一篇学习笔记的原因是,我真的觉得,后缀自动机是我学 OI 以来最抽象最难理解的东西。
所以我将用很多图片和具体的描述来解释这一抽象的数据结构,用于自己参考回顾,同时方便读者学习。
后缀自动机强势图解
需要的前置知识:Trie 树(字典树),基本字符串知识,基本集合知识,图论基础。
声明:不要跳过任何部分,哪怕你认为你明白这部分,否则可能阅读到的信息不连续导致不懂。
如果下文的图挂了请联系我或者评论。
正文部分来啦,我们首先需要相信它很简单,不能被敌人吓趴了。
01. 自动机
自动机用于对一个信号序列进行判定。
OI 界指的自动机一般是确定有限状态自动机(缩写是 DFA),即转移方案确定且状态有限。
我们通常用一个有向图来描述自动机,以节点描述状态,以有向边来描述状态之间的转移。
下图描述了一个自动机:
我们令
当我们接收到一个
如果我们此时在
如果我们此时在
这代表着上图的自动机并不接受
02. 后缀自动机的引入
一个 DFA,它能接受一个字符串的所有子串。
SAM(Suffix Automaton,后缀自动机)是满足该条件的节点数最少的 DFA。
在下文中可以证明,SAM 的节点个数至多为
举个例子,下面图片是
我们令
当我们接收到不合法的信号比如
大家可以试试其他
引入 1.【后缀树】
有的资料以 parent 树引入,注意,它们不是同一个东西。(事实上,反串的 parent 树就是原串的后缀树。)
在本文中我们以后缀树作为 SAM 的引入,不再提及 parent 树。我个人认为以后缀树作为引入会比 parent 树直观、好理解很多。
后缀树是这样的:
- 把一个字符串的所有后缀拿出来建立成 Trie 树;
- 节点太多了,保留“端点”形成的虚树(如果你不了解什么是虚树,看后面的图)。
比如对于字符串
注意到节点数过多,还注意到只有红色节点的出度大于
这就是
除特殊说明外,下文的后缀树都是指压缩路径(虚树)后的后缀树。
先记着这个,后面要用。
引入 2.【endpos 集合】
- 对于一个字符串的某个子串,其 endpos 集合为其在原串中所有出现位置的右端点。
举个例子:
对于字符串
注意到 endpos 有个性质:对于任意两个原串的子串
我们还能注意到:
- 两个 endpos 集合不同的串不可能共享 SAM 上的同一个节点,否则转移是不确定的;
- SAM 会保证任意两个 endpos 集合相同的串共享一个节点,因此 SAM 的节点数(状态数)是最少的。
简单来说,就是可行的(存在的) endpos 集合与 SAM 上的节点形成了双射。这一点后面要用,请留意。
我们让 SAM 的每一个节点表示一个 endpos 集合。
比如我们拿出上文
我们可以发现:
无论我们走
它们所对应的子串(
03. 后缀自动机的构建
我们需要在
讲完了后缀自动机的概念和一些辅助的东西(endpos 和后缀树),它那么可爱,应该如何构建呢?
后缀树、endpos 集合与 SAM 之间的联系
我们考虑构建一个串的反串的后缀树。
举个例子,比如有一个串是
我们在上文已经构建好了(见【引入 1】):
后缀树是把一个串的所有后缀拿出建 trie 树,然后再压缩。
我们对反串建后缀树,是拿出反串的后缀建 trie 后压缩,这等价于拿出原串的前缀反着建 trie 后压缩。
比如
然后反着建 trie(即把所有的前缀的反串放进 trie 中)。
也就是往 trie 中分别加入
有点抽象是吧,看图:
我们把其对应的后缀树拿过来放在一起看,以上图第三行为例,当我们反着加入
注意到这一条路径是到叶子结点的。
我们发现一个第三行的
说明上图后缀树的
同样的,我们可以发现第五行反串
我们就得到了下图:
进一步的,如果我们按照后缀树上的边走,得到的串的反串(即原串的前缀),在原串的 endpos 集合为下图:
注意我们强行规定空串的 endpos 集合为所有位置(即上图
这时我们发现一个惊人的事实:叶子结点的 endpos 集合大小为
所以两个相同的 endpos 集合不会用两个点表示,同时同一个点也不会表示两个不同的 endpos 集合。
这说明 endpos 集合与反串的后缀树的节点也形成了双射!
前面我们证明过了 endpos 集合与 SAM 的节点形成了双射(在【引入 2】那里,原谅我没有用相同的串举例)。
这说明:
SAM 节点、endpos 集合相同的串、反串的后缀树节点,三者一一对应!
尽管我没有用相同的串举例,但是读者们可以去画一画,会发现确实是这样的。
这说明,我么可以利用后缀树来帮助构建 SAM,因为其节点是一一对应的。
从后缀树的角度构建
接下来可能不能用具体的例子了,请读者们做到全神贯注(真的很难讲懂这个东西啊)。
请注意,区别于上文,下文的后缀树的树边我们将用无向边。
我们假设已经构建了原有的字符串的后缀树,长这个样子:
我们构建 SAM,是在原有字符串的末尾添加一个字符。
这相当于新添加一个原串的前缀的反串,如图所示:
这在后缀树上表现为添加一条从根节点开始的链:
注意后缀树本质上是 trie 树,所以新加链,可能会存在一段相同的路径,之后再分叉,如上图。
在链中间新加节点是因为后缀树是压缩的 trie,压缩后原有节点可能不能满足新的链的需要。
当然也不一定是这样的,不仅有上图情况,也有可能有以下情况:
不管怎么样,总之新添加的节点个数一定
由于后缀树上节点与 SAM 上一一对应,这也证明了 SAM 的节点个数是
SAM 最初有
1 个初始节点,后面添加的2 个字符都只新增1 个节点,最后n-2 个字符新增至多2 节点,所以节点个数上限为3+2(n-2)=2n-1 。
构建真正的 SAM
铺垫了这么久,终于来了!
事实上 SAM 和反串的后缀树是同步构建的。为了在加入新字符的时候构建出新的 SAM,其反串的后缀树也是要维护的。
换句话说,我们需要的 SAM 实际上就是在后缀树上加转移边!!!
而后缀树上的树边并不是 SAM 的边,但后缀树上的点是 SAM 的点。
再说一遍,后缀树和 SAM 是共用节点的!!!
我们规定后缀树上儿子指向父亲的边叫做 SAM 的
假设下图是一个 SAM,下图红色的有向边叫做
注意注意注意!!!
- 注意区分树边(后缀树上无向边)、
link (SAM 上指向父亲的链接,不是边)和转移边(SAM 收到信号的转移的有向边)、fa (后缀树上节点的父亲),这很重要,容易混! - 注意区分下文表述的是后缀树还是 SAM,这也很重要,容易混!
- 由于 SAM 与后缀树共用点不共用边,除特殊说明外,凡是 SAM 的转移边我都用粉色,其余的要么是树边,要么是
link ,但link 我会画出方向,以便区分。
后缀树压缩前的平行关系
好的,我们继续。
每个 SAM 上的节点的
我们可以发现,在未被压缩的后缀树上(即不进行取虚树操作的 trie 上),SAM 的转移边构成了平行关系。
什么意思呢?假设我们有一个字符串
假设我们现在添加一个字符
我们可以观察到,最下面的两个叶子结点,经过后缀树的根节点走过来,组成了
我们称在 SAM 中,这两个节点代表了
换句话说,SAM 中每个节点代表了其后缀树上从根节点走过来的字符串的反串。
此时这个 SAM 需要转移边,下图是 SAM 新添的转移边:
表示了从代表
同理的,需要更多的转移边(忘记画根节点的转移了抱歉):
这些转移边表示了:从代表
是不是平行的?这就是所谓的平行关系。
我们可以得到以下性质:
- 在未经压缩的后缀树上,若其对应的 SAM 有转移边
x\stackrel{c}{\longrightarrow}y ,则有转移边fa(x)\stackrel{c}{\longrightarrow}fa(y) (注意不是link ,虽然指向方向是一样的,但是这是后缀树)。
当然,在压缩过的后缀树上也会有这样的平行关系,只不过因为被我们压缩了,所以长得很奇怪。
后缀树压缩后的平行关系
接下来讲压缩过后的。
重复一遍上文,在未经压缩的后缀树对应的 SAM 中,称一个节点代表其后缀树从根节点走过来的字符串的反串。
在 SAM 中,若其后缀树被压缩了,那么称这个节点代表:其后缀树从根节点开始,走到其后缀树的这个节点的父亲,与当前节点与其父亲的树边上的字符,所组成的字符串反串组。
很抽象是吧,其实不难理解:
左边的
-
ba -
aba -
baba -
cbaba
不理解的可以结合图多看几遍。
然后以字符串
可以发现,由于被压缩了,本来应该转移到后缀树树边上的 SAM 转移边,实际上转移到了其下方的点。
如何构建
接下来,我们称一个节点代表一个字符串,当且仅当其代表的字符串组中有这个字符串。
我们假设已经建好了原有串的后缀树:
我们需要维护一个
假设有一个字符串
下图绿色的路径就是
现在我们加入一个字符
有可能长这个样子:
我们考虑这之后 SAM 应该有的转移边可能是什么(注意此处不是粉色,而是红色):
但是由于后缀树被压缩了,实际上 SAM 是这个样子的(我们假设
于是我们得到一个初步的流程:
- 新建节点
cur (上图为节点7 )。 - 如果
las 没有c 的转移边,将las 往cur 连一条c 的转移边。 - 让
las\gets link_{las} (这是赋值符号,下同),即跳后缀链接(也即走到其后缀树上的父亲)。 - 重复
2,3 操作,直到发现是根节点或者有c 的转移边。
为什么可以“找到有
此时我们把没有
考虑接下来怎么做,具体的,我们要考虑怎么把
我们考虑上一个流程结束后,有
有两种可能的情况(第二种就是我们刚刚举的例子):
此时
我们令上图
解释一下为什么第二幅图
然后第一种情况很好解决,直接
考虑第二种情况,我们从最开始什么都没有来讲(注意黑色边的定义改成了
新建节点
然后让
此时把所有
然后
这样就把
我们上文讲过平行关系,即
这样就在原 SAM 基础上构建完新 SAM 了。
现在还有一个很重要的问题,怎么判断是第一种情况还是第二种?
我们考虑每个节点维护一个
我们考虑
所以我们只需要判断
然后如果是第二种情况,还要在新建节点的时候
注意刚开始的
04. 构建 SAM 代码
说了那么复杂,实际上代码超级短。
struct SAM
{
struct node{int len,lk;int e[26];}nd[N<<1]; // 每个节点维护 len、link、还有其转移边(字符集大小)
int cnt=1,las=1; // SAM 初始要有一个节点,表示空串
void insert(int c)
{
int cur=++cnt; // 新建 cur
nd[cur].len=nd[las].len+1; // 更新 cur 的 len
for(;las&&!nd[las].e[c];las=nd[las].lk)nd[las].e[c]=cur; // 不断跳 link,把 c 边都指向 cur,直到找到有 c 边的节点
if(!las)nd[cur].lk=1; // 如果根本找不到有 c 边的节点,直接将 cur 的 link 赋值为根节点编号即可
else
{
int p=las,q=nd[las].e[c]; // 与上文意义相同的 p,q
if(nd[q].len==nd[p].len+1)nd[cur].lk=q; // 第一种情况
else // 第二种情况
{
int cl=++cnt; // 新建 cl
nd[cl].lk=nd[q].lk,nd[cl].len=nd[p].len+1; // 与上文一样的操作
for(int i=0;i<26;i++)nd[cl].e[i]=nd[q].e[i]; // 注意转移边也要操作
nd[q].lk=nd[cur].lk=cl; // 像链表一样就把 cl 和 cur 插入进 SAM 中了
for(;las&&nd[las].e[c]==q;las=nd[las].lk)nd[las].e[c]=cl; // 跳 link,改原有的 c 边
}
}
las=cur; // 最后让代表原串的 las 变成 cur
}
}sam;
注意到上面代码有一个枚举字符集的操作(因为转移边也要复制)。
事实上,若字符集很大,我们还能使用 map
或者 unordered_map
来存边,复制的时候直接复制即可,相当于映射一个从 char
到 int
的东西。
这样做能降低枚举字符集的复杂度。
复杂度相关证明
-
SAM 点数至多是
2n-1 :上文已经证明过了,在【从后缀树的角度构建】那里。
-
SAM 边数至多是
3n-4 :我翻遍了全网都只找到了证明边数,所以我这里就证明\sout{\le 3n-2} 的(除 OI-WIKI 太抽象看不懂之外)3n-2 就好,事实上并不影响 SAM 的复杂度。- 我们知道 SAM 的转移边构成一张 DAG。
- 考虑在这个 DAG 上弄随便一颗生成树出来(现在跟后缀树没什么关系了啊,不要往那边联想)。
- 首先由于点数至多是
2n-1 所以树边最多是2n-2 。现在我们要证明非树边是\le n 的。 - 考虑搞两个集合:
E=\{\texttt{非树边}\} ,S=\{\texttt{原串所有后缀}\} ,由于原串所有后缀是\le n 的,我们只需要考虑证明存在一个单射函数f:E\to S ,即可证明|E|\le n 。 - 考虑构造这个单射函数,对于每条非树边
u\to v ,我们给其分配一个后缀(分成3 段):- 从生成树根节点开始到
u 的串,由于是生成树,这样的串存在且唯一; - 走非树边
u\to v ; - 从
v 走到任意一个终止节点(一个节点是终止节点,当且仅当任意一个原串的后缀,作为信号,在 SAM 上转移到的最终的节点)。
- 从生成树根节点开始到
- 注意第三段不一定要走树边,且我们只任取一个。
- 我们把这三段拼起来就是原串的一个后缀,证明显然。
- 考虑证明这个玩意是单射的:
-
注意我们分配的东西有这个性质:
u\to v 分配到的后缀,在 SAM 中转移遇到的第一条非树边就是u\to v 。由于只有一个“第一条非树边”,所以如果两条非树边被映射到了同一个后缀,它们一定是相同的两条非树边。
也就是说,一个后缀只会对应一个非树边,由于一个非树边只对应一个后缀,所以它貌似是双射的(?
- 这就证完了,
好像有点不严谨?但其实无所谓啦。
-
SAM 时间复杂度为
O(n) :我没证出来,真的没证出来,读者看 OI-WIKI 吧,或者自己找资料(逃。
尾言
后缀自动机这个东西真的很难理解啊,本人断断续续学了将近
但是我也不保证我这篇学习笔记完全正确或者讲述得完全清楚,我只是自认为讲述得挺清楚的且应该没有错误。所以如果有错误或者讲述得不清楚的地方,欢迎私信或评论指出。
在代码的海洋里,我们都是追逐数据结构星光的旅人。后缀自动机在时光里生长,每一道转移边都凝结着前人智慧的结晶。或许此刻你正为 SAM 的构建而困惑,为 link 链接的添加而辗转,但若将视角拉长,这些字符终将编织成照亮前路的银河。
对所有 OIer 而言,那些调试到凌晨的夜晚,那些反复优化的常数,那些 AC 后颤抖的指尖,都将沉淀为生命里珍贵的底色。无论未来是站在领奖台的聚光灯下,还是转身走向更广阔的天地,请记得:你曾在 OI 的星空中留下过属于自己的轨迹。
愿我们永远保持对未知的好奇,像 SAM 处理字符串般优雅地接纳所有可能的挑战。无论未来走向何方,这段为 OI 燃烧的岁月,都将是我们回望时最璀璨的星辰。前路漫漫亦灿灿,愿所有努力终不被辜负,AC 常伴,未来可期。