SAM 基础

SAM 的定义

  • SAM 是一张有向无环图。结点被称作 状态 ,边被称作状态间的转移

  • 图存在一个源点 t0 ,称作 初始状态,其它各结点均可从 t0 出发到达

  • 每个 转移 都标有一些字母。从一个结点出发的所有转移均不同

  • 存在一个或多个 终止状态 。如果我们从初始状态 t0 出发,最终转移到了一个终止状态,则路径上所有转移连接起来一定是字符串 s 的一个后缀。s 的每个后缀均可用一条从 t0 到某个终止状态的路径构成

  • 在所有满足上述条件的自动机中,SAM 的结点数最少

子串的性质

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

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

结束位置 endpos

  • 考虑字符串 s 的任意非空子串 t ,我们记 endpos(t) 为在字符串中 t 的所有结束位置

  • 两个子串的 endpos 集合可能相等,这样所有字符串 s 的非空子串都可以根据它们的 endpos 集合被分为若干 等价类

  • 对于 SAM 中的每个状态对应一个或多个 endpos 相同的子串,也就是SAM 中的状态数等于所有子串的等价类的个数,再加上初始状态。SAM 的状态个数等价于 endpos 相同的一个或多个子串所组成的集合的个数 + 1

一些引理

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

  • 考虑两个非空子串 uw|u||w|)。那么要么 endpos(u)endpos(w) 不相交,要么 endpos(w)endpos(u) 的子集,这取决于 u 是否是 w 的一个后缀

  • 如果集合 endpos(u)endpos(w) 有至少一个公共元素,那么 endpos(w)endpos(u) 的子集

  • 考虑一个 endpos 等价类,将类中所有子串按长度非递增顺序排序。每个子串都不会比它前一个子串长,且一定是前一个子串的后缀。也就是对于同一等价类的任一两子串,较短者为较长者的后缀,且该等价类中中的子串长度恰好覆盖一个区间

后缀链接 link

  • 考虑一个非初始状态的状态 v 。我们已经知道状态 v 对应于具有相同 endpos 的等价类。我们定义 w 为这些字符串中最长的一个,则所有其它字符串都是 w 的后缀

  • 我们还知道字符串 w 的前几个后缀(按长度降序考虑)全部包含于这个等价类,且所有其它后缀(至少有一个--空后缀)在其它的等价类中,我们记 t 为最长的这样的后缀,然后将 v 的后缀链接接连奥 t

  • 换句话说,一个后缀链接 link(v) 链接到对应 w 的最长后缀是另一个 endpos 等价类的状态

  • 以下我们假设初始状态 t0 对应它自己这个等价类 (只包含一个空字符串)。为了方便,我们规定 endpos(t0)={1,0,,|S|1}

一些引理

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

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

小结

  • s 的子串可以根据它们结束的位置 endpos 被划分为多个等价类

  • SAM 由初始状态 t0 和与每一个 endpos 等价类对应的每个状态组成

  • 对于每一个状态 v ,一个或多个子串与之匹配。我们记 longest(v) 为其中最长的一个字符串,记 len(v) 为它的长度。类似地,记 shortest(v) 为最短的子串,它的长度为 minlen(v) 。那么对应这个状态的所有字符串都是字符串 longest(v) 的不同后缀,且所有字符串的长度恰好覆盖区间 [minlen(v),len(v)] 中的每一个整数

  • 对于任意不是 t0 以外的状态 v ,定义后缀链接为连接到对应字符串 longest(v) 的长度为 minlen(v)1 的后缀的一条边。从根节点 t0 出发的后缀链接可以形成一棵树。这棵树也表示 endpos 集合间的包含关系

  • 对于 t0 以外的状态 v ,可用后缀链接 link(v) 表达 minlen(v):

    minlen(v)=len(link(v))+1

  • 如果我们从任意状态 v0 开始顺着后缀链接遍历,总会到达初始状态 t0 。这种情况下我们可以得到一个互不相交的区间 [minlen(vi),len(vi)] 的序列,且它们的并集形成了连续的区间 [0,len(v0)]

算法

SAM 是 在线 算法,我们可以以逐个加入字符串中的每个字符,并且在每一步中对应地维护 SAM 。

为了保证线性的空间复杂度,我们将只保存 lenlink 的值和每个状态的转移列表,我们不会标记终止状态。

一开始 SAM 只包含一个状态 t0 ,编号为 0 (其它状态的编号为 1,2,...)。为了方便,对于状态 t0 我们指定 len=0,link=11 表示虚拟状态)

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

  • last 为添加字符 c 之前,整个字符串对应的状态(一开始我们设 last ,算法的最后一步更新 last) 。

  • 创建一个新的状态 cur ,并将 len(cur) 赋值为 len(last)+1 ,在这时 link(cur) 的值还未知

  • 现在我们按以下流程进行(从状态 last 开始)。如果还没有到字符 c 的转移,我们就添加一个到状态 cur 的转移,遍历后缀链接。如果在某个点已经存在到字符 c 的转移,我们就停下来,并将这个状态标记为 p

  • 如果没有找到这样的状态 p ,我们就到达了虚拟状态 1 ,我们将 link(cur) 赋值为 0 并退出

  • 假设我们找到一个状态 p ,其可以通过字符 c 转移。我们将转移到状态标记为 q

  • 现在我们分类讨论两种状态,要么 len(p)+1=len(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 后找到所有的终止状态。为此,我们从对应整个字符串的状态 (存储在变量 last 中) ,遍历它的后缀链接,直到到达初始状态。我们将所有遍历到的节点都标记为终止节点。容易理解这样做我们会准确地标记字符串 s 地所有后缀,这些状态都是终止状态。

正确性证明

  • 若一个转移 (p,q) 满足 len(p)+1=len(q) ,我们称这个转移是 连续地 。否则,即当 len(p)+1<len(q) 时,这个转移被称为 不连续的 。从算法描述中可以看出,连续的、不连续的转移时算法的不同情况。连续的转移是固定的,我们不会再改变了,与此相反,当向字符串中插入一个新的字符时,不连续的转移可能会改变(转移边的端点可能会改变)。

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

  • 算法从创建一个新状态 cur 开始,对应于整个字符串 s+c 。我们创建一个新的节点,与此同时我们也创建了一个新的字符和一个新的等价类

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

  • 最简单的情况是我们到达了虚拟状态 -1 ,这意味着我们为所有 s 的后缀添加了 c 的转移,这也意味着,字符 c 从未在字符串 s 中出现过。因此 cur 的后缀链接为状态 0

  • 第二种情况下,我们找到了现有的转移 (p,q) ,这意味着我们尝试向自动机内添加一个已经存在的字符串 x+c (其中 xs 的一个后缀,且字符串 x+c 已经作为 s 的一个子串出现过了 )。因为我们假设字符串 s 的自动机的构造是正确的,我们不应该在这里添加一个新的转移,然而,难点在于从状态 cur 出发的后缀链接应该连接到哪个状态呢? 我们要把后缀链接接连到一个状态上,且其中最长的字符串恰好是 x+c ,即这个状态的 lenlen(p)+1 ,然而还不存在这样的状态,len(q)>len(p)+1 ,这种情况下,我们必须通过拆开状态 q 来创建一个这样的状态

  • 如果转移 (p,q) 是连续的,那么 len(q)=len(p)+1 ,这种情况下只需要将 cur 的后缀链接指向状态 q

  • 否则状态是不连续的,这意味着状态 q 不止对应于长度为 len(p+1) 的后缀 s+c ,还对应于 s 更长的子串。除了将状态 q 拆成两个子状态以外我们别无它法,所以第一个子状态的长度就是 len(p)+1 了。

    我们如何拆开一个状态呢?我们 复制 状态 q ,产生一个状态 clone ,我们将 len(clone) 赋值为 len(p)+1 ,由于我们不想改变遍历到 q 的路径,我们将 q 的所有转移复制到 clone ,我们也将从 clone 出发的后缀链接设置为 q 的后缀链接的目标,并设置 q 的后缀链接为 clone

    在拆开状态后,我们将从 cur 出发的后缀链接设置为 clone

    最后一步我们将一些到 q 的转移重定向到 clone 。我们需要哪些修改呢?

    只重定向相当于所有字符串 w+cwp 的最长字符串)的后缀就够了。即,我们需要继续沿着后缀链接遍历,从结点 p 直到虚拟状态 1 或者转移到不是状态 q 的一个转移

操作次数为线性的证明(略)

posted @   Kzos_017  阅读(172)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 地球OL攻略 —— 某应届生求职总结
· 周边上新:园子的第一款马克杯温暖上架
· Open-Sora 2.0 重磅开源!
· 提示词工程——AI应用必不可少的技术
· .NET周刊【3月第1期 2025-03-02】
点击右上角即可分享
微信分享提示