后缀自动机学习笔记
前提
个人感觉 SAM 比其余的字符串算法更具有记录价值,像 KMP,AC 自动机,SA 等算法都能或多或少被 SAM 替代,除了像马拉车这类特殊处理回文的算法和基本子串结构这类高深算法之外,SAM 基本上就是能接触到的最顶端的东西了。
这可能是我退役前写的最后一篇学习笔记了。
SAM 是什么
SAM 通俗说就是利用一个字符串(字符集任意)的后缀性质利用 \(O(n)\) 级别的信息表示出整个字符串绝大部分子串信息的结构。
SAM 的结构
设要建出 SAM 的字符串为 \(s\),下标从 \(1\) 开始,长度为 \(n\)。
类似于一张有向无环带权图(边权是字符),但是这张图中又有一棵树,每个结点既是图上的结点又是树上的结点,我们记初始空结点为 \(st\),那么在图上,由 \(st\) 出发的一条有向路径上的字符组成的字符串都是 \(s\) 的子串,且 \(s\) 的所有子串都能用这种方式表示出来。
endpos 集合
我们定义 \(s\) 的一个子串 \(t\) 在 \(s\) 中所有出现末位置所构成的集合为 \(t\) 的 endpos 集合(在 \(s\) 中)。
关于 endpos 集合有一些显然的性质:
- 设目前 \(t'\) 为 \(t\) 的一个后缀,那么 \(t'\) 的 endpos 集合包含于 \(t\) 的 endpos 集合,其逆命题同样成立。
- 对于 \(s\) 的任意两个子串 \(p, q\),其 endpos 集合要么为包含关系,要么为不交关系(若有重合则必然有相同的结束位置,则必然有后缀关系)。
其实第二点就有点感觉了,要么包含要么不交,这正是建树的一个条件之一。
我们定义 endpos 集合相同的子串所构成的集合为 endpos 等价类(等价类是极大的),那么又有一些很重要的性质:
- endpos 等价类里的子串必然两两为后缀关系,且长度连续。
- 每一个 endpos 等价类都在 SAM 上恰好对应一个状态(你也可以说是结点),你只需要知道构建 SAM 可以维护这个性质就足够了。
对于第一个性质,显然第一个描述可以由上述推导,我们想一下第二个描述。
假设长度不连续,令这个 endpos 等价类为 \(S\),不连续意味着其中必有至少一个子串 \(w\) 将其长度隔开(\(w\) 与其中子串为后缀关系),这里就令其只有一个,则 \(S\) 里的子串必然可以分成两类,一类长度大于 \(w\),一类长度小于 \(w\),根据上述定理,长度小于 \(w\) 的子串的 endpos 集合包含 \(w\) 的 endpos 集合,\(w\) 的 endpos 集合包含长度大于 \(w\) 的 的子串的 endpos 集合,有由于两种子串的 endpos 集合相同,所以 \(w\) 的 endpos 集合必然也与这些子串的 endpos 集合相同,则 \(w\) 必然可以加入这个 endpos 等价类。
上与基本性质非常重要,在构建 SAM 的过程中与做题过程中可能都要用到。
link 链接
对于一个 \(s\) 的子串 \(t\),令 \(w\) 为 \(t\) 的后缀中与 \(t\) 的 endpos 集合不同的最长的那个后缀,令 \(t\) 的 endpos 等价类结点为 \(p\),\(w\) 的 endpos 等价类结点为 \(q\),我们定义 \(link(p) = q\),若不存在这样的 \(w\),则 \(link(p) = st\)。
关于 link 链接,有一个很重要的性质是:
- 整个后缀自动机的结点通过 link 链接构成一棵根为 \(st\) 的内向树,这就是我们上面说的那棵树,我们称它为 parent tree。
这个证明网上说的都很轻飘,我认为它是触及到 SAM 本质的第一步,我会尽量将这个结构给描述清楚。
我们取一个字符串并将其一些子串拿出来(这个字符串前面后面会有很多字符,用省略号表示):
...banana...
n
an
nan
anan
banan
由于上述性质,同一 endpos 等价类里子串长度连续,我们假设这 \(5\) 个子串中有 \(3\) 个 endpos 等价类,标出来:
...banana...
n(1)
an(1)
nan(2)
anan(2)
banan(3)
我们定义 \(s\) 中位置 \(x\) 对应的梯形集合为 \(\{s[x, x], s[x - 1, x], s[x - 2, x], ...\}\),我们姑且认为这个集合的有序的(这个定义我编的,网上找不到)。
目前就需要发掘一些比较本质的东西了:
- \(x\) 的梯形集合一定可以被按顺序划分为若干段(也有可能是一段),每一段都是一个 endpos 等价类(不仅要说明 endpos 集合都是相同,而且需要说明这是极大的)。
- link 链接的边只会存在于这些梯形集合中。
这些性质容易说明,但非常容易被忽视,这对构建 SAM 和做题是十分重要的。
在上述所示的梯形集合中,link 链接的方式就是第 \(3\) 个 endpos 等价类向第 \(2\) 个 endpos 等价类连边,第 \(2\) 个 endpos 等价类向第 \(1\) 个 endpos 等价类连边,第 \(1\) 的 endpos 等价类向 \(st\) 连边。显然,每个梯形集合连边都是一条链,且每个 endpos 等价类连边都会向上跳到 \(st\),因此会构成一棵树(下述可能以“父亲”代替 link 链接)。
另外,为了方便和构建 SAM,我们需要基于 link 链接定义一些东西:我们对于一个结点 \(u\),其对应一个 endpos 等价类,定义 \(maxlen(u)\) 为这个等价类中最长的子串长度。
为什么只定义 \(maxlen(u)\) 不定义 \(minlen(u)\) 呢?根据我们上述所示的结构,容易发现 \(minlen(u) = maxlen(link(u)) + 1\),因此只需维护这个东西,endpos 等价类里的子串长度区间就能维护出来了。
构建 SAM
SAM 的构建是一个增量算法,每次通过已有的结构,在末尾新加一个字符,进行一系列调整变动。
我们设原本字符串为 \(s\),新加的字符为 \(c\),且 \(s\) 所在的 endpos 等价类(等价类里必定只有 \(s\) 一个字符串)的结点编号为 \(lst\)。
首先我们考虑 \(s' = s + c\) 这个子串,也就是新的整个串,是在之前从未出现过的,因此需要新开一个 endpos 等价类,也就是新结点来存储,我们暂且将这个结点设为 \(lst'\),然后将 \(lst\) 向 \(lst'\) 连一条有向边。
但是不止于此,\(s\) 的所有后缀都可以通过这个新加入的字符 \(c\) 产生一个全新的或者之前出现过的子串,我们需要将这部分结构增添上去。我们发现,这些串就是 \(n + 1\) 位置的梯形集合,且这些串除了 \(s'[n + 1, n + 1]\) 之外都是由 \(n\) 位置的梯形集合末尾加上一个 \(c\) 表示出来的。
我们考虑从下往上(对于刚刚的图示)去跳 \(n\) 位置的梯形集合,并不需要跳每个字符串,因为这个梯形集合已经被划分为了若干个连续的 endpos 等价类,并且由 link 链接连接,我们可以从 \(s'\) 开始,通过 link 链接遍历这个梯形集合里的每个 endpos 等价类。如果遍历到的当前结点没有出边为 \(c\) 的有向边,证明之前这个等价类里的子串过加上 \(c\) 所构成的子串是没有出现过的,这是第一次出现,因此我们将其连一条边权为 \(c\) 的边连向 \(lst'\),因为这些子串加上 \(c\) 之后在整个字符串中没有出现过,所以这些加了 \(c\) 的子串应当与 \(lst'\) 处在同一等价类,符合连边的定义。
不难发现,我们将从 \(n\) 位置的梯形集合拎出来,从上到下从小到大依次排列,总会满足,下面一段 endpos 等价类里的子串加上 \(c\) 都是没出现过的,上面一段 endpos 等价类里的子串加上 \(c\) 都是出现过的,因此我们上述连边直接向上跳到第一个不合法退出就是对的了。此时,如果全部 endpos 等价类跳完了,则在 parent tree 上让 \(lst'\) 的父亲为 \(st\)。
接下来,我们思考复杂一点的情况,也就是上面那段 endpos 等价类里的子串加上 \(c\) 出现过怎么办。
我们让第一个不满足条件的 endpos 等价类对应的结点为 \(x\),此时 \(x\) 一定有一条为 \(c\) 的出边,我们令这条出边到的结点为 \(y\),那么我们分为以下两种情况进行讨论:
- \(maxlen(y) = maxlen(x) + 1\)
- \(maxlen(y) > maxlen(x) + 1\)
首先想一个简单的问题,为什么不会有 \(maxlen(y) < maxlen(x) + 1\),因为根据定义此时 \(x\) 中所有子串加上 \(c\) 之后能全部出现在 \(y\) 中,所以 \(y\) 的最大长度一定不小于 \(maxlen(x) + 1\)。
第一个情况,此时令 \(S\) 为 \(x\) 中所有字符串加上 \(c\) 之后构成了的字符串集合,那么意味着 \(y\) 中不存在比 \(S\) 中长度更长的字符串了,我们上述所谓的“上面一段不满足条件 endpos 等价类”其实已经给我们直接建好了且 \(y\) 的最长串长度恰到好处。因为梯形集合的缘故,所以 \(y\) 的 parent tree 上的祖先结点一定能满足将上面一段不满足条件的 endpos 等价类中所有的字符串加上 \(c\) 后全部覆盖且没有多的字符串。相当于上面的结点我不用建了,之前已经给我建好了,简单来说,就是在 parent tree 上让 \(lst'\) 的父亲为 \(y\)。
第二个情况,其实此时除开 \(y\) 以外,也就是 \(y\) 的祖先链,上面的 endpos 等价类已经给我们建好了,但是 \(y\) 这个结点不是很听话,不仅有 \(x\) 转移到的字符串,还可能有更长的字符串(这是可能的)。我们此时就要进行一个操作,就是将 \(y\) 给裂开,就是 endpos 集合突然多了一个位置一样,让后让小的那部分像第一种情况一样做,大的那部分连向小的那部分(parent tree 上的父亲)。
但其实想起来不是很困难,但是写起来会漏情况。具体来说,你将 \(y\) 给裂成两个结点 \(up, down\)(其出边与 \(y\) 一样),\(up\) 就是串长小的那个结点,\(down\) 就是串长大的那个结点,比较显然的,此时应该将 \(up\) 的 \(maxlen\) 更新成 \(maxlen(x) + 1\),让 \(down\) 的父亲变成 \(up\),根据第一种情况,应该将 \(lst'\) 的父亲连向 \(up\)。比较关键的,\(x\) 往上还有一段 endpos 等价类结点 \(c\) 的出边是连向 \(y\) 的(为什么是一段,可以考虑一下 endpos 的单调性),我们应该将其出边连到的点更新为 \(up\)(对于 \(down\) 这一块同理,但代码里不用实际处理 \(down\) 的过程)。这一段在实际代码体现上会有所不同,代码里会将 \(down\) 直接赋值为 \(y\),只会新建一个 \(up\) 结点,然后更改上述过程(这样就解决了处理 \(down\) 的入边问题)。
具体而言,代码实现:
void insert ( int c ) {
int x = last, cur = last = ++ cnt;
siz[cnt] = 1, sam[cur].len = sam[x].len + 1;
for ( ; x && !sam[x].ch[c]; x = sam[x].dad ) {
sam[x].ch[c] = cur;
}
if ( !x ) {
sam[cur].dad = 1;
}
else {
int y = sam[x].ch[c];
if ( sam[y].len == sam[x].len + 1 ) {
sam[cur].dad = y;
}
else {
int up = ++ cnt;
sam[up] = sam[y];
sam[up].len = sam[x].len + 1;
sam[y].dad = sam[cur].dad = up;
for ( ; x && sam[x].ch[c] == y; x = sam[x].dad ) {
sam[x].ch[c] = up;
}
}
}
}
如果你仔细了解了梯形集合的性质和基本定义,理解上面的构造过程应该不难。
SAM 的复杂度
有一些论文的较为复杂的证明说明了 SAM 在整个过程中时间与空间复杂度均为 \(O(n)\) 级别(可能有个倍数),其与字符集大小无关,上述实现空间复杂度因为为了好写所以带了字符串常数。
SAM 的应用
大该是搬的 OI-wiki。
Problem:多次检查 \(t\) 是否是 \(s\) 的子串。
从 \(st\) 开始,按照 DAG 上的连边看能否走下去即可。
Problem:计算 \(s\) 的不同子串个数。
答案就是每个结点上,endpos 等价类所代表的串的数量,用 \(maxlen(u) - maxlen(link(u))\) 求和即可(上述有提到)。
Problem:计算 \(s\) 的不同子串长度。
跟上面一样,同一个结点的串长度是一段连续区间,数学计算一下即可。

浙公网安备 33010602011771号