字符串
后缀自动机(SAM)
概述
SAM 是一种很优秀的字符串数据结构。它可以将一个字符串的所有子串压缩成一个 $O(n)$ 大小的结构。
定义
SAM 是有限状态自动机,换种说法就是:
- SAM 是一个有向无环图。节点被称为状态。边有边权,权值为一个字母,边称为转移。
- 存在一个源点 $t_0$,即初始状态。从 $t_0$ 出发可以到达所有状态。
- 存在若干个终止状态。从 $t_0$ 到某个终止状态的路径,边权拼起来一定是 $s$ 的一个后缀。反之,$s$ 的任意一个后缀对应一条从 $t_0$ 到某个终止状态的路径。
SAM 与子串的关系
从 $t_0$ 出发的任意一条路径,边权拼起来一定是 $s$ 的子串。反之,$s$ 的任意子串对应从 $t_0$ 出发的一条路径。
$t_0$ 到每个点可能有多条路径,也就是说每个状态对应一些子串的集合。
引入其它概念
endpos
假设 $s$ 有非空子串 $t$,则 $\operatorname{endpos}(t)$ 表示 $t$ 在 $s$ 中所有出现的结束位置。例如 $s=\texttt{abcbc}$,则 $\operatorname{endpos}(\texttt{bc})=\{3,5\}$。
某几个子串的 $\operatorname{endpos}$ 可能相同,例如 $s=\texttt{abcbc}$,则 $\operatorname{endpos}(\texttt{c})=\operatorname{endpos}(\texttt{bc})=\{3,5\}$。$\operatorname{endpos}$ 相同的子串会被划分到同一个等价类 中, $s$ 的所有子串会被划分成若干个等价类。
SAM 的每个状态对应一个等价类中的所有子串。也就是说,SAM 的状态数为等价类的数量 $+1$,其中 $+1$ 加上的是 $t_0$,$t_0$ 对应的是空串。
引理 1:$u,w$ 是字符串的两个子串,则:$u$ 是 $w$ 的后缀 $\iff$ $\operatorname{endpos}(w) \subseteq \operatorname{endpos}(u)$。
引理 2:在一个等价类中,短的字符串是长的字符串的后缀,且这些字符串长度连续。
后缀链接 link
现有状态 $u$,假设 $u$ 所对应的等价类中,长度最长的是 $t$。将 $t$ 的所有后缀按长度递减排序,则这个等价类中包含前几个后缀。设第一个不属于这个等价类的后缀为 $w$(一定存在,因为 $w$ 可以为空),则 $\operatorname{link}(u)$ 连向 $w$ 所属的等价类所对应的状态。
所有的后缀链接构成一棵根节点为 $t_0$ 的树。
注意后缀链接 link 的边与 SAM 的边并不相同。
由引理 1 可知,$\operatorname{endpos}(u)\subsetneq\operatorname{endpos}(\operatorname{link}(u))$。
其它定义
对于状态 $u$,其等价类中最长的字符串为 $\operatorname{longest}(u)$,其长度为 $\operatorname{len}(u)$。最短的字符串为 $\operatorname{shortest}(u)$,其长度为 $\operatorname{minlen}(u)$。
可以得出:$\operatorname{minlen}(u)=\operatorname{len}(\operatorname{link}(u))+1$。
SAM 的构建
我们采用在线构建方法,即向 SAM 中逐个加入字符。
先放代码(代码中 $t_0$ 节点的编号为 $1$):
1 | struct SAM |
我们假设已知字符串 $s$ 的 SAM,现在我们要添加字符 $x$,新的字符串为 $s+x$。
接下来我们看看 $\operatorname{add}$ 函数里代码的作用:
1 | int p = lst, cur = ++tot; |
$lst$ 指原串 $s$ 所属的节点,我们记录这个点为 $p$。
同时我们要新建一个节点,记这个节点为 $cur$。
新建节点也就意味着新建了一个等价类,显然这个等价类中最长的字符串为 $s+x$。所以可以求出 $\operatorname{len}(cur)=\operatorname{len}(p)+1$,即 $s$ 的长度 $+1$。
1 | while (p && !t[p].ch[x]) t[p].ch[x] = cur, p = t[p].link; |
在这个循环中,$p$ 会不停跳到 $\operatorname{link}(p)$,因此 $p$ 对应的字符串永远是原串 $s$ 的后缀。
循环的执行条件 !t[p].ch[x]
,意为 $p$ 没有权值为 $x$ 的转移,也就是说 $p$ 中的字符串接上 $x$ 后都没有在原串中出现过。那么此时 $p$ 中所有的字符串接上 $x$ 后仍为同一个等价类,直接把状态 $p$ 添加一个权值为 $x$ 的转移,指向新的等价类 $cur$ 即可。
1 | if (!p) t[cur].link = 1; |
如果遍历完原串 $s$ 的所有后缀,它们接上 $x$ 都没有在原串中出现过,那么 $s$ 的所有后缀接上 $x$ 都在同一个等价类中,它们的 $\operatorname{endpos}$ 都只有为 $|s|+1$ 这一个元素。直接将这个等价类的 $\operatorname{link}$ 指向 $t_0$ 即可。
1 | else |
否则,我们在 $p$ 向上跳的过程中,找到了一个等价类,其中的字符串接上 $x$ 在原串中出现过。记 $q$ 为 $p$ 沿着权值为 $x$ 的转移走到的节点。
1 | if (t[p].len + 1 == t[q].len) t[cur].link = q; |
在这里我们做了一个判断:$\operatorname{len}(p)+1=\operatorname{len}(q)$。如果这个条件满足,说明等价类 $q$ 中只含有一个字符串,这个字符串即为 $\operatorname{longest}(p)+x$。此时将新建的等价类的 $\operatorname{link}$ 指向 $q$ 即可。
否则,$\operatorname{len}(p)+1\neq\operatorname{len}(q)$。我们知道,$\operatorname{shortest}(q)=\operatorname{longest}(p)+x$。所以 $\operatorname{shortest}(q)$ 的 $\operatorname{endpos}$ 在原串的基础上增加了 $|s|+1$ 这一个值,而 $q$ 中其它字符串的 $\operatorname{endpos}$ 均未发生改变。
也就是说字符串加入 $x$ 后,$q$ 中的字符串的 $\operatorname{endpos}$ 不相同了。为了使每个节点的 $\operatorname{endpos}$ 相同,我们需要把 $q$ 拆分成两个等价类,一个只包含 $\operatorname{shortest}(q)$,另一个包含 $q$ 中的其他字符串。
1 | int nw = ++tot; |
我们新建一个编号为 $nw$ 的节点,它只包含 $\operatorname{shortest}(q)$。它的出边和 $\operatorname{link}$ 应与原先 $q$ 的相同,所以先让 t[nw] = t[q]
。现在的 $q$ 应包含除 $\operatorname{shortest}(q)$ 以外的字符串。
然后计算 $\operatorname{len}(nw)$,显然它等于 $\operatorname{len}(p)+1$。
最后,$q$ 和 $cur$ 的 $\operatorname{link}$ 都应连向 $nw$。
1 | while (p && t[p].ch[x] == q) t[p].ch[x] = nw, p = t[p].link; |
最后,$p$ 的某些后缀的权值为 $x$ 的转移可能指向 $q$,我们应该将他指向 $nw$。
至此,SAM 的就构建完了。