SAM
本文为学习 SAM 的笔记与我自己的思考,亮点在于从 MN 关系出发推导 SAM 的构造。
记号约定与前置知识
\(\renewcommand\MN{\equiv_{\mathcal R}}\renewcommand{\tp}[1]{\left(#1\right)}\renewcommand{\endpos}{\text{endpos}}\renewcommand{\link}{\text{link}}\renewcommand{\dep}{\text{dep}}\)下标从 0 开始,左闭右开。
MN 同余:对一个语言 $L \subseteq \Sigma^* $ 而言,两个字符串 \(w_1, w_2\) 是 MN 同余的,如果 \(\forall u \in \Sigma^* , (w_1u \in L \leftrightarrow w_2u \in L)\)。一个等价的定义是,\(w_1^{-1}L = w_2^{-1}L\),其中,\(w^{-1}L := \set{u \mid wu \in L}\)。
Syntactic DFA:是识别一个正则语言的最小 DFA,其每个状态是语言的一个 MN 同余类。
后缀语言的 MN 类,两种视角
我们可以从 MN 同余的角度看 SAM 的构造。现在请你忘掉所有关于 SAM 的知识,除了这一条:
SAM 是识别 \(s\) 的后缀语言的 syntactic DFA。
其中字符串 \(s\) 的后缀语言是 \(L_s = \set{t \mid t \text{ 是 } s \text{ 的后缀}}\)。
根据 MN 同余,在后缀语言里,有 \(a \MN b \iff a^{-1}L_s = b^{-1}L_s \iff \endpos(a) = \endpos(b)\),其中 \(\endpos(w) := \set{i \in [1, |s|] \mid s_{i-|w|..i}=w}\)。这给了我们两种视角来看待 syntactic DFA 里的节点:
- 作为 \(s\) 的子串的等价类
- 作为一个 \(\endpos\) 集合
相应的,也有两种视角可以看待 syntactic DFA 里的转移:
- \(w\) 所在的等价类加上一个字符 \(c\) 之后变成 \(w+c\) 所在的等价类
- 一个 \(\endpos\) 集合能匹配下一个 \(c\) 的位置,即 \(S \to \set{i + 1 \mid i \in S \land s_i=c}\)
同时我们可以把后缀连接 \(\link\) 定义为下面两个等价的函数:
- 从 \(s\) 指向最长的不与 \(s\) 等价的 \(s\) 的后缀
- 从一个 \(\endpos\) 集合指向一个真包含它的 \(\endpos\) 集合
于是 \(\link\) 构成一棵以 \(\varepsilon\)(或 \(\set{0, 1, \dots, |s|}\))为根的树。
性质
后缀语言的 MN 等价类有一些有趣的性质
- 一个等价类里的任意两个字符串之间是后缀关系(一个是另一个的后缀),且存在一个最长后缀和一个最短后缀,它们之间的所有后缀都在这个等价类里
- \(w+c\) 所在的等价类只能由 \(w\) 的后缀所在的等价类转移得到
- 从 \(s\) 的等价类出发,不停跳 \(\link\) 指针,可以遍历 \(s\) 的所有后缀的等价类
- \(\link\) 指针构成的树有 \(|s|\) 个叶子,每个叶子代表 \(s\) 的一个前缀
- \(s\) 的所有后缀等价类在 \(+c\) 后会粗化
增量地构造 SAM
基于第一种视角,即 \(s\) 子串的等价类,我们可以给出一个增量构造 SAM 的方式。考虑已有了 \(s\) 的 syntactic DFA,如何得到 \(s+c\) 的 syntactic DFA。
先考虑等价类的变化。考虑 \(s\) 原有的两个等价子串 \(w_1, w_2\),在 \(s+c\) 的 MN 关系中是否会变得不等价。这需要讨论两种情况:
- 它们同时是/不是 \(s+c\) 的后缀,此时它们在 \(s+c\) 的 MN 关系中是等价的
- \(w_1\) 不是 \(s+c\) 的后缀,而 \(w_2\) 是 \(s+c\) 的后缀,此时它们不等价
第二种情况需要着重考察。这样的情况只会在 \(s\) 的至多一个等价类中出现,并且这个等价类的一些较短的后缀是 \(s+c\) 的后缀,而较长的则不是。此时需要将其分裂成两个等价类。
除此之外,\(s+c\) 会带来一个新的等价类,那就是 \(s+c\) 自己。
这里也可以这样讨论,就是 \(s+c\) 带来的新子串即为 \(s+c\) 的后缀
现在考虑它们之间的转移如何进行。对于 \(s+c\) 自己所在的等价类,考虑哪些状态能转移到它,即 \(s\) 的哪些后缀 \(+c\) 后不在 \(s\) 中出现。tl;dr: 从 \(s\) 开始跳 \(\link\)(从而枚举 \(s\) 的后缀状态),直到某个状态满足 \(+c\) 后在 \(s\) 中出现了,那么此前的所有状态就应该转移到 \(s+c\)。同时,对于分裂出来的 \(s+c\) 的后缀 \(w=w'+c\) 状态,需要枚举 \(w'\) 的后缀状态,判断其 \(+c\) 后是否是分裂后的状态。
除了维护新的状态和转移外,我们还需要维护 \(\link\) 指针。\(s+c\) 状态的 \(\link\) 就是 \(s+c\) 在 \(s\) 中出现过的最长后缀。分裂出来的新状态的 \(\link\) 需要指向原状态的 \(\link\),而原状态的 \(\link\) 又需要指向分裂出来的新状态……
广义 SAM
SAM 是考虑 \(s\) 的所有后缀,而广义 SAM 则是考虑一个 trie 树的所有后缀,此处的后缀应该定义为“被插入到 trie 树上的所有字符串的后缀的并集”。
我们尝试用类似的方式构造广义 SAM,考虑插入一个新节点 \(c\),设根到其父亲的路径的字符串为 \(s\)(i.e. 插入了一个字符串 \(s+c\))。这时 \(s+c\) 可能会也可能不会分裂出新状态,取决于原 DFA 里 \(s+c\) 是否与某个 \(w+s+c\) 等价,然而这并不很好判定,如果我们能按照长度不降的顺序加入字符串,就可以保证 \(s+c\) 一定是新状态。而 \(s+c\) 的后缀也可能会分裂出新状态,然而同 SAM 一样,只可能有一个状态会分裂。所以只要按照长度不降的顺序依次插入字符,就可以做到广义 SAM 了。
时间复杂度
我们可以证明 SAM 的状态数与转移数都是 \(\mathcal O(n)\) 的,且 SAM 的构造算法也是 \(\mathcal O(n)\) 的。
先考虑状态数。根据前面的算法,每加入一个字符会产生至多两个新状态,从而状态数不超过 \(2n\)。我们也可以从 \(\link\) 树的角度考虑。注意到 \(\link\) 树对应的叶子一定是 \(s\) 的前缀所在的等价类,而树上只有一个儿子的节点也一定是 \(s\) 的前缀所在的等价类,这意味着 \(\link\) 树的大小不超过 \(2n-1\),即状态数不超过 \(2n-1\)。
再考虑转移数。定义一个转移 \(c:p\to q\) 是连续的,如果 \(\text{len}(p)+1=\text{len}(q)\)。从 \(\varepsilon\) 出发,只走连续转移,能得到的字符串一定是一个等价类里最长的那个,所以每个等价类只有最多一条连续入边,那么连续边构成了 SAM 的生成树,最多有 \(2n-2\) 条。而非连续转移 \(c:p\to q\) 可以唯一对应到 \(s\) 的一个后缀:取 \([p]\) 里最长的串 \(u\) 和 \(q\) 到接受状态最长的路径 \(v\),那么 \(u+c+v\) 是 \(s\) 的一个真后缀,并且其中只包含一条非连续转移,故非连续转移最多只有 \(n-1\) 个。加起来,总转移数不超过 \(3n-3\)。
最后考虑构造算法。以 OI Wiki 上的实现为例:
void sam_extend(char c) {
int cur = sz++;
st[cur].len = st[last].len + 1;
int p = last;
while (p != -1 && !st[p].next.count(c)) {
st[p].next[c] = cur;
p = st[p].link;
}
if (p == -1) {
st[cur].link = 0;
} else {
int q = st[p].next[c];
if (st[p].len + 1 == st[q].len) {
st[cur].link = q;
} else {
int clone = sz++;
st[clone].len = st[p].len + 1;
st[clone].next = st[q].next;
st[clone].link = st[q].link;
while (p != -1 && st[p].next[c] == q) {
st[p].next[c] = clone;
p = st[p].link;
}
st[q].link = st[cur].link = clone;
}
}
last = cur;
}
我们需要证明两个 while 循环的运行次数是均摊 \(O(n)\) 的。为此定义势能函数 \(\Phi\) 为 last 状态在 \(\link\) 树中的深度。若算法进入 p==-1 的分支,则 while 的运行次数为 \(T = -\Delta \Phi\);若算法进入 st[p].len+1 == st[q].len 的分支,则 \(\dep(cur)=\dep(q)+1 \le \dep(p)+1=\dep(p')-T+1\),其中 \(p\) 是新值 \(p'\) 是旧值,因此 \(T \le \dep(p')-\dep(q)+1 = -\Delta\Phi+1\);最后,如果发生了状态分裂,\(\dep(cur)=1+\dep(clone)=2+\dep(\link(q)) \ge \dep(p)-1=\dep(p')-\Delta\Phi-1\) (方向好像不对...)
浙公网安备 33010602011771号