后缀自动机 (SAM) 的构造及应用

cnblogs 怎么又炸了。
为什么又可爱又强的 xxn 去年 9 月就会的科技樱雪喵现在还不会呢 /kel。

感觉 SAM 的教程已经被前人写烂了啊。那就写点个人学习过程中对 SAM 的理解。
参考资料:KesdiaelKen-史上最通俗的后缀自动机详解OI wiki-后缀自动机 (SAM)

upd on 2023.10.25:补充了时空复杂度证明。

概述

SAM 是一个能够在线性时间内解决很多字符串问题的算法。

比如对于 s="abb",构造出的 SAM 形如下图:

看得出来,它是一张有向无环图,其中每条边上标有一个字母表示转移。在图上的任意一条路径,把它经过的边上的字符接起来,都是 s 的一个子串。相应地,s 中的任意一个子串都能在 SAM 中找到至少一条对应的路径。
设 SAM 的起点为 t0,则字符串 s 的任意一个后缀都存在唯一一条从 t0 出发的路径与之对应。

SAM 就是用来构造满足上面条件,且点数与边数均最小的图的算法。
SAM 的点数(状态数)上界为 2n1,边数(转移数)上界为 3n4,时空复杂度均为 O(n)。这些将在后文给出解释与证明。


在构建 SAM 之前,先要了解一些相关的概念和结论。

endpos

假设 ts 的一个非空子串,我们定义 ts 中的所有结束位置构成的集合为 endpos(t)。例如对于 s="abaab"endpos("ab")={2,5}
那么 s 的所有子串 t,可以根据它们 endpos 的不同,将子串划分为若干个等价类。
为方便后文对 link 性质的总结,这里定义空串 endpos("")={0,1,,|s|1,|s|}

而这也就是 SAM 中节点的定义。SAM 中的每个节点恰好对应地表示一个 endpos 等价类,所有从 t0 到这个点的路径表示的子串,它们的 endpos 都相同,并与以其他点为终点的均不同。
因此,SAM 的节点个数即为 s 所有子串不同的 endpos 等价类个数 +1(起点)。

根据 endpos 的定义,它有如下性质:

性质 1:若子串 uw 属于同一等价类,且 |u|<|w|,则 u 每次出现在 s 中,都作为子串 w 的后缀。

反证,若存在 u 在某个结束位置 x 出现,且它不是 w 的后缀,那 w 没法也在 x 位置出现,与它们属于同一 endpos 等价类矛盾。

性质 2:对于两个非空子串 uw|u|<|w|),必然满足 endpos(u)endpos(w)=endpos(w)endpos(u) 其中之一。

  • uw 的后缀:所有出现 w 的位置一定会同时存在子串 u,而出现子串 u 的位置不是一定有 w。故此时 endpos(w)endpos(u) 成立。
  • u 不是 w 的后缀:依旧反证,如果有一个位置同时是它们两个的 endposu 一定得是 w 的后缀,与条件矛盾。此时 endpos(u)endpos(w)= 成立。

性质 3:将一个 endpos 等价类所包含的所有互不相同子串按长度升序排序,任意两串长度不相等,每个串都是下一个串的后缀,且长度差为 1

  • 若存在两个串长度相等,endpos 相同,那这两个串一定相同,与子串互不相同矛盾。
  • 设存在两个串 uwendpos 相同,且 |w||u|>1。设 vw 的一个后缀,且 |u|<|v|<|w|。那么由性质 1 知,uv 的后缀。那么由性质 2,得 endpos(w)endpos(v)endpos(u)。又因为 endpos(u)=endpos(w),则 endpos(w)=endpos(v)=endpos(u)。证得 v 也必定属于该等价类。

对于 SAM 上除 t0 外的点 v,设它包含的子串中最长的一个是 w。定义这样的 w 的长度为 len(v)
根据上文的性质 3,我们知道:一段连续的,长度在 [x,|w|] 之间的 w 的后缀属于该等价类。在这里,我们定义点 v 的后缀链接 link(v) 表示 w 最长且 endpos 不等于 v 的后缀所属的等价类节点。

对于 link(v),有如下性质:

性质 4:把所有 vlink(v) 连边,则后缀链接构成一棵以 t0 为根的树。

  • 根据定义,link(v) 包含的子串均为 v 子串的后缀。那么跳后缀链接过程中,子串长度单调递减,最后必然能跳到空串,即 t0

性质 5:对任意节点 v,都有 endpos(v)endpos(link(v))

  • 由性质 2 和后缀链接的定义,link(v) 包含的子串是 v 包含的后缀,故 endpos(v)endpos(link(v))
  • 它们一定不相等,因为如果相等应该合并成一个点。

同时,我们得到关于 minlen(v) 的表达式:minlen(v)=len(link(v))+1


正片开始

SAM 的构造

这是一个在线算法,也就是说我们逐一加入字符,并对应地构造出当前字符串的 SAM。现假设已经构造完了串 s,要在 s 后面添加一个字符 c
这里先粘个板子,再逐一解释每句代码的含义。

void add(int c)
{
    int p=lst,np=lst=++tot;
    d[np].len=d[p].len+1;
    for(;p&&!d[p].ch[c];p=d[p].fa) d[p].ch[c]=np;
    if(!p) {d[np].fa=1;return;}
    int q=d[p].ch[c];
    if(d[q].len==d[p].len+1) d[np].fa=q;
    else
    {
        int nq=++tot; d[nq]=d[q];
        d[nq].len=d[p].len+1,d[q].fa=d[np].fa=nq;
        for(;p&&d[p].ch[c]==q;p=d[p].fa) d[p].ch[c]=nq;
    }
}

背下来背下来。

int p=lst,np=lst=++tot;
d[np].len=d[p].len+1;

在已有的字符串后面接了一个 c,考虑多了哪些子串:原来 s 的每个后缀(包含空串)后面接一个 c
lst 为原来表示整个 s 串的点,即 endpos=|s| 的点;那么 s+c 这个字符串用现在的 SAM 肯定表示不出来,我们要建一个新点 np,并连一条 lstnp 的边来表示新串。
那么这个新点包含的最长子串长度显然为 |s|+1,即 len(lst)+1

for(;p&&!d[p].ch[c];p=d[p].fa) d[p].ch[c]=np;
if(!p) {d[np].fa=1;return;}

我们现在的 SAM 表示出了 s+c 这一整个子串,接下来我们要让 s 的每个后缀后面都接上 c 这个转移。
对于第一个循环,p=d[p].fa 就是枚举 s 的所有后缀,并让它向 np 连边。如果循环过程中没有发现一个 p 连过 c 这条边,代表新串的所有后缀都没有在原串中出现过,它们的 endpos 都是 |s|+1,那么根据 link 的定义,第一个不属于该等价类的后缀就是空串,即 link(np)=t0
否则如果跳的过程中发现有一个 p 连过 c 这条边了,那么 p 的后缀链接自然也都连过 c 边,无需继续循环;则接下来的后缀都是以前在 s 里就出现过的子串,要把这种情况拿出来继续讨论。

int q=d[p].ch[c];
if(d[q].len==d[p].len+1) d[np].fa=q;

这里需要讨论 len(p)len(q) 的关系。
考虑 len(q)=len(p)+1 的实际含义。感觉这里很神秘啊。
对于所有连向点 q 的点 p,里面显然只有一个点满足 len(q)=len(p)+1。那如果这条边正好是 c,就代表 longest(q)=longest(p)+c
根据 endpos 的性质 3,这就保证了 q 包含的所有子串都是新字符串的一个后缀。它们的 endpos 集合都增加了一个 |s|+1,依旧属于同一个等价类,不用改动。
link(np) 自然也就是 q

else
{
    int nq=++tot; d[nq]=d[q];
    d[nq].len=d[p].len+1,d[q].fa=d[np].fa=nq;
    for(;p&&d[p].ch[c]==q;p=d[p].fa) d[p].ch[c]=nq;
}

到了最抽象的部分!
len(q)>len(p)+1 的话,longest(p)+clongest(q) 的一个后缀。也就是说,q 包含的所有子串中,只有长度不大于 len(p)+1 的这部分后缀出现次数又增加了 1。我们被迫把原来全部属于 q 的子串分进两个不同的等价类里面。
新建一个节点 nq,表示从 p 转移过来,出现次数增加了 1 的这部分子串。虽然这部分被单独分出来了,但它的后续转移跟新加的 c 并没有关系,可以从 q 直接复制过来。
考虑被分出来的子串是 q 中比较短的那一部分后缀,而且它们也是新串的后缀。故有 link(q)=link(np)=nq
循环的作用是把原来连在 q 的边都改到 nq 上去。

至此,我们成功地构建了一个 SAM 。

正确性证明

upd: 来补个证明。

状态数

SAM 的状态数上界为 2n1

这可以从 SAM 的构建过程中直接看出:插入前两个字符时一定只会新建一个状态,而后面假设每次都需要新建状态,至多会建出 2(n1) 个。算上初始节点,总数为 2n1
该上界可以在字符串形如 abbb...b 时取到,因为它从第三个字符开始每加一个 b 都会产生新的 endpos 等价类。画出来大概长这样:

img

转移数

SAM 的转移数上界为 3n4

我们把满足 len(q)=len(p)+1 的转移称作连续的,考虑这样的转移个数。观察代码,可以发现除初始状态以外的每个状态 q 有且仅有一个 p 满足 pq 的转移连续。
那么连续的转移数上界为状态数上界减 1,即 2n2

接下来证明不连续的转移个数。设 pq 的转移是不连续的,这条转移边上的字符是 c
我们考虑找出经过这条转移边的最长字符串。设 u 为从初始状态到 p 的最长字符串,v 为从 q 到某个终止状态的最长字符串。根据最长的性质,uv 上的转移都是连续的。这也就是说,经过每条不连续的转移边的最长字符串均不相同。
再考虑 u+c+v 这个串的性质,根据 SAM 的定义,一条从 t0 出发走到某终止状态的路径唯一对应原串的一个后缀。那么 u+c+v 是原串 s 的后缀,且不能是完整的 s, 因为这样所有转移都应该连续。
故我们证明了不连续的转移至多有 n1 个。

但是 (2n2)+(n1) 等于 3n3,为什么多了一个呢?
这是因为两种转移数不可能同时取到上界。我们令连续的转移取上界,即状态数取上界时,字符串形如 abbb...b。而此时根据上图不连续的转移数量显然取不到 n1。因此我们得到更精确的上界为 3n4,这可以在 s="abbb...bbc" 时取到。它长成这样:

构建的时间复杂度

证明了状态与转移的数量,我们来证明 SAM 的构建过程也是线性。

代码里只有两句带循环,对它们依次进行分析。

for(;p&&!d[p].s[c];p=d[p].fa) d[p].s[c]=np;

这句的作用是新连一条转移边,根据转移数线性的结论,我们执行这个循环的次数是线性。

for(;p&&d[p].s[c]==q;p=d[p].fa) d[p].s[c]=nq;

首先我们知道:设 f=link(nq),根据代码可以看出一定不会存在 fnq 的不连续转移边。

考虑每次进行完该循环后的 fail 树。此时有 link(lst)=nq,同时由于 link(nq) 是原来的 link(q),而且原来的 p 还是不连续转移,link(nq) 不会是某个遍历到过的 p。那么下次再插入时,从 lst 开始跳 fail 树,必然不会把这些重定向过的边再重定向一遍。那么每个边最多被重定向了一次,为线性。

综上,构建 SAM 的总时间复杂度为线性。

基础应用

貌似 SAM 能做的事 SA 也差不多都能,所以合一起口胡一下做法。

判断字符串是否出现

  • SAM
    把文本串的 SAM 建出来。根据任意一个子串都能被 SAM 上一条以 t0 为起点的路径表示的性质,从起点开始顺着模式串字符的转移边跑,能跑完整个模式串就是出现了。

  • SA
    对文本串 s 跑 SA,模式串如果出现就一定是 s 的一个后缀的前缀,在排序后 s 的所有后缀中逐位二分即可确定它所在的位置。

不同子串个数

  • P4070 [SDOI2016] 生成魔咒

  • SAM
    两种做法。
    从 SAM 上转移边的角度考虑,不同的子串个数即为从 t0 出发的不同路径数。设从点 u 出发的路径数为 fu,则有转移:fu=1+uvfv,答案为 ft0
    从后缀树的角度考虑,我们知道 minlen(v)=len(link(v))+1,则属于点 vendpos 等价类的子串个数为 len(v)len(link(v))。总个数为每个节点的答案之和。

  • SA
    不同子串个数本质上求的是 n(n+1)2i=1nj=i+1nmin{heighti,heighti+1,,heightj}。使用单调栈处理出 i 左右首个比自己小的数的位置,即可统计贡献。似乎也可以从大到小填数,用并查集维护两边的 size

字典序第 k 大子串

  • P3975 [TJOI2015] 弦论

  • SAM
    预处理从每个点出发的路径数,那么可以快速地判断是否要走当前转移边。按字典序从小到大枚举转移边,直到确定第 k 个子串包含在里面即可。
    如果本质不同的算成多个,就在处理 fu 时把同一个点包含的子串个数计入贡献。

  • SA
    排序后从前往后枚举每个后缀,它对答案的贡献就是总长度去掉 height
    对于本质不同算多个好像挺麻烦的,具体可以看 这篇博客

子串出现次数

  • P3804 【模板】后缀自动机(SAM)

  • SAM
    找到该子串在 SAM 上对应的点,从这个点出发能走到的终止状态数就是子串出现的次数。这个东西可以在后缀树上 DP,注意求的不是从这个点出发的路径数。

  • SA
    与判断字符串是否出现方法一致,使用二分找到以该子串作为前缀的 sa 区间。

最长公共子串

  • SP1811 LCS - Longest Common Substring

  • SAM
    要求 ST 的最长公共子串,我们先对 S 构造 SAM,然后再对 T 在 SAM 上进行匹配。具体地,对于 T 的每个前缀,我们在 S 上找最长的(T 的这个前缀)的后缀。
    听起来很绕。换句话来讲:逐一往 T 后面添加字符,维护当前 T 的后缀在 S 上最多匹配到哪里。我们维护两个变量 nowlen,表示当前 SAM 上匹配到的节点 & 匹配的字符串长度。
    考虑在 T 末尾新加入字符 c 对答案的影响。若 now 有字符 c 的转移边,则沿边转移,lenlen+1;否则不断跳 link(now) 直到存在该转移边,并令 len=len(now)。在这个过程中记录 len 的最大值即可。
    关于这里为什么令 len=len(now) 是对的:如果实际匹配的长度大于 len(now),那在上一次跳 link 的时候就应该能转移。如果实际匹配的长度小于 len(now),那加入 c 之前,原来的 nows 根本对不上,与上一轮已经匹配正确的前提矛盾。

  • SA
    相比之下 SA 处理这个就很简单,把两个字符串接在一起,最长公共子串所属的后缀,排完序肯定挨在一起,所以满足 saisai1 不属于同一个串的 height 的最大值就是答案。

多个串的最长公共子串

  • SP1812 LCS2 - Longest Common Substring II

  • SAM
    看到两种主流做法。
    一种是 OI-wiki 说的把所有串并在一起跑 SAM,然后根据特殊字符判断。
    另外一种是对最短的子串建 SAM,并依次考虑其他串,在 SAM 上记录每个点的最大匹配长度的最小值。然后答案是(这些最小值)的最大值。(禁止套娃。

  • SA
    把所有串并在一起跑 SA。二分最长公共子串的长度 k,对于每个 height(i)k 的连续段判断是不是在每个串中都出现过即可。

于是学个 SAM 板子学了一天,并且可以预见到未来的几天内我又会把它忘得一干二净。什么时候能变得有效率呢!

那就完结撒花吧 >w<

posted @   樱雪喵  阅读(386)  评论(1编辑  收藏  举报
相关博文:
阅读排行:
· 全程不用写代码,我用AI程序员写了一个飞机大战
· DeepSeek 开源周回顾「GitHub 热点速览」
· 记一次.NET内存居高不下排查解决与启示
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· .NET10 - 预览版1新功能体验(一)
点击右上角即可分享
微信分享提示