后缀自动机的一些证明

1.后缀自动机

后缀自动机是一种维护方式,利用它和求解它时用到的某些方法能快速且动态维护很多信息。

原本的后缀自动机,解决的是这么一个问题:

对于一个字符串建立拥有 \(O(n)\) 级别的点和边的图(每条边表示一个字符),使得从源点走到全部点的全部路径和字符串的所有子串一一对应

事实上,这种由状态转移到状态的DAG被称作自动机。然后你要求最小的自动机,所以这也是最小 DFA 问题)))

2.节点的设计

以上的限制实在是太多了,我们先来设计 \(n\) 个节点。考虑节点的意义,源点到某节点的任意一条路径表示一个字符串,故而每个节点对应一个字符串集合,也就是说我们要将子串划分为 \(O(n)\) 级别的集合。

然后,有大佬提出了如下构造方法:令一个子串在原字符串中所有出现位置的结束位置构成集合,称为 \(endpos\) ,并令 \(endpos\) 相同的点组成一个类。

可以发现,在一个字符串前面加入字符,则生成的字符串的 \(endpos\) 集合必为原串的子集,且加入不同的字符生成的 \(endpos\) 集合没有交集。所以所有子串的 \(endpos\) 集合相当于从空串开始分裂来。这构成一棵树,并且树的根节点 \(endpos\) 集合大小为 \(n\)

所以有效分裂的节点数为 \(O(n)\),也就是 \(endpos\) 集合的种类数为 \(O(n)\)

此时,我们把原串的所有子串划分为空串类在内的 \(O(n)\) 个类。按照分割关系,可以看出这些类呈现一棵树的形态,称为“parent树”。这棵树有一个重要性质,称每个节点所代表的字符串集合中的最长字符串为“代表串”,则树上的父亲节点的代表串,是儿子节点代表串的后缀。

3.自动机与parent树

为什么要用 \(endpos\) 集合来划分类?因为一个如此划分的类中的所有字符串结尾相同且开头连续,按照自动机的转移,向它们末尾加入一个字符,仍然呈现结尾相同,开头连续,方便划分。

而前缀的后缀们形成的字符串集合,也呈现这种状态,于是我们将所有前缀按顺序加入,每个次插入都可以由上一次插入的节点转移而来。

这样每次插入以 \(i\) 结尾的前缀只会新产生一个 \(endpos\) 集合只有 \(i\) 的类,新生成的不在这个类里的串,一定是已经出现过的串。对于这些串,这次添加仅仅相当于在它们所属的类的 \(endpos\) 集合添加了一个位置。

首先,它们肯定都是最长的出现过的后缀的后缀,这样,它们的长度一定从1到某个值连续。我们要找的就是这个最大长度。

这个最大长度是哪里来的?事实上,它前面加一个字符就会变成属于新建立的类,这相当于一次分割。于是,这个最大长度的后缀事实上是新建类在parent树上父亲的代表串!

4.自动机的构建

parent树和自动机节点相同,所以信息一并保存。我们维护 \(to[j]\) 表示添加字符 \(j\) 转移到的集合, \(fa\) 表示parent树上的父亲, \(len\) 表示代表串的长度。新插入的前缀的长度为 \(id\) ,字符为 \(val\) 。 代码中变量与之相同。

明白了要干嘛,直接分析代码。首先

int x=la,u=++cnt;
a[u].len=id;la=u;
while(x!=0&&!a[x].to[val]) a[x].to[val]=u,x=a[x].fa;

新建等价类 \(u\)\(la\) 是上一个前缀插入后新建的节点,我们枚举 \(la\) 的祖先链,这个过程其实相当于枚举上一次加入前缀的后缀们所属的等价类(见parent树的性质),它们都可以加一个字符转移到新建状态。当加一个字符出现的状态已经在某个类当中,说明我们找到了一个最长的出现过的当前前缀的后缀。

int y=a[x].to[val];
if(x==0) a[u].fa=1;
else if(a[y].len==a[x].len+1) a[u].fa=y;

\(y\) 就是这个后缀所属的类。若不存在 \(y\) 相当于新加入的子串没有一个出现过,所以新加入的串都属于新建类。

否则, \(y\) 一定是当前节点的父亲吗?事实上,我们需要这个最长后缀为 \(y\) 的代表串,而现在它只是属于 \(y\) 。于是,当 \(y\) 的代表串长度与这个后缀的长度(因为 \(x\) 的代表串长度肯定是上一次加入前缀的后缀的长度)相等时,这个后缀才是代表串。不然的话:

else
{
    int p=++cnt;a[p].len=a[x].len+1;a[p].col=col;
    a[p].fa=a[y].fa;a[y].fa=a[u].fa=p;
    for(int i=0;i<26;i++) a[p].to[i]=a[y].to[i];
    while(x!=0&&a[x].to[val]==y) a[x].to[val]=p,x=a[x].fa;
}

我们要将 \(y\) 中(因为长度连续)分割为出现过的最长后缀为代表串的类 \(p\) 与剩下的 \(y\) 两部分。在parent树上 \(p\)\(y\) 的父亲,因为它的代表串是 \(y\) 的后缀。接着 \(p\)会继承 \(y\)\(fa\) 信息,包括出边和父亲,父亲显然,出边是类中任意一个字符都可以转移的,故可以继承。节点 \(p\) 的入边来自加且仅来自添加字符后长度仍小于最长后缀长度的边,即 \(x\) 代表串的后缀。于是,我们跳 \(x\) 的父亲遍历这些类,如果它们有转移到 \(y\) 的边,现在应转移到 \(p\)

这样,我们构建了后缀自动机,从过程也能看出,点数严格小于 \(2n\) 。时间复杂度,对于跳父亲的过程,我们可以看作在树上减少深度,而每次对于parent树只会增加常数个叶子节点,增加常数深度,这样减少深度的复杂度也是 \(O(n)\) 的,同时边的数量也为 \(O(n)\)

5.自动机的边数

一个事实是自动机的边数也是 \(O(n)\) 的。

证明可以考虑将边分为两类,一类满足 \(u\to v\)\(len[v]=len[u]+1\),称为树边,另一类称为非树边。

首先每个 \(len\ne 1\) 的节点都有且仅有一个父亲。因为首先把我所有出现位置都删掉右端点,它们肯定就对应这个节点;其次这个节点对应的最长长度不可能比 \(len-1\) 长,因为如果它可以变长则当前节点 \(len\) 也可以变长。所以树边恰好构成一棵树,边数为 \(2n-2\)

而非树边 \(u\to v\),字符是 \(c\),则其中 \(u\) 代表的最长串加上 \(c\) 这一段一定是原串的子串。任意这样子串的开始点都不一样,比如说 aaaeaaaf,里面 aaaaaa 连到 aaaeaaaeaaaf ,开始点分别是 \(3,2,1,7,6,5\),就互不相同。

具体地,如果 \(\left[l,r_1\right]\)\(\left[l,r_2\right]\) 都是这样的子串且 \(r_1<r_2\),则存在开头在 \(l\) 前面的 \(\left[l,r_2-1\right]\),所以 \(\left[l,r_1\right]\) 前面已经出现过,所以并不会连第一条边,就矛盾了。

所以说非树边最多 \(n\)。这个非树边条数的证明很重要的用途是证明后缀自动机这个实现的时间复杂度

可以看出 else 里面有一个 while 循环用来把原先连到 y 的边连向更短的 p,这一步的复杂度看起来很没有保证。

每一次这种重新连边都肯定连了一条非树边,而把每一次这种连边所连的边拿出来,数量肯定不大于 \(n\)。注意这里“每一次”是说如果 a->aaaab 被改成 a->aaab,又被改成 a->aab,则后两条边都考虑在内。这个看起来比边数的证明要强,但是其实说的都是“既然在 \(r_1\) 需要连边那么 \(r_2\)不需要连边”所以在这里仍然适用。

除去这个 while 循环,其他地方都没有很高的复杂度。所以把循环枚举边改成用 vector 存边的话可以做到 \(O(n)\) 建后缀自动机。

事实上,SAM 的边数上界是 \(3n-4\)。这告诉我们,SAM 的复杂度本质上和字符集无关,如果使用 map 存边可以跑任意字符集。

6.广义后缀自动机

广义后缀自动机,就是多串后后缀自动机,解决给多个字符串的所有子串一起划分等价类的问题。

这里 \(endpos\) 集合的定义是自然的,变成了“在哪些字符串的哪些位置出现”。

一种写法是每次插入一个串后把 la 指向 \(1\) 也就是根节点。这样可能会出现问题,就比如你插入 aa 后再插入 a,按照刚才的代码就会两次建 a 这个点。

解决办法是每次新加入的字符串,扫一下哪个前缀已经出现过了,这些前缀没有新建等价类这一步,只有在所在的类的 \(endpos\) 集合中新加入一个位置这一步,所以也像刚才的代码一样考虑是否需要分裂即可。

不过有时候可以不写这个解决办法,因为出现问题体现在会把一些节点建多次,但维护的信息仍然没问题,所以有时候也是对的。

7.Trie树上自动机

用于处理将 Trie 树中的所有串划分等价类的问题。Trie 树上购建后缀自动机就相当于每个节点以父亲对应的节点为 la 运行一次 insert,与刚才的代码十分相似。

虽然 \(n\) 个节点的 Trie 树可能表示出总长为 \(O(n^2)\) 的字符串,但加入前缀的次数是 \(O(n)\),所以节点数仍然是 \(O(n)\)

这个时候用 dfs 复杂度是错的,因为当先加入长串后加入短串的时候,如果短串是长串的后缀,短串的每一个后缀都需要分裂一次。比如说 aaaab 已经在 SAM 中,在 Trie 树上回溯后搜到了 aaab 这个串,则原先 aaaab 这个点需要分裂,分裂的时候会遍历 aaaaaaaaaa 这四个串。所以说复杂度会变成 \(O(n^2)\)

这是因为,套用刚才那个复杂度证明 \(r_1\) 都不一定在 \(r_2\) 前处理,而且 \(\left[l,r_2-1\right]\) 也并不一定包含 \(\left[l,r_1\right]\)

但很神奇的是,使用 bfs 的话时间复杂度是 \(O(|S| n)\) 的,其中 \(|S|\) 是字符集大小。

有待研究。

posted @ 2025-12-05 10:33  cinccout  阅读(3)  评论(0)    收藏  举报