后缀自动机
自动机入门——后缀自动机
数据结构简介
后缀自动机是一个可以解决许多字符串相关问题的有力的数据结构,字符串的 SAM 可以理解为给定字符串的所有子串的压缩形式,SAM 的空间复杂度和构造的时间复杂度均为线性的,准确的说,一个 SAM 最多有 \(2n-1\) 个节点和 \(3n-4\) 条转移边。
定义
字符串 \(s\) 的 SAM 是一个接受 \(s\) 的所有后缀的最小 DFA(确定性有限自动机或确定性有限状态自动机)。
换句话说:
- SAM 是一张有向无环图。结点被称作 状态,边被称作状态间的 转移。
- 图存在一个源点 \(t_0\),称作 初始状态,其它各结点均可从 \(t_0\) 出发到达。
- 每个 转移 都标有一些字母。从一个结点出发的所有转移均 不同。
- 存在一个或多个 终止状态。如果我们从初始状态 \(t_0\) 出发,最终转移到了一个终止状态,则路径上的所有转移连接起来一定是字符串 \(s\) 的一个后缀。\(s\) 的每个后缀均可用一条从 \(t_0\) 到某个终止状态的路径构成。
- 在所有满足上述条件的自动机中,SAM 的结点数是最少的。
性质
SAM 包含关于字符串 \(s\) 的所有子串的信息,任意从初始状态开始的路径,如果我们将转移路径上的字符写下来都会形成一个 \(s\) 的子串,反之每一个 \(s\) 的子串对应从 \(t_0\) 开始的某条路径。
为了简化表达,我们称子串对应一条路径,反过来,一条路径也可以对应一个子串。
到达某个状态的路径可能不止一条,因此我们说一个状态对应一个字符串的集合,这个集合的每一个元素对应这些路径。
构造
结束位置
结束位置 \(endpos\) 是一个比较重要的概念。
考虑字符串 \(s\) 的任意非空子串 \(t\) ,我们记 \(endpos(t)\) 为在字符串 \(s\) 中 \(t\) 出现的所有位置(用右端点的结束位置来代表),例如串 \(\text{abbaabab}\) 中 \(\text{ab}\) 的结束位置为 \(1,5,7\)。两个子串的 \(t_1\),\(t_2\) 的 \(endpos\) 集合可能相等,这种二元关系显然是等价关系。我们可以根据它们的 \(endpos\) 集合把所有子串划分为若干等价类。
SAM 中的每一个状态都对应一个等价类,也就是说 SAM 的状态总数为等价类的个数 \(+1\)(初始节点)。
-
引理 \(1\)
字符串 \(s\) 的两个不同的非空子串 \(u,w\) ,(假设 \(|u|<|w|\))的 \(endpos\) 相同,当且仅当字符串 \(u\) 在 \(s\) 中的每次出现,都是以 \(w\) 的后缀形式存在的。
证明:引理显然成立。
-
引理 \(2\)
考虑两个非空子串 \(u,w\) (假设 \(|u|\le |w|\)),那么要么 \(endpos(u)\cap endpos(w)=\varnothing\) ,要么 \(endpos(w)\subseteq endpos(u)\) ,这取决与 \(u\) 是否为 \(w\) 的一个后缀,如果不是,就是前者,否则就是后者。
证明:其实也比较显然,因为如果不是后缀,显然 \(w\) 出现的地方 \(u\) 不可能出现,所以是空集,如果是后缀,那么长度小的有可能出现在更多地方,并且一定在 \(w\) 都出现的地方出现过。
-
引理 \(3\)
考虑一个 \(endpos\) 等价类,对于同一等价类中的任意两个子串,较短者为较长者的后缀,且该等价类中的子串长度是连续的。
证明:前面这个后缀关系是显然的,我们来证明它们是连续的。如果不连续,那么设字符串 \(q\) 为夹在两个属于同一等价类的字符串 \(s,t(|s|<|t|)\) 之间的一个字符串,且 \(q\) 为 \(t\) 的后缀,\(s\) 是 \(q\) 的后缀,根据引理 \(2\) ,不难推出矛盾。
通过 SAM 的转移,即一些有向边,通过不同的方式走到状态 \(u\) ,我们就可以得到状态 \(u\) 对应的等价类所对应的所有字符串。
后缀链接 link
考虑 SAM 中某一个不是 \(t_0\) 的状态 \(v\) ,我们已经知道,状态 \(v\) 对应于具有相同 \(endpos\) 的等价类,设 \(w\) 是最长的一个,那么所有等价类中的字符串都是 \(w\) 的后缀。
我们还知道字符串 \(w\) 的前几个后缀全部包含于这个等价类,且所有其它后缀都在其他的等价类中,我们记 \(t\) 为最长的一个后缀,包含 \(t\) 的等价类不是 \(v\)。然后将 \(v\) 的后缀链接连到 \(t\) 的等价类所代表的状态上。
为了方便,我们规定:\(endpos(t_0)=\{-1,0,...,|s|-1\}\) 。
-
引理 \(4\)
所有的后缀链接构成一棵根节点为 \(t_0\) 的树。
比较显然,首先一定有 \(n-1\) 条边,其次因为字符串长度递减,所以不会出现环。然后一直递减,一定会到达初始状态 \(t_0\) 。
-
引理 \(5\)
通过 \(endpos\) 集合构造的树(每个子节点的 \(subset\) 都包含在父节点的 \(subset\) 中)与通过后缀链接 \(link\) 构造的树相同。
由引理 \(2\) ,这种实质是后缀关系的 \(endpos\) 能够形成一棵树。我们考虑不是 \(t_0\) 的状态 \(v\) ,显然有 \(endpos(v)\subsetneq endpos(link(v))\)。所以定理成立。
但是需要注意的是,这棵树上,儿子节点的 \(endpos\) 集合不一定是父亲节点的一个划分,反例就是父亲节点的状态包含原字符串上的一个前缀。如果不包含前缀的话,性质是成立的。
小结
- \(s\) 的子串可以被划分成多个等价类。
- SAM 由若干状态构成,其中每一个状态对应一个等价类。对于每一个状态 \(v\) ,一个或多个子串与之匹配,我们记 \(longest(v)\) 为里面最长的一个,记 \(len(v)\) 为它的长度,记 \(shortest(v)\) 为最短的子串,它的长度为 \(minlen(v)\) ,那么所有字符串的长度恰好覆盖 \([minlen(v),len(v)]\) 中的每一个整数。
- 后缀链接可以定义为连接到对应某个状态满足 \(longest(v)\) 的长度为 \(minlen(u)-1\) 且是后缀关系的一条从 \(u\) 到 \(v\) 的边。后缀链接形成了一棵以 \(t_0\) 为根节点的内向树。这棵树也表示 \(endpos\) 集合间的包含关系。
- 我们有 \(minlen(v)=len(link(v))+1\)
- 如果我们从 \(v_0\) 开始一直走到 \(t_0\) ,那么沿途所有字符串的长度形成了连续的区间 \([0,len(v_0)]\)。
算法
这个算法是一个在线算法,可以逐个加入字符串中的每个字符并在每一步维护 SAM。
一开始 SAM 只包含一个状态 \(t_0\) ,编号为 \(0\) ,对于状态 \(t_0\) 我们指定 \(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\) 。
-
否则,存在一个 \(p\) 到 \(q\) 的转移,如果这个转移连续,这表明这个集合仍然满足是一个等价类,因为 \(q\) 中的字符串一定是由 \(p\) 和 \(p\) 的父亲经过转移得到的。所以我们直接把 \(cur\) 的后缀链接连上来就可以。
-
反之,\(p\) 一定有多个儿子,且某个儿子能转移到 \(q\),这使得 \(q\) 中的字符串某些 \(endpos\) 会发生变化,某些不会变化,我们要把它分裂成两个状态,一个是变化的,一个是不会变化的,变化的那些就是 \(p\) 和 \(p\) 的父亲转移得到的字符串,变化的原因是我们往整个字符串 \(s\) 的末尾加了一个字符。分裂之后维护后缀链接即可。
对操作次数为线性的证明
一下我们认为字符集的大小为常数。
我们考虑算法的各个部分,有三处时间复杂度不明显是线性的:
- 第一处是遍历所有状态 \(last\) 的后缀链接,添加字符 \(c\) 的转移。
- 第二处是当状态 \(q\) 被复制到一个新的状态 \(clone\) 时复制转移的过程。
- 第三处修改指向 \(q\) 的转移,将它们重定向到 \(clone\) 。
因为 SAM 状态数是线性的,而每个节点最多只有常数个转移,所以转移数也是线性的,所以第一处和第二处是线性的。
我们接下来证明第三处也是线性的。
在每一次添加字符时我们不妨关注一下 \(shortest(link(last))\) ,在向 \(s\) 中添加字符之前,有 \(shortest(p)\le shortest(link(last))\) ,这是因为 \(link(last)\) 至多是 \(p\) ,我们由 \(q\) 拷贝得到了节点 \(clone\) ,并试图从 \(p\) 沿后缀链接上溯,将所有通往 \(q\) 的转移重定向为 \(clone\) ,这时 \(shortest(clone)\) 是严格变小的,加完字符后,我们有 \(last=cur\rightarrow link(last)=link(cur)=clone\) ,所以 \(shortest(link(last))\) 是在严格变小的,而且减小的幅度和改变转移的次数级别相同,故我们改变转移也是线性的。
其他应用
对于一个字符串,把所有字符串加进 Trie 里面,然后进行压缩之后得到的树,是后缀树。后缀树上一条边代表的是一个字符串。对于一个串 \(s\),我们建出反串的后缀自动机得到的 fail 树就是后缀树。
引用
有一些明显的错误已经在本文改正。