后缀自动机 学习笔记
本文目录(20 节)
因为 SA 就很有用了,所以一直不想学 SAM。突然不知道为什么就想学了,可能是为了吊打兔兔吧。
后缀自动机 (SAM) 是一个能解决许多字符串相关问题的有力的数据结构。
直观上,SAM 可以理解为将字符串的所有子串高度压缩至理论复杂度下界 的形式。
确定有限状态自动机 (DFA)
我们先要了解,什么是确定有限状态自动机 (DFA)。
定义
一个 DFA 可以看成一张有向图,由五个部分构成:
- 状态集合 :图中的点。
- 字符集 :所有合法字符的集合。
- 转移函数 :图中的边,其中 和返回值都是一个状态,分别对应一条边的起点和终点,,表示这条边上的字符。
- 起始状态 :,是一个特殊的状态。
- 接受状态集合 :,是一组特殊的状态。
作用
DFA 可以识别字符串。
当 DFA 读入字符串 时,从 开始按照 一个一个字符地转移。
特别地,如果某个状态 没有字符 的转移,则令 , 只能转移到 ,且 。
如果读入完 后处在 内,就称这个 DFA 接受 。
扩展定义转移函数 ,其中 是一个由 内的字符构成的字符串,则 。
一个 DFA 是否接受 ,等价于求 的值。
后缀自动机 (SAM)
后缀自动机 (SAM) 是接受且仅接受字符串 的所有后缀的最小 DFA。
构造
等价类
在一些教程中, 也被称作 。本文选择 ,是因为同样存在定义类似的 ,而不存在 的叫法。
对于 的子串 ,记 为 中 的所有结束(即最右边的字符)位置。如对于字符串 ,。特别地,空串的 定义为 。
若两个非空子串 ,满足 ,则将他们划分到一个 等价类中。
SAM 中的每个状态都对应着一个 等价类,其中 对应着空串所在的等价类。
性质
- 考虑一个子串 ,我们每次去掉 的首字母,则它所在的 要么不变,要么会增加若干个新位置。
形式化地,对于两个子串 ,不妨设 :
若 为 的后缀,则 ;
否则,说明 所在的位置 都不在,即 。 - 反过来考虑更强一点的性质,如果若干个子串的 一样,意味着它们在 中的出现位置一样,且它们一定是由一个子串 不断去掉首字母,但还未能增加新位置时的所有子串构成的。
因此,在一个 等价类中,较短的子串为较长的子串的后缀,且所有子串的长度恰好覆盖一个区间。
后缀链接
对于一个不是 的状态 ,设它对应的等价类中最短的子串为 。
后缀链接 。
另外,对于一个状态 ,记 表示它对应的等价类中最长的子串的长度。特别的,。
性质
- 依然考虑不断去掉一个子串 的首字母:
如果还未能增加新位置,则还在同一个 等价类中;
否则,会跳到其 等价类中。
因此,对于一个不是 的状态 ,将其视作 集合,有 。 - 根据上条性质,对于一个不是 的状态 ,它对应的 等价类中,子串的长度区间为 ,即 对应的子串数量为 。
- 所有 构成一棵以 为根的树,我们称其为 树。
构造 SAM
一开始 SAM 只包含一个状态 ,编号为 。为了方便,不妨令 。
考虑依次添加字符,同时维护每个状态的 , 和转移。
令 表示任意时刻整个字符串对应的状态,每次添加一个字符 的过程如下:
- 创建一个新的状态 ,并将 设为 。
- 从 开始遍历 :
- 如果当前状态不存在字符 的转移,则添加一个到 的转移。
- 如果当前状态存在字符 的转移,则将这个状态标记为 ,转移到的状态标记为 ,然后跳出遍历。
- 接下来有三种情况:
- 如果始终没有状态存在字符 的转移,则将 设为 ,然后退出。
- 否则,若 ,则将 设为 ,然后退出。
- 否则,再创建一个新的状态 ,将 的 和转移复制到 上,将 设为 ,将 和 都设为 。然后从 往回遍历 ,若存在到 的转移,则将转移重定向到 上。
- 更新 ,即将 设为 。
最后,我们再从 遍历一次 ,则 即由遍历到的状态构成。
复杂度
如果转移用 map 存储,则时间复杂度为 ,空间复杂度为 。
如果转移用数组存储,同时借助链表,则时间复杂度为 ,空间复杂度为 。如果不借助链表,时间复杂度为 ,空间复杂度为 。
状态数和转移数上界
对于 的字符串,状态数 ,上界可以在 达到。
对于 的字符串,转移数 ,上界可以在 达到。
性质
- 的子串和 SAM 中从 开始的路径一一对应。
对于一个非 状态 , 对应的 等价类大小 对应的子串数量 从 开始到 的路径数量 。 - 称 的前缀在 SAM 上对应的节点为终点节点。
对于一个非 状态 , 对应的 集合 其 树的子树内的终点节点所对应前缀的位置 对应的子串在 中出现的位置。
根据此条性质,我们可以方便地采用线段树合并求出 集合。 - 说白了,SAM 中的概念,包括 、、终点节点、前缀、后缀、子串、路径,全都能串起来。
串起来之后,随便组合就有各种各样的性质。
例如, 的任意两个前缀对应的终点节点在 树上的 LCA 所对应的最长子串就是这两个前缀的最长公共后缀。
应用
【模板】P3804 【模板】后缀自动机 (SAM)
根据:
- 对于一个非 状态 , 对应的 集合 其 树的子树内的终点节点所对应前缀的位置 对应的子串在 中出现的位置。
每个前缀所在的状态有贡献 。
那么,对每个状态求出其 树上的子树贡献和,然后乘上这个状态的 ,最后取 即可。
const int N = 1e6 + 7;
struct SAM {
int n, l, ch[N<<1][26], len[N<<1], pa[N<<1], t;
char s[N];
inline void init() { pa[0] = -1; }
inline void add(int c) {
int w = ++t, p = l;
len[w] = len[l] + 1;
while (~p && !ch[p][c]) ch[p][c] = w, p = pa[p];
if (!~p) return pa[l=w] = 0, void();
int q = ch[p][c];
if (len[p] + 1 == len[q]) return pa[l=w] = q, void();
int k = ++t;
pa[k] = pa[q], memcpy(ch[k], ch[q], sizeof(ch[k]));
len[k] = len[p] + 1, pa[w] = pa[q] = k;
while (~p && ch[p][c] == q) ch[p][c] = k, p = pa[p];
l = w;
}
inline void build() {
init();
for (int i = 1; i <= n; i++) add(s[i] - 'a');
}
int cnt[N<<1];
ll ans;
vi e[N<<1];
void dfs(int x) {
for (int y : e[x]) dfs(y), cnt[x] += cnt[y];
if (cnt[x] > 1) ans = max(ans, 1ll * cnt[x] * len[x]);
}
inline void calc() {
for (int i = 1, p = 0; i <= n; i++) p = ch[p][s[i]-'a'], ++cnt[p];
for (int i = 1; i <= t; i++) e[pa[i]].pb(i);
dfs(0), print(ans);
}
} sam;
int main() {
rds(sam.s, sam.n);
sam.build(), sam.calc();
return 0;
}
【例题】P4070 [SDOI2016]生成魔咒
动态子串数。
每加入一个字符,本质不同的子串数就会加 个。
【例题】SP7258 SUBLEX - Lexicographical Substring Search
第 小子串。
由于 SAM 是个 DAG,因此设 表示从 开始的路径条数,拓扑排序后再 DAG 上 dp。
这里拓扑排序可以利用基数排序,借助转移一定是从 小的往大的即可。
找答案时,从根开始贪心地找。
const int N = 9e4 + 7;
struct SAM {
int n, l, len[N<<1], pa[N<<1], t;
char s[N];
map<char, int> ch[N<<1];
inline void init() { pa[0] = -1; }
inline void add(char c) {
int w = ++t, p = l;
len[w] = len[l] + 1;
while (~p && !ch[p][c]) ch[p][c] = w, p = pa[p];
if (!~p) return pa[l=w] = 0, void();
int q = ch[p][c];
if (len[p] + 1 == len[q]) return pa[l=w] = q, void();
int k = ++t;
pa[k] = pa[q], ch[k] = ch[q];
len[k] = len[p] + 1, pa[w] = pa[q] = k;
while (~p && ch[p][c] == q) ch[p][c] = k, p = pa[p];
l = w;
}
inline void build() {
init();
for (int i = 1; i <= n; i++) add(s[i]);
}
int b[N<<1], c[N];
ll cnt[N<<1];
inline void sort() {
for (int i = 1; i <= t; i++) ++c[len[i]];
for (int i = 1; i <= n; i++) c[i] += c[i-1];
for (int i = 1; i <= t; i++) b[c[len[i]]--] = i;
for (int i = t; ~i; i--) {
int x = b[i];
cnt[x] = 1;
for (auto o : ch[x]) cnt[x] += cnt[o.se];
}
}
inline void ask(int x) {
int p = 0;
while (x)
for (auto o : ch[p])
if (x - cnt[o.se] > 0) x -= cnt[o.se];
else {
putchar(o.fi), p = o.se, --x;
break;
}
putchar('\n');
}
} sam;
int main() {
rds(sam.s, sam.n), sam.build(), sam.sort();
int q, x;
rd(q);
while (q--) rd(x), sam.ask(x);
return 0;
}
【例题】SP1811 LCS - Longest Common Substring
两个串的最大公共子串。
对第一个串建 SAM,然后依次加入第二个串的字符,求当前第二个串的是第一个串的子串的最长后缀。
用一个指针记录当前解在 SAM 上的位置,加入一个字符时先看能不能匹配,如果能就直接匹配,不能就暴力跳 ,直到可以匹配为止。
每次的解取 即为最优解。
const int N = 2.5e5 + 7;
struct SAM {
int n, l, len[N<<1], pa[N<<1], t;
char s[N];
map<char, int> ch[N<<1];
inline void init() { pa[0] = -1; }
inline void add(char c) {
int w = ++t, p = l;
len[w] = len[l] + 1;
while (~p && !ch[p][c]) ch[p][c] = w, p = pa[p];
if (!~p) return pa[l=w] = 0, void();
int q = ch[p][c];
if (len[p] + 1 == len[q]) return pa[l=w] = q, void();
int k = ++t;
pa[k] = pa[q], ch[k] = ch[q];
len[k] = len[p] + 1, pa[w] = pa[q] = k;
while (~p && ch[p][c] == q) ch[p][c] = k, p = pa[p];
l = w;
}
inline void build() {
init();
for (int i = 1; i <= n; i++) add(s[i]);
}
int op, ol, ans;
inline void calc(char c) {
while (op && !ch[op][c]) op = pa[op], ol = len[op];
if (ch[op][c]) op = ch[op][c], ++ol;
ans = max(ans, ol);
}
} sam;
char s[N];
int n;
int main() {
rds(sam.s, sam.n), sam.build();
rds(s, n);
for (int i = 1; i <= n; i++) sam.calc(s[i]);
print(sam.ans);
return 0;
}
【例题】SP10570 LONGCS - Longest Common Substring
多个串的最大公共子串。
依然对第一个串建 SAM,类似两个串的情况,将其他串放到 SAM 上匹配。
维护 SAM 每个状态的最长匹配长度,每次的解取 ,所有状态取 即可。
const int N = 1e4 + 7;
char s[N];
int n, ans[N<<1];
struct SAM {
int n, l, ch[N<<1][26], len[N<<1], pa[N<<1], t;
char s[N];
inline void init() {
for (int i = 0; i <= t; i++)
memset(ch[i], 0, sizeof(ch[i]));
t = l = len[0] = 0, pa[0] = -1;
}
inline void add(int c) {
int w = ++t, p = l;
len[w] = len[l] + 1;
while (~p && !ch[p][c]) ch[p][c] = w, p = pa[p];
if (!~p) return pa[l=w] = 0, void();
int q = ch[p][c];
if (len[p] + 1 == len[q]) return pa[l=w] = q, void();
int k = ++t;
pa[k] = pa[q], memcpy(ch[k], ch[q], sizeof(ch[k]));
len[k] = len[p] + 1, pa[w] = pa[q] = k;
while (~p && ch[p][c] == q) ch[p][c] = k, p = pa[p];
l = w;
}
inline void build() {
init();
for (int i = 1; i <= n; i++) add(s[i] - 'a');
}
int b[N<<1], c[N];
inline void sort() {
for (int i = 1; i <= n; i++) c[i] = 0;
for (int i = 1; i <= t; i++) ++c[len[i]];
for (int i = 1; i <= n; i++) c[i] += c[i-1];
for (int i = 1; i <= t; i++) b[c[len[i]]--] = i;
}
int op, ol, ans[N<<1];
inline void calc(int c) {
while (op && !ch[op][c]) op = pa[op], ol = len[op];
if (ch[op][c]) op = ch[op][c], ++ol, ans[op] = max(ans[op], ol);
}
inline void update() {
for (int i = t; i; i--) {
int x = b[i];
ans[pa[x]] = max(ans[pa[x]], min(ans[x], len[pa[x]]));
::ans[x] = min(::ans[x], ans[x]), ans[x] = 0;
}
}
} sam;
inline void solve() {
int m;
rd(m), rds(sam.s, sam.n), sam.build(), sam.sort();
for (int i = 1; i <= sam.t; i++) ans[i] = 1e9;
while (--m) {
rds(s, n), sam.op = sam.ol = 0;
for (int i = 1; i <= n; i++) sam.calc(s[i] - 'a');
sam.update();
}
print(*max_element(ans + 1, ans + sam.t + 1));
}
int main() {
int T;
rd(T);
while (T--) solve();
return 0;
}
参考资料
- OI Wiki 后缀自动机 (SAM)
- OI Wiki 自动机
- EternalAlexander 炫酷后缀树魔术
- 张天扬 后缀自动机及其应用
- 吴作同 后缀自动机的理解与运用
评论
暂无已公开评论。