SAM 基础
作者:互联网
SAM 的定义
-
SAM 是一张有向无环图。结点被称作 状态 ,边被称作状态间的转移
-
图存在一个源点 \(t_0\) ,称作 初始状态,其它各结点均可从 \(t_0\) 出发到达
-
每个 转移 都标有一些字母。从一个结点出发的所有转移均不同
-
存在一个或多个 终止状态 。如果我们从初始状态 \(t_0\) 出发,最终转移到了一个终止状态,则路径上所有转移连接起来一定是字符串 \(s\) 的一个后缀。\(s\) 的每个后缀均可用一条从 \(t_0\) 到某个终止状态的路径构成
-
在所有满足上述条件的自动机中,SAM 的结点数最少
子串的性质
-
SAM 包含关于字符串 \(s\) 的所有子串的信息。任意从初始状态 \(t_0\) 开始的路径,如果我们将转移路径上的标号写下来,都会形成 \(s\) 的一个子串。反之,每个 \(s\) 的子串对应从 \(t_0\) 开始的某条路径。
-
到达某个状态的路径可能不止一条,因此我们说一个状态对应一些字符串的集合,这个集合的每个元素对应这些路径
结束位置 endpos
-
考虑字符串 \(s\) 的任意非空子串 \(t\) ,我们记 \(endpos(t)\) 为在字符串中 \(t\) 的所有结束位置
-
两个子串的 \(endpos\) 集合可能相等,这样所有字符串 \(s\) 的非空子串都可以根据它们的 \(endpos\) 集合被分为若干 等价类
-
对于 SAM 中的每个状态对应一个或多个 \(endpos\) 相同的子串,也就是SAM 中的状态数等于所有子串的等价类的个数,再加上初始状态。SAM 的状态个数等价于 \(endpos\) 相同的一个或多个子串所组成的集合的个数 + 1
一些引理
-
字符串 \(s\) 的两个非空子串 \(u\) 和 \(w\) (假设 \(|u|\leq|w|\))的 \(endpos\) 相同,当且仅当字符串 \(u\) 在 \(s\) 中的每次出现,都是以 \(w\) 的后缀形式存在的
-
考虑两个非空子串 \(u\) 和 \(w\) (\(|u|\leq|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\) 等价类的状态
-
以下我们假设初始状态 \(t_0\) 对应它自己这个等价类 (只包含一个空字符串)。为了方便,我们规定 \(endpos(t_0)=\{-1,0,\cdots,|S|-1\}\)
一些引理
-
所有后缀链接构成一颗根节点为 \(t_0\) 的树
-
通过 \(endpos\) 集合构造的树(每个子节点的 \(subset\) 都包含在父节点的 \(subset\) 中)与通过后缀链接 \(link\) 构造的树相同
小结
-
\(s\) 的子串可以根据它们结束的位置 \(endpos\) 被划分为多个等价类
-
SAM 由初始状态 \(t_0\) 和与每一个 \(endpos\) 等价类对应的每个状态组成
-
对于每一个状态 \(v\) ,一个或多个子串与之匹配。我们记 \(longest(v)\) 为其中最长的一个字符串,记 \(len(v)\) 为它的长度。类似地,记 \(shortest(v)\) 为最短的子串,它的长度为 \(minlen(v)\) 。那么对应这个状态的所有字符串都是字符串 \(longest(v)\) 的不同后缀,且所有字符串的长度恰好覆盖区间 \([minlen(v),len(v)]\) 中的每一个整数
-
对于任意不是 \(t_0\) 以外的状态 \(v\) ,定义后缀链接为连接到对应字符串 \(longest(v)\) 的长度为 \(minlen(v)-1\) 的后缀的一条边。从根节点 \(t_0\) 出发的后缀链接可以形成一棵树。这棵树也表示 \(endpos\) 集合间的包含关系
-
对于 \(t_0\) 以外的状态 \(v\) ,可用后缀链接 \(link(v)\) 表达 \(minlen(v)\):
\[minlen(v)=len(link(v))+1 \] -
如果我们从任意状态 \(v_0\) 开始顺着后缀链接遍历,总会到达初始状态 \(t_0\) 。这种情况下我们可以得到一个互不相交的区间 \([minlen(v_i),len(v_i)]\) 的序列,且它们的并集形成了连续的区间 \([0,len(v_0)]\)
算法
\(\quad\) SAM 是 在线 算法,我们可以以逐个加入字符串中的每个字符,并且在每一步中对应地维护 SAM 。
\(\quad\)为了保证线性的空间复杂度,我们将只保存 $ len$ 和 \(link\) 的值和每个状态的转移列表,我们不会标记终止状态。
\(\quad\)一开始 SAM 只包含一个状态 \(t_0\) ,编号为 \(0\) (其它状态的编号为 \(1,2,...\))。为了方便,对于状态 \(t_0\) 我们指定 \(len=0,link=-1\) (\(-1\) 表示虚拟状态)
\(\quad\)现在,任务转化为实现给当前字符串添加一个字符 \(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\)
\(\quad\)如果我们还想知道哪些状态是 终止状态 而哪些不是,我们可以在为字符串 \(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\) (其中 \(x\) 为 \(s\) 的一个后缀,且字符串 \(x+c\) 已经作为 \(s\) 的一个子串出现过了 )。因为我们假设字符串 \(s\) 的自动机的构造是正确的,我们不应该在这里添加一个新的转移,然而,难点在于从状态 \(cur\) 出发的后缀链接应该连接到哪个状态呢? 我们要把后缀链接接连到一个状态上,且其中最长的字符串恰好是 \(x+c\) ,即这个状态的 \(len\) 是 \(len(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+c\) (\(w\) 是 \(p\) 的最长字符串)的后缀就够了。即,我们需要继续沿着后缀链接遍历,从结点 \(p\) 直到虚拟状态 \(-1\) 或者转移到不是状态 \(q\) 的一个转移
操作次数为线性的证明(略)
标签:子串,状态,SAM,后缀,基础,len,endpos,字符串 来源: https://www.cnblogs.com/kzos/p/16388923.html