后缀自动机

自动机入门——后缀自动机

数据结构简介#

后缀自动机是一个可以解决许多字符串相关问题的有力的数据结构,字符串的 SAM 可以理解为给定字符串的所有子串的压缩形式,SAM 的空间复杂度和构造的时间复杂度均为线性的,准确的说,一个 SAM 最多有 2n1 个节点和 3n4 条转移边。

定义#

字符串 s 的 SAM 是一个接受 s 的所有后缀的最小 DFA(确定性有限自动机或确定性有限状态自动机)。

换句话说:

  • SAM 是一张有向无环图。结点被称作 状态,边被称作状态间的 转移
  • 图存在一个源点 t0,称作 初始状态,其它各结点均可从 t0 出发到达。
  • 每个 转移 都标有一些字母。从一个结点出发的所有转移均 不同
  • 存在一个或多个 终止状态。如果我们从初始状态 t0 出发,最终转移到了一个终止状态,则路径上的所有转移连接起来一定是字符串 s 的一个后缀。s 的每个后缀均可用一条从 t0 到某个终止状态的路径构成。
  • 在所有满足上述条件的自动机中,SAM 的结点数是最少的。

性质#

SAM 包含关于字符串 s 的所有子串的信息,任意从初始状态开始的路径,如果我们将转移路径上的字符写下来都会形成一个 s 的子串,反之每一个 s 的子串对应从 t0 开始的某条路径。

为了简化表达,我们称子串对应一条路径,反过来,一条路径也可以对应一个子串。

到达某个状态的路径可能不止一条,因此我们说一个状态对应一个字符串的集合,这个集合的每一个元素对应这些路径。

构造#

结束位置#

结束位置 endpos 是一个比较重要的概念。

考虑字符串 s 的任意非空子串 t ,我们记 endpos(t) 为在字符串 st 出现的所有位置(用右端点的结束位置来代表),例如串 abbaababab 的结束位置为 1,5,7。两个子串的 t1t2endpos 集合可能相等,这种二元关系显然是等价关系。我们可以根据它们的 endpos 集合把所有子串划分为若干等价类。

SAM 中的每一个状态都对应一个等价类,也就是说 SAM 的状态总数为等价类的个数 +1(初始节点)。

  • 引理 1

    字符串 s 的两个不同的非空子串 u,w ,(假设 |u|<|w|)的 endpos 相同,当且仅当字符串 us 中的每次出现,都是以 w 的后缀形式存在的。

    证明:引理显然成立。

  • 引理 2

    考虑两个非空子串 u,w (假设 |u||w|),那么要么 endpos(u)endpos(w)= ,要么 endpos(w)endpos(u) ,这取决与 u 是否为 w 的一个后缀,如果不是,就是前者,否则就是后者。

    证明:其实也比较显然,因为如果不是后缀,显然 w 出现的地方 u 不可能出现,所以是空集,如果是后缀,那么长度小的有可能出现在更多地方,并且一定在 w 都出现的地方出现过。

  • 引理 3

    考虑一个 endpos 等价类,对于同一等价类中的任意两个子串,较短者为较长者的后缀,且该等价类中的子串长度是连续的。

    证明:前面这个后缀关系是显然的,我们来证明它们是连续的。如果不连续,那么设字符串 q 为夹在两个属于同一等价类的字符串 s,t(|s|<|t|) 之间的一个字符串,且 qt 的后缀,sq 的后缀,根据引理 2 ,不难推出矛盾。

通过 SAM 的转移,即一些有向边,通过不同的方式走到状态 u ,我们就可以得到状态 u 对应的等价类所对应的所有字符串。

考虑 SAM 中某一个不是 t0 的状态 v ,我们已经知道,状态 v 对应于具有相同 endpos 的等价类,设 w 是最长的一个,那么所有等价类中的字符串都是 w 的后缀。

我们还知道字符串 w 的前几个后缀全部包含于这个等价类,且所有其它后缀都在其他的等价类中,我们记 t 为最长的一个后缀,包含 t 的等价类不是 v。然后将 v 的后缀链接连到 t 的等价类所代表的状态上。

为了方便,我们规定:endpos(t0)={1,0,...,|s|1}

  • 引理 4

    所有的后缀链接构成一棵根节点为 t0 的树。

    比较显然,首先一定有 n1 条边,其次因为字符串长度递减,所以不会出现环。然后一直递减,一定会到达初始状态 t0

  • 引理 5

    通过 endpos 集合构造的树(每个子节点的 subset 都包含在父节点的 subset 中)与通过后缀链接 link 构造的树相同。

    由引理 2 ,这种实质是后缀关系的 endpos 能够形成一棵树。我们考虑不是 t0 的状态 v ,显然有 endpos(v)endpos(link(v))。所以定理成立。

    但是需要注意的是,这棵树上,儿子节点的 endpos 集合不一定是父亲节点的一个划分,反例就是父亲节点的状态包含原字符串上的一个前缀。如果不包含前缀的话,性质是成立的。

小结#

  • s 的子串可以被划分成多个等价类。
  • SAM 由若干状态构成,其中每一个状态对应一个等价类。对于每一个状态 v ,一个或多个子串与之匹配,我们记 longest(v) 为里面最长的一个,记 len(v) 为它的长度,记 shortest(v) 为最短的子串,它的长度为 minlen(v) ,那么所有字符串的长度恰好覆盖 [minlen(v),len(v)] 中的每一个整数。
  • 后缀链接可以定义为连接到对应某个状态满足 longest(v) 的长度为 minlen(u)1 且是后缀关系的一条从 uv 的边。后缀链接形成了一棵以 t0 为根节点的内向树。这棵树也表示 endpos 集合间的包含关系。
  • 我们有 minlen(v)=len(link(v))+1
  • 如果我们从 v0 开始一直走到 t0 ,那么沿途所有字符串的长度形成了连续的区间 [0,len(v0)]

算法#

这个算法是一个在线算法,可以逐个加入字符串中的每个字符并在每一步维护 SAM。

一开始 SAM 只包含一个状态 t0 ,编号为 0 ,对于状态 t0 我们指定 len=0,link=1 。(这里 1 就是一个虚拟状态)

现在任务转化为实现给当前字符串添加一个字符 c 的过程,算法流程如下:

  • last 为添加字符 c 之前,整个字符串对应的状态。
  • 创建一个新的状态 cur ,并将 len(cur) 赋值为 len(last)+1
  • 现在我们从状态 last 开始按以下流程进行:如果没有字符 c 的转移,我们就添加一个到状态 cur 的转移,遍历后缀链接,如果在某个点已经存在字符 c 的转移,我们就停下来,并将这个状态标记为 p
  • 如果没有找到这样的状态 p ,我们就到达了虚拟状态 1 ,我们将 link(cur) 赋值为 0 并退出。
  • 假设现在我们找到了一个状态 p ,其可以通过字符 c 转移,我们将转移到的状态记为 q
  • 如果 len(p)+1=len(q) ,我们只需要将 link(cur) 赋值为 q 并退出。
  • 否则,我们需要复制状态 q ,我们创建一个新的状态 clone ,复制 q 的除了 len 的值以外的所有信息(后缀链接和转移)。我们将 len(clone) 赋值为 len(p)+1 。复制之后,我们将后缀链接从 cur 指向 clone ,也从 q 指向 clone 。最终我们需要使用后缀链接从状态 p 往回走,只要存在一条通过 p 到状态 q 的转移,就将该转移重新定向到状态 clone
  • 以上三种情况,在完成这个过程之后,我们将 last 的值更新为 cur

因为我们只对 s 的每一个字符建立一个或两个新状态,所以 SAM 只包括线性个状态。

正确性证明#

  • 如果一个转移 (p,q) 满足 len(p)+1=len(q) ,则我们称这个转移是连续的。否则,即当 len(p)+1<len(q) 时称其为不连续的。连续的转移是固定的,而不连续的转移可能会改变。

  • 为了避免引起歧义,我们称 SAM 中插入当前字符 c 之前的字符串为 s

  • 算法从创建一个新状态 cur 开始,对应于整个字符串 s+c ,我们创建一个新的节点的原因很清楚,就是要创建一个包含 endpos(s+c) 的等价类。

  • 在创建一个新的状态之后,我们会从对应整个字符串 s 的状态通过后缀链接进行遍历,对于每一个状态,我们尝试添加一个通过字符 c 到新状态 cur 的转移。然而我们只能添加原有转移不冲突的转移。因此我们只要找到已存在的 c 的转移,我们就必须停止。

  • 换句话说,当我们加入一个字符 c 的时候,会产生 |s| 个新的后缀,我们不断跳后缀链接,其实就是不断跳 s 的后缀,然后如果不冲突我们就连一条到 cur 的边。

  • 如果不存在冲突,也就是说我们到达了虚拟状态 1 ,那意味着我们为所有 s 的后缀所对应的状态添加了转移 c ,这同时也意味着 c 之前从来没有在字符串中出现过,所以显然 cur 的后缀链接为 0

  • 否则,存在一个 pq 的转移,如果这个转移连续,这表明这个集合仍然满足是一个等价类,因为 q 中的字符串一定是由 pp 的父亲经过转移得到的。所以我们直接把 cur 的后缀链接连上来就可以。

  • 反之,p 一定有多个儿子,且某个儿子能转移到 q,这使得 q 中的字符串某些 endpos 会发生变化,某些不会变化,我们要把它分裂成两个状态,一个是变化的,一个是不会变化的,变化的那些就是 pp 的父亲转移得到的字符串,变化的原因是我们往整个字符串 s 的末尾加了一个字符。分裂之后维护后缀链接即可。

对操作次数为线性的证明#

一下我们认为字符集的大小为常数。

我们考虑算法的各个部分,有三处时间复杂度不明显是线性的:

  • 第一处是遍历所有状态 last 的后缀链接,添加字符 c 的转移。
  • 第二处是当状态 q 被复制到一个新的状态 clone 时复制转移的过程。
  • 第三处修改指向 q 的转移,将它们重定向到 clone

因为 SAM 状态数是线性的,而每个节点最多只有常数个转移,所以转移数也是线性的,所以第一处和第二处是线性的。

我们接下来证明第三处也是线性的。

在每一次添加字符时我们不妨关注一下 shortest(link(last)) ,在向 s 中添加字符之前,有 shortest(p)shortest(link(last)) ,这是因为 link(last) 至多是 p ,我们由 q 拷贝得到了节点 clone ,并试图从 p 沿后缀链接上溯,将所有通往 q 的转移重定向为 clone ,这时 shortest(clone) 是严格变小的,加完字符后,我们有 last=curlink(last)=link(cur)=clone ,所以 shortest(link(last)) 是在严格变小的,而且减小的幅度和改变转移的次数级别相同,故我们改变转移也是线性的。

可以在线构造后缀自动机的神奇网站

其他应用

对于一个字符串,把所有字符串加进 Trie 里面,然后进行压缩之后得到的树,是后缀树。后缀树上一条边代表的是一个字符串。对于一个串 s,我们建出反串的后缀自动机得到的 fail 树就是后缀树。

引用

有一些明显的错误已经在本文改正。

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