Appearance
即后缀自动机。
SAM 主要用于表示一个字符串的所有子串,并支持子串存在性等询问。
“SAM 是最小 DFA”的标准表述是:对于固定字符串 last 状态及其 suffix link 祖先是接受状态;若把空后缀也计入语言,则根节点也是接受状态。不存在的转移表示拒绝,若要求完整 DFA,还需额外加入一个拒绝状态。
从根出发沿字符转移能够读出的字符串恰好是
比如对于字符串
Trie 图:
简单来说,SAM 上有两类边。第一类是字符转移边(上图的蓝边),它们形成一个 DAG,根节点表示空串。从根开始的每条路径所拼出的字符串都是
对于子串
转移 son[x][c] = y 表示:当前路径对应的字符串末尾追加字符
空间复杂度:
fail 树:
有的地方也称作:slink 树、parent 树、后缀链接树。
第二类边是 suffix link,代码中记为 fail。对于根以外的状态 fail[v] 指向
如上图中,状态
令
区间左端点由 suffix link 唯一确定:
因此构造时只需显式维护代码中的 maxlen。沿某个状态的 suffix link 向根移动,可以依次得到 endpos 集合逐渐扩大的后缀等价类;结合每个状态的长度区间,才能还原其代表的所有连续后缀。
SAM 是增量构造,可以对一个字符串在线地逐步加入字符构造 SAM。至于构造部分原理,和做题无关,略。
时间复杂度:
点击展开代码
cpp
struct SAM {
int maxlen[N], fail[N], son[N][26];
int idx = 1;
void extend(string &s) {
int cur = 1, p, np;
for (auto u : s) {
int c = u - 'a';
p = cur;
cur = ++idx;
np = cur;
maxlen[np] = maxlen[p] + 1;
for (; p && !son[p][c]; p = fail[p])
son[p][c] = np;
if (!p)
fail[np] = 1;
else {
int q = son[p][c];
if (maxlen[q] == maxlen[p] + 1)
fail[np] = q;
else {
int nq = ++idx;
maxlen[nq] = maxlen[q], fail[nq] = fail[q];
memcpy(son[nq], son[q], sizeof son[nq]);
maxlen[nq] = maxlen[p] + 1;
fail[q] = fail[np] = nq;
for (; p && son[p][c] == q; p = fail[p])
son[p][c] = nq;
}
}
}
}
};endpos 与出现次数:
fail 树中,后代状态的 endpos 集合包含于祖先状态,但这些集合彼此可能重叠,不能把子树内各状态的 endpos 集合大小直接相加。正确的集合表述是:对于每个前缀 last_i 上,则
统计出现次数时,可以在加入第 occ[np] = 1,clone 状态令 occ[nq] = 0。构造完成后按 maxlen 从大到小遍历状态,并执行 occ[fail[v]] += occ[v],最终 occ[v] 就是
广义 SAM
SAM 通常针对单个字符串构造,广义 SAM 则表示多个字符串的子串集合。此时应把 endpos 定义为二元组“字符串编号、串内结束位置”,避免混淆不同字符串中的相同位置编号。构造时还必须阻止跨越两个字符串边界的子串进入自动机。
常见构造方式有两种:
- 逐串构造:处理每个新字符串前把
last重置为根,再逐字符扩展。所用扩展函数必须能够正确处理自动机中已经存在的转移,不能未经说明就把只证明过单串构造的模板重复调用。 - Trie 上构造:先把所有字符串插入 Trie,再按 Trie 的父子转移构造 SAM。相同前缀只在 Trie 中保存一次,适合字典规模的多串构造。
上方 extend 代码只说明从空 SAM 构造一个字符串的情形;若要实现广义 SAM,应根据所选方案另行给出初始化、扩展顺序和出现次数统计规则。
