后缀自动机
自动机入门——后缀自动机
数据结构简介#
后缀自动机是一个可以解决许多字符串相关问题的有力的数据结构,字符串的 SAM 可以理解为给定字符串的所有子串的压缩形式,SAM 的空间复杂度和构造的时间复杂度均为线性的,准确的说,一个 SAM 最多有
定义#
字符串
换句话说:
- SAM 是一张有向无环图。结点被称作 状态,边被称作状态间的 转移。
- 图存在一个源点
,称作 初始状态,其它各结点均可从 出发到达。 - 每个 转移 都标有一些字母。从一个结点出发的所有转移均 不同。
- 存在一个或多个 终止状态。如果我们从初始状态
出发,最终转移到了一个终止状态,则路径上的所有转移连接起来一定是字符串 的一个后缀。 的每个后缀均可用一条从 到某个终止状态的路径构成。 - 在所有满足上述条件的自动机中,SAM 的结点数是最少的。
性质#
SAM 包含关于字符串
为了简化表达,我们称子串对应一条路径,反过来,一条路径也可以对应一个子串。
到达某个状态的路径可能不止一条,因此我们说一个状态对应一个字符串的集合,这个集合的每一个元素对应这些路径。
构造#
结束位置#
结束位置
考虑字符串
SAM 中的每一个状态都对应一个等价类,也就是说 SAM 的状态总数为等价类的个数
-
引理
字符串
的两个不同的非空子串 ,(假设 )的 相同,当且仅当字符串 在 中的每次出现,都是以 的后缀形式存在的。证明:引理显然成立。
-
引理
考虑两个非空子串
(假设 ),那么要么 ,要么 ,这取决与 是否为 的一个后缀,如果不是,就是前者,否则就是后者。证明:其实也比较显然,因为如果不是后缀,显然
出现的地方 不可能出现,所以是空集,如果是后缀,那么长度小的有可能出现在更多地方,并且一定在 都出现的地方出现过。 -
引理
考虑一个
等价类,对于同一等价类中的任意两个子串,较短者为较长者的后缀,且该等价类中的子串长度是连续的。证明:前面这个后缀关系是显然的,我们来证明它们是连续的。如果不连续,那么设字符串
为夹在两个属于同一等价类的字符串 之间的一个字符串,且 为 的后缀, 是 的后缀,根据引理 ,不难推出矛盾。
通过 SAM 的转移,即一些有向边,通过不同的方式走到状态
后缀链接 link#
考虑 SAM 中某一个不是
我们还知道字符串
为了方便,我们规定:
-
引理
所有的后缀链接构成一棵根节点为
的树。比较显然,首先一定有
条边,其次因为字符串长度递减,所以不会出现环。然后一直递减,一定会到达初始状态 。 -
引理
通过
集合构造的树(每个子节点的 都包含在父节点的 中)与通过后缀链接 构造的树相同。由引理
,这种实质是后缀关系的 能够形成一棵树。我们考虑不是 的状态 ,显然有 。所以定理成立。但是需要注意的是,这棵树上,儿子节点的
集合不一定是父亲节点的一个划分,反例就是父亲节点的状态包含原字符串上的一个前缀。如果不包含前缀的话,性质是成立的。
小结#
的子串可以被划分成多个等价类。- SAM 由若干状态构成,其中每一个状态对应一个等价类。对于每一个状态
,一个或多个子串与之匹配,我们记 为里面最长的一个,记 为它的长度,记 为最短的子串,它的长度为 ,那么所有字符串的长度恰好覆盖 中的每一个整数。 - 后缀链接可以定义为连接到对应某个状态满足
的长度为 且是后缀关系的一条从 到 的边。后缀链接形成了一棵以 为根节点的内向树。这棵树也表示 集合间的包含关系。 - 我们有
- 如果我们从
开始一直走到 ,那么沿途所有字符串的长度形成了连续的区间 。
算法#
这个算法是一个在线算法,可以逐个加入字符串中的每个字符并在每一步维护 SAM。
一开始 SAM 只包含一个状态
现在任务转化为实现给当前字符串添加一个字符
- 令
为添加字符 之前,整个字符串对应的状态。 - 创建一个新的状态
,并将 赋值为 。 - 现在我们从状态
开始按以下流程进行:如果没有字符 的转移,我们就添加一个到状态 的转移,遍历后缀链接,如果在某个点已经存在字符 的转移,我们就停下来,并将这个状态标记为 。 - 如果没有找到这样的状态
,我们就到达了虚拟状态 ,我们将 赋值为 并退出。 - 假设现在我们找到了一个状态
,其可以通过字符 转移,我们将转移到的状态记为 。 - 如果
,我们只需要将 赋值为 并退出。 - 否则,我们需要复制状态
,我们创建一个新的状态 ,复制 的除了 的值以外的所有信息(后缀链接和转移)。我们将 赋值为 。复制之后,我们将后缀链接从 指向 ,也从 指向 。最终我们需要使用后缀链接从状态 往回走,只要存在一条通过 到状态 的转移,就将该转移重新定向到状态 。 - 以上三种情况,在完成这个过程之后,我们将
的值更新为 。
因为我们只对
正确性证明#
-
如果一个转移
满足 ,则我们称这个转移是连续的。否则,即当 时称其为不连续的。连续的转移是固定的,而不连续的转移可能会改变。 -
为了避免引起歧义,我们称 SAM 中插入当前字符
之前的字符串为 。 -
算法从创建一个新状态
开始,对应于整个字符串 ,我们创建一个新的节点的原因很清楚,就是要创建一个包含 的等价类。 -
在创建一个新的状态之后,我们会从对应整个字符串
的状态通过后缀链接进行遍历,对于每一个状态,我们尝试添加一个通过字符 到新状态 的转移。然而我们只能添加原有转移不冲突的转移。因此我们只要找到已存在的 的转移,我们就必须停止。 -
换句话说,当我们加入一个字符
的时候,会产生 个新的后缀,我们不断跳后缀链接,其实就是不断跳 的后缀,然后如果不冲突我们就连一条到 的边。 -
如果不存在冲突,也就是说我们到达了虚拟状态
,那意味着我们为所有 的后缀所对应的状态添加了转移 ,这同时也意味着 之前从来没有在字符串中出现过,所以显然 的后缀链接为 。 -
否则,存在一个
到 的转移,如果这个转移连续,这表明这个集合仍然满足是一个等价类,因为 中的字符串一定是由 和 的父亲经过转移得到的。所以我们直接把 的后缀链接连上来就可以。 -
反之,
一定有多个儿子,且某个儿子能转移到 ,这使得 中的字符串某些 会发生变化,某些不会变化,我们要把它分裂成两个状态,一个是变化的,一个是不会变化的,变化的那些就是 和 的父亲转移得到的字符串,变化的原因是我们往整个字符串 的末尾加了一个字符。分裂之后维护后缀链接即可。
对操作次数为线性的证明#
一下我们认为字符集的大小为常数。
我们考虑算法的各个部分,有三处时间复杂度不明显是线性的:
- 第一处是遍历所有状态
的后缀链接,添加字符 的转移。 - 第二处是当状态
被复制到一个新的状态 时复制转移的过程。 - 第三处修改指向
的转移,将它们重定向到 。
因为 SAM 状态数是线性的,而每个节点最多只有常数个转移,所以转移数也是线性的,所以第一处和第二处是线性的。
我们接下来证明第三处也是线性的。
在每一次添加字符时我们不妨关注一下
其他应用
对于一个字符串,把所有字符串加进 Trie 里面,然后进行压缩之后得到的树,是后缀树。后缀树上一条边代表的是一个字符串。对于一个串
引用
有一些明显的错误已经在本文改正。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 全程不用写代码,我用AI程序员写了一个飞机大战
· DeepSeek 开源周回顾「GitHub 热点速览」
· 记一次.NET内存居高不下排查解决与启示
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· .NET10 - 预览版1新功能体验(一)