【Coel.学习笔记】后缀自动机
来了!NOI 算法中最抽象的字符串算法——后缀自动机!
当然咱只是一个普通的小 OIer,不会搞那么多杂七杂八的ww
引入
后缀自动机(,简称 )是一种确定性有限状态自动机(,简称 ),通过把一个字符串的所有子串存储到一张有向无环图上,并借助图上的状态转移在线性时间内实现字符串的各种操作。
各种定义与性质
SAM 有点过于枯燥(emm),所以直接讲各种定义性质了,先不看例题~
取一张网上的图片作为例子。
性质一:子串对应性
在 SAM 上,所有从起点开始的路径能够与原字符串的子串一一对应,并且 SAM 的边数为 级别。
性质二:节点包含性
每个节点可以对应若干个子串。每个子串都能够与一条从起点到该节点的路径,并且这些子串是其中最长串的连续后缀。
例如对于第四个节点,可以对应的子串有 ,其中 的后缀是 。
性质三:不同的边
第一种边为图例上的蓝色边。类似字典树,可以接在每一个节点的后面,相当于给这个节点对应的所有子串加上一个字符。
还是用第四个节点举例,它的子串 到达节点六就变成了 。
第二种边为图例上的绿色边,称为 link 边或后缀链接。接在每个节点后时,相当于给最短字串去掉一个字符。
第四个节点的最短字串为 ,通过后缀链接到达节点五时变成了 。
这类边能够形成一棵树,这棵树又叫 parent 树。
构造思路
SAM 的构造需要一个叫做 的东西。对于一个子串 , 表示这个子串在字符串中出现时,所有结束位置的集合。
例如对于字符串 ,有 。
在某些情况下两个子串的 可能相等,例如 。这种情况下,这些子串被称为 等价类。SAM 上的所有状态都能和每一个不同的等价类一一对应。
的性质有:
- 对于两个非空子串 和 (设 ),若 ,则字符串 在 中仅以 的后缀形式存在;反之,若两个子串的 完全无交,则 不为 的后缀。
由上述性质可以得到一个推论:两个非空子串的 只可能为包含关系或者完全无交。 - 若 ,则短字符串为长字符串的后缀。
- 对于一个 等价类,若对应的最长子串的后缀 ,满足其长度在最长串与最短串之间,那么 为在等价类对应的集合之中。
换而言之,对于同一等价类的任一两子串,较短者为较长者的后缀,且该等价类中的子串长度恰好覆盖整个区间。
利用 ,我们就可以建立一个满足前面提到条件的 SAM 了。
构造采用增量插入。初始时 SAM 为空,每次把每个字符串添加进去,然后进行维护。
例题讲解
还是从模板开始。
【模板】后缀自动机
洛谷传送门
给定一个字符串,求出所有出现次数不为 的子串中,出现次数与该子串长度乘积的最大值。
解析:先看看怎么求每个子串的出现次数,相当于求出每个子串的 。求解时,我们只需要利用后缀链接边。
举一个例子,对于字符串 ,它们不为后缀关系所以 完全无交;但它们都有一个后缀 ,所以它们又都是 的一个子集。这也就说明了,所有子节点的 其实是对父节点的一个划分。那么,我们只需要找到对应的父节点集合,并求出所有子节点的 之和即可,一个 dfs 就可以搞定。
怎么找集合?其实也很简单,照着定义一步步往下走就行了。
#include <cstring>
#include <iostream>
#define Miolic 0
using namespace std;
const int maxn = 2e6 + 10; // 节点数为串串长度两倍
char s[maxn];
long long ans;
class Suffix_Automaton {
private:
int tot = 1, lst = 1; // lst 为上一个状态
int head[maxn], nxt[maxn], to[maxn], cnt; //构造自动机后存图
long long f[maxn]; // f[i] 表示第 i 个字串的出现次数
struct node {
int len, fa;
int ch[26]; //当字符集较大时使用 map 或哈希表
} node[maxn];
void add(int u, int v) { nxt[cnt] = head[u], to[cnt] = v, head[u] = cnt++; }
public:
void extend(int c) {
int p = lst, nxp = lst = ++tot;
f[tot] = 1;
node[nxp].len = node[p].len + 1; // 新状态长度等于原状态加一
while (p && !node[p].ch[c]) // 一步步向下走
node[p].ch[c] = nxp, p = node[p].fa; // p 向前跳的目的是找到最长串
if (!p) return node[nxp].fa = 1, (void)Miolic;
//若新串后缀处理结束则直接跳出
int q = node[p].ch[c];
if (node[q].len == node[p].len + 1) // 满足条件,直接把父亲向前指
node[nxp].fa = q;
else { // 不满足条件,复制一个新节点并维护
int nxq = ++tot;
node[nxq] = node[q], node[nxq].len = node[p].len + 1;
node[q].fa = node[nxp].fa = nxq;
while (p && node[p].ch[c] == q) node[p].ch[c] = nxq, p = node[p].fa;
}
}
void build() { //按照 SAM 建图跑 dfs
memset(head, -1, sizeof(head));
for (int i = 2; i <= tot; i++) add(node[i].fa, i);
}
void dfs(int u) {
for (int i = head[u]; ~i; i = nxt[i]) {
int v = to[i];
dfs(v);
f[u] += f[v];
}
if (f[u] > 1) ans = max(ans, f[u] * node[u].len);
}
} SAM;
int main(void) {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> (s + 1);
for (int i = 1; s[i]; i++) SAM.extend(s[i] - 'a');
SAM.build();
SAM.dfs(1);
cout << ans;
return 0;
}
可以发现,SAM 的代码量其实很少(核心代码只有三十来行),写起来也很方便。
上面注释提到,当字符集较大时要考虑使用哈希表或 map,这是因为我们给每个后缀都建了一个 ch 数组,当字符串长度很大时内存开销也非常恐怖 (这题就达到了可怕的 ,已经是内存上限的一半了)。
当然如果用了 map 时间复杂度就要多一个 ,用哈希表同样要大内存,见仁见智吧。
下面看几道例题。很多 SAM 的题都可以用其他字符串算法(比如 KMP,AC 自动机,后缀数组)解决,做题时不妨对比一下几种算法的优劣。
[JSOI2012]玄武密码
洛谷传送门
给定一个模式串和若干个匹配串,对每个匹配串找到一个最长前缀的长度,满足该前缀为模式串的子串。
解析:这道题在之前讲 AC 自动机时提到过,可以看看。但是用 SAM 做这题,就非常简单了。
对模式串建立一个 SAM,那么 SAM 上每一个路径都可以对应一个子串。对每一个模式串,直接顺着 SAM 一直走,直到匹配失败时就是最长前缀。
呐,很简单吧?
//板子内容全部略过
inline int get(char c) {
if (c == 'E') return 0;
if (c == 'S') return 1;
if (c == 'W') return 2;
return 3;
}
class Suffix_Automaton {
private:
//...
public:
//...
int solve(char s[]) {
int p = 1, res = 0;
for (int i = 0; s[i]; i++) {
int c = get(s[i]);
if (node[p].ch[c])
p = node[p].ch[c], res++;
else
break;
}
return res;
}
} SAM;
int main(void) {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n >> m;
cin >> s;
for (int i = 0; s[i]; i++) SAM.extend(get(s[i]));
while (m--) {
cin >> s;
cout << SAM.solve(s) << '\n';
}
return 0;
}
[SPOJ1812] LCS2 - Longest Common Substring II
洛谷传送门 双倍经验 三倍经验
(本题另有一个削弱版本:LCS - Longest Common Substring)
给定 个字符串,求这些字符串的最长公共子串长度。(削弱版中 )
解析:先考虑削弱版怎么做。先给第一个字符串建 SAM (其实建哪个都一样),那么我们相当于得到了这个字符串的一个子串集合。
一很容易想到的方法是枚举 LCS 在另一个串上的起点,并同时在 SAM 上跳,直到失配。这样可以保证枚举到所有可能,但实际上并没有发挥 SAM 的优势。
考虑利用 SAM 的性质。由于一个节点对应的子串均为该点对应最长串的后缀,所以失配时相当于排除了所有后缀。处于这种情况时,我们利用 parent 树进行转移,并缩小匹配长度,直到能够继续匹配或者跳出 SAM。这样时间复杂度就会和 SAM 中的边相关(因为只会在 parent 树或者一类边上跳),为 。
接下来考虑多个子串的情况。还是先对第一个串建立 SAM,然后一个个处理。维护一个 数组表示对每个节点的最长匹配长度,则对于这个节点而言,答案为跳到的所有节点中 的最小值;由于要求所有字串的 LCS,所以找到所有节点对应答案最大值即可。
在实际实现中,我们还要自顶向下地更新 。这一点可以通过给 parent 树建图,每处理完一个字符串之后就跑一遍 dfs。
class Suffix_Automaton {
private:
//...
int ans[maxn], now[maxn];
void dfs(int u) {
for (int i = head[u]; ~i; i = nxt[i]) {
int v = to[i];
dfs(v);
now[u] = max(now[u], now[v]);
}
}
public:
//...
void build() { //建图并初始化 ans 数组
memset(head, -1, sizeof(head));
for (int i = 2; i <= tot; i++) add(node[i].fa, i);
for (int i = 1; i <= tot; i++) ans[i] = node[i].len;
}
int solve() {
int res = 0;
while (cin >> s) {
memset(now, 0, sizeof(now));
int p = 1, t = 0;
for (int j = 0; s[j]; j++) {
int c = s[j] - 'a';
while (p > 1 && !node[p].ch[c]) p = node[p].fa, t = node[p].len;
if (node[p].ch[c]) p = node[p].ch[c], t++;
now[p] = max(now[p], t);
}
dfs(1);
for (int j = 1; j <= tot; j++) ans[j] = min(ans[j], now[j]);
}
for (int i = 1; i <= tot; i++) res = max(res, ans[i]);
return res;
}
} SAM;
int main(void) {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n >> s;
for (int i = 0; s[i]; i++) SAM.extend(s[i] - 'a');
SAM.build();
cout << SAM.solve();
return 0;
}
本文作者:Coel's Blog
本文链接:https://www.cnblogs.com/Coel-Flannette/p/16614448.html
版权声明:本作品采用知识共享署名-非商业性使用-禁止演绎 2.5 中国大陆许可协议进行许可。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步