后缀自动机

看了一篇博文,写得很好,让我这个大人机弄明白了 SAM 是什么。

参见

SAM 的定义

SAM,后缀自动机。储存了一个字符串的后缀信息。

  1. SAM 是一张 DAG,节点称为 状态,边称为 转移

  2. DAG 有一个 初始状态(节点) PP,其余所有状态(节点)可以通过一些转移(边)到达。

  3. 每一个转移(边)标记 了字符集中的一个字母(边权)。从一个状态(节点)出发的所有边(转移)互不相同。

  4. 将从初始状态(节点)到一个 终止状态(节点)经过的所有转移(边)上的标记连接起来,就能得到原串的一个后缀。反之,原串的 任意 一个后缀可以被表示为从初始状态(节点)到某一个终止状态(节点)的路径。

  5. 在满足上述条件的 DAG 中,SAM 所使用的节点数最少,为 O(n)O(n) 级别,其中 nn 是原串长度。(最小性)

正是因为最小性,才能满足我们日益增长的效率需要。所以我们要研究这个 SAM,来使得人机能通过 图灵测试 字符串毒瘤题。

注意到原串的后缀的前缀对应了原串的子串,从起始节点走到终止节点,表示一个后缀,而这条路径上除起点以外任意一个节点都表示原串的一个非空子串。不难发现,由于 SAM 能表示任意后缀,任意子串也能被 SAM 表示,这样 O(n)O(n) 个节点压缩了许多信息。

后缀链接

后缀链接通过连接 SAM 中的节点,形成了一棵树的结构。

结束位置与等价类

约定字符串下标从 11 开始。

假设字符串 SS 有子串 ss,定义 endpos(s) 表示 ssSS 中,每一次出现的 结束位置,组成的集合。由于 ss 是一个子串,则 endpos(s) 必定非空。

S="abab"
        s =  "a"  "b","ab"  "aba","ba"  "abab","bab"
endpos(s) = {1,3}  {2,4}        {3}          {4}

上面是一个例子。

显然,两个子串的 endpos\text{endpos} 可能相同,如果两个子串 endpos\text{endpos} 集合相同,称这两个子串处于同一个 等价类 中。

注意 endpos\text{endpos} 是数集,是结束位置下标集合。等价类是字符串集合。

SAM 上的一个节点对应一个等价类。

引理

画画图感受一下,这些都是对的。

  1. 两个子串 endpos\text{endpos} 集合相同,等价于,一个子串是另一个子串的后缀 并且 较短串只作为较长串的后缀出现。

  2. s1s_1s2s_2 的后缀,且 s1s_1 较长。则较短串 endpos\text{endpos} 集合包含(不一定真包含)较长串的 endpos\text{endpos}。否则,两集合交集为空。

    是后缀时,较长串出现时,较短串必然出现,较短串出现时较长串不一定出现。

    不是后缀时,假设存在两集合交集非空,取交集中任一位置,则两个串均以这个位置为结尾出现过,则较短串必定是较长串的后缀,与假设矛盾。

  3. 一个 endpos\text{endpos} 集合不包括两个等长但是本质不同的子串。

    如果等长,出现位置又完全一样,难道有可能本质不同吗?

  4. 一个等价类中,任意两个串,较短者为较长者的后缀。

    由引理 1 可以得到。有了这个引理,一个等价类中,其余串均是最长串的后缀。

  5. 若一个等价类中最短字符串为 ll,最长字符串为 rr,则 [l,r][l,r] 之间的长度均在该等价类中出现过。

    可以反证,假如有一个长度没有出现过,显然是不可能的。

后缀链接的定义

上面提到,SAM 中每一个节点对应一个等价类,设 ss 为一个等价类中最长的字符串。

记串 tt 为最长的,不属于 ss 的等价类的,ss 的一个真后缀。ss 的等价类对应的节点的后缀链接连到 tt 对应的等价类。

link(s)=t\text{link(s)}=t

由定义得,设一个等价类 EE 中最短的字符串是 s0s_0,则 link(E)\text{link(E)} 中最长串的长度为 s01\lvert s_0\rvert-1

这启示我们,如果知道 link(E)\text{link}(E) 最长串的长度,就能得到 EE 中最短串的长度。所以 SAM 上只需要记录最短串长度即可。

由引理 2,endpos(E)\text{endpos}(E) 包含于 endpos(link(E))\text{endpos}(\text{link}(E))

所有后缀链接构成了一棵树的形态。对于每一个节点(等价类),其后缀链接节点中最长串长度,小于此节点最短串长度。则沿着后缀链接遍历,每一个等价类中的串长度只会减少,不会成环。此外,除初始节点 PP 以外,每一个点只有至多一条出边,且从任意节点一直跳后缀链接均可以跳到 PP,故此图连通。则后缀链接形成了树的形态。

小结与记号约定

  • 原串 SS 每一个子串根据 endpos\text{endpos} 划分为多个等价类,每一个等价类对应 SAM 上的一个节点。

  • 对于每一个节点(等价类)vv,定义 long(v)\text{long}(v) 表示其最长的串,len(v)\text{len}(v) 表示其长度;short(v)\text{short}(v) 为最短的串,其长度可以表示为 len(link(v))+1\text{len}(\text{link}(v))+1

  • 从任意点 v0v_0 出发总能到达 PP,经过的节点 viv_i 的等价类表示的串,均为 long(v0)\text{long}(v_0) 的后缀,且长度 [1,len(v0)][1,\text{len}(v_0)] 恰好各出现一次。

  • 所有后缀链接形成一棵以 PP 为根的根向树。

  • 后缀链接也可以表示 endpos\text{endpos} 的包含关系:父亲节点包含儿子节点。

  • 注意,后缀链接和 SAM 实际上是共用点,而各自有边的两张图。SAM 是一个 DAG,描述了字符串的转移,后缀链接树是树,描述了等价类的包含关系。

SAM 的线性构建算法

先从算法流程出发,再分析原理。

算法流程

此算法是一个 在线算法,每一次在已经构建好的串 SS 的末尾插入一个字符 cc,对已有信息进行维护。

  1. lastlast 为上一次构建结束后停在的节点处,这个节点表示 SS 所属的等价类。在插入结束部分对 lastlast 进行更新。

  2. 现在插入一个字符 cc。新建一个节点 curcur,将 len(cur)\text{len}(cur) 设置为 len(last)+1\text{len}(last)+1

  3. lastlast 开始遍历 lastlast 的后缀链接,如果当前节点 vv 没有 cc 的转移,则加入一条 vcurv\rightarrow cur 的边,标记(边权)为 cc

  4. 如果遍历到了起始节点 PP,则到第 8 步。

  5. 如果遍历到节点 vv 时,发现 vv 有到 cc 的转移,记当前节点为 pp,其转移到 qq

  6. 如果 len(p)+1=len(q)\text{len}(p)+1=\text{len}(q),将 link(cur)\text{link}(cur) 设置为 qq,转到第 8 步。

  7. 否则,创建一个 copycopy 节点,复制 qq 的所有信息到 copycopy 中,包括 linklink 和所有出边。将 len(copy)\text{len}(copy) 设为 len(p)+1\text{len(p)}+1

    然后,设 link(q)=copy\text{link}(q)=copylink(cur)=copy\text{link}(cur)=copy

    pp 遍历后缀链接,设当前遍历到了点 vv,若 vv 有边权为 cc 的出边,且连向 qq,则将这条边连向 copycopy

    如果 vv 没有边权为 cc 的出边,或是出边不连向 qq,则停止遍历,转到第 8 步。

  8. lastlast 赋值为 curcur,转到第 1 步。

算法原理

  1. lastlastSS 串所属的等价类。

  2. 现在插入字符 cc 之后,必然产生一些新的子串,这些子串是 S+cS+c 的一个后缀,其 endpos\text{endpos} 集合必然包含新的末尾位置。所以一定会产生新的等价类,新建一个 curcur 节点记录该等价类的信息。此等价类的最长串显然是 S+cS+c,其长度为 len(last)+1\text{len}(last)+1

  3. lastlast 遍历后缀链接,遍历到的节点均为 SS 的后缀,且一直没有遇到有 cc 转移的。考虑某一个后缀 ss 没有到 cc 的转移,那么 s+cs+c 必然属于 S+cS+c 这个等价类集合。

  4. 如果走到了初始节点 PP 也没有遇到转移 cc,说明 cc 在原串中没有出现过,此时直接结束即可。

  5. 如果遇到一个节点有到 cc 的转移,就需要进行一些维护。

  6. 由于 endpos(p)\text{endpos}(p) 的真包含 endpos(last)\text{endpos}(last),必定存在一些位置在 endpos(p)\text{endpos}(p) 而不在 endpos(last)\text{endpos}(last) 中。且 long(p)\text{long}(p) 是满足这个条件的最长的串。

    由于 pp 有边权为 cc 的转移,故 long(p)+c\text{long}(p)+c 是原本存在的,且这个串是满足,最长的,与 S+cS+cendpos\text{endpos} 集合不同的串。

    如果 len(p)+1=len(q)\text{len}(p)+1=\text{len}(q),则 long(p)+c\text{long}(p)+cqq 等价类中最长串,直接将 curcur 的后缀链接连到 qq 即可。

  7. 如果不满足上述条件,说明 long(p)+c\text{long}(p)+c 不是 len(q)\text{len}(q) 中最长的串,那么需要将 qq 分裂为两个部分。

    一部分的长度在 (len(p)+1,len(q)](\text{len}(p)+1,\text{len}(q)] 之间,这部分并不会作为 S+cS+c 的后缀出现,故其 endpos\text{endpos} 集合与新的等价类不同。所以建立一个 copycopy 节点,将 qq 节点的信息复制到 copycopy 节点上(包括转移等),再将 copycopy 节点的 len\text{len} 设为 len(p)+1\text{len}(p)+1。前一部分仍然使用 qq 这个节点。

    修改后缀链接:

    • copycopy 继承的是 qq 中较短的一些后缀,其后缀链接应当为原来的 link(q)\text{link}(q)。事实上这一步在复制 qqcopycopy 的时候顺便进行了。

    • 上一段提到,前一部分的 endpos\text{endpos} 集合应当是这一部分的真子集,根据定义,将 link(q)\text{link}(q) 修改为 copycopylink(cur)\text{link}(cur) 也修改为 copycopy

    还有一些东西需要修改:原本一些节点通过边权为 cc 的边转移到 qq,现在,有一些应当转移到 copycopy,另一些转移到新的 qq。沿着 pp 继续遍历后缀链接,假设到了点 vv

    • vv 有一条边权 cc 到达 qq 的边,由于 vvpp 的后缀(后缀链接定义),则 v+cv+c 一定在 copycopy 集合内,将这条边重定向到 copycopy

    • vv 有边权为 cc 但是不连向 qq,其一定连向 qq 在后缀链接树上的一个祖先,此时不能将其重定向。此时 vv 的祖先连向的必然也是 qq 的祖先,所以也不能重定向,此时无需继续遍历。

    • vv 可能出现没有边权为 cc 的边的情况吗?

SAM 的复杂度

假设字符集大小为 Σ\lvert \Sigma\rvert,字符串长度为 O(n)O(n),则:

  • 若每一个节点使用 O(Σ)O(\lvert \Sigma\rvert) 的空间,记录每一个边权的转移,还需要一些辅助的东西,做到 O(nΣ)O(n\lvert \Sigma\rvert) 的空间复杂度,O(n)O(n) 的时间复杂度。

  • 如使用 map 进行实现,则可以做到 O(n)O(n) 的空间复杂度和 O(nlogΣ)O(n\log \lvert \Sigma\rvert) 的时间复杂度。

当字符集较小(例如 26)时常用第一种做法。

SAM 的应用

统计子串

子串是前缀的后缀,在 SAM 的构造中,我们逐个向 SAM 中插入字符,则每一次新建的 curcur 节点表示前缀所属的等价类。curcur 节点在 link\text{link} 树上的祖先则是 curcur 的后缀,也就是前缀的后缀。于是,只要知道哪些节点是前缀所属的等价类,找这些点的祖先就能统计到所有子串了。

Problem A 【模板】后缀自动机

Link

求一个字符串 SS 每一个出现次数不为 11 的子串长度乘以出现次数最大值。

1S2×1061\le \lvert S\rvert \le 2\times 10^{6}

Solution

在每一个前缀节点打上一个标记,这个标记的作用是:将其所有祖先的出现次数加 1。这样,只要 DFS 一遍树,求子树内的标记数量,就能知道某一个点的出现次数,也即等价类的出现次数。根据等价类的定义,可以知道,等价类中所有字符串的出现次数是一致的,于是只需要考虑其最长的串,即长度为 len(v)\text{len}(v) 的串,用这个串贡献答案即可。

Problem B 【SDOI 2016】 生成魔咒

Link

初始有一个空串 SS,每次操作加入一个字符 xx,求每次操作后本质不同的子串数量。

1x109,1n1051\le x\le 10^{9},1\le n\le 10^{5}

Solution

考虑每一次插入之后会新增多少个本质不同的子串。容易发现,只有 curcur 等价类中包含的串才是第一次出现,其余串都已经出现过(在原有节点中)。根据后缀链接的性质,curcur 等价类包含长度在 (len(link(cur)),len(cur)](\text{len}(\text{link}(cur)),\text{len}(cur)] 的字符串,故其新增字符串数量为 len(cur)len(link(cur))\text{len}(cur)-\text{len}(\text{link}(cur))

由于此题值域较大,需要使用 map,如果用 unordered_map 可能会被卡。

Problem C LCS

LinkLink

求两个字符串的 LCS 的长度。LCS 指最长公共子串。

1s1,s22.5×1051\le \lvert s_1\rvert,\lvert s_2\rvert\le 2.5\times 10^{5}

Solution

一种方法是:在两个字符串中间插入一个神秘字符,使其不在字符集中(使得跨过两个串的子串不被统计)。如果插入的是第一个串中的字符,在结束的 curcur 节点上打标记 A,如果是第二个串,在结束的 curcur 上打标记 B。依然遍历整棵树,若一个点子树内既有 A 标记,又有 B 标记,这个点在两个串中都出现过,就将这个点计入答案。

另一种方法:将一个串插入 SAM,另一个串在 SAM 上游走。具体我没有实现,应该跟 AC 自动机上匹配差不多。

另外 这道题 也挺类似的,但是有点不同,可以做一做。

高级数据结构常见套路
NOI Linux 2.0 中的 Arbiter 的使用