后缀自动机 (SAM) 学习笔记
一、定义
字符串
- SAM 是一张有向无环图。它的结点是图中的状态,边是状态之间的转移。
- SAM 有源点
,且其它各结点均可从 出发到达。 - SAM 中每个转移都标有一个字母,且从一个结点出发的所有转移都是不同的。
- SAM 存在一些终止状态。特殊地,到达一个终止状态时从
到该状态的路径连接起来是字符串 的后缀。反之, 的每个后缀同样可从一条由 到某个终止状态的路径构成。 - 满足这样条件的自动机有多个,而 SAM 的结点数是最少的。
让我们举一个例子来描述一个对于字符串
需要注意的是,SAM 的结点个数和边数都是
二、 等价类及其性质
这一部分的内容,似乎和 SAM 没有直接关系,但却是 SAM 中很重要的一部分。我们需要证明关于它的一些性质,并得出一些结论,这是我们构建 SAM 的基础。
1. 定义
对于字符串
2. 性质及其证明
- 若串
满足 ,且 ,则 。
证明:若存在
满足 ,则对于任意 ,由 有 ,显然矛盾。
- 对于
的任意后缀 ,有 。
证明:由
的定义知道每个 能匹配到的右端点 必能匹配。
- 若两个不同的串
满足 ,则对于 ,一定存在 满足 且 。
证明:由 1,
,不妨设 。令 ,由 2, 。因此 。
集合相等的字符串的长度必然是连续的。
证明:设其中两个不同串为
,由 1,可不妨设 。由 3,必然有 满足 。
- 对于两个
集合 ,要么 ,要么 。
证明:设
,那么设从 到 结点路径表示的字符串的集合为 。记 表示集合 中最长的串的长度, 同理,则由 4, 。不妨设 ,则对于任意 ,有 。又因为 ,由 2, 。考虑 的情形,则 。
三、 parent 树及 SAM 的复杂度
根据上面的性质,任意两个
让我们进一步发现 parent 树上的一些奇妙性质:
- parent 树有
个儿子。
证明:显然会存在的叶子结点
集合为 。
- parent 树的状态不会超过
级别。
证明:由于一个点至少有两个子结点,那么新增一个结点必然会删去两个结点,因此最多新增
个非叶子结点。于是总的状态级别是 。
那么我们已经证明了 SAM 状态数是
四、SAM 的构造
1. 构造流程
初始情况是只有状态
我们创建新的状态
若
最后,我们从
需要知道的是,建完这个 SAM 后对应的终止状态就是
2. 正确性证明
需要证明的部分只有
需要知晓的是,这样构建 SAM 的时间复杂度是 std::map
来存边,此时时间复杂度为
这里给出 SAM 的一般实现:
struct SAM {
int len, fa;
int s[M];
} sam[N];
int tot = 1, lst = 1;
void insert(int c) {
int p = lst, np = lst = ++tot;
sam[np].len = sam[p].len + 1;
for (; p && !sam[p].s[c]; p = sam[p].fa) sam[p].s[c] = np;
if (!p) sam[np].fa = 1;
else {
int q = sam[p].s[c];
if (sam[q].len == sam[p].len + 1) sam[np].fa = q;
else {
int nq = ++tot;
sam[nq] = sam[q], sam[nq].len = sam[p].len + 1;
sam[q].fa = sam[np].fa = nq;
for (; p && sam[p].s[c] == q; p = sam[p].fa) sam[p].s[c] = nq;
}
}
}
五、SAM 的基础应用
1. 求本质不同子串个数
一般的方法是求每个状态内子串的个数。也就是
2. 求第 小子串
考虑到每个子串唯一对应着 SAM 上一条路径,于是转化为求 SAM 上字典序第
3.求两个字符串的最长公共子串
对于两个字符串
当
给出代码实现:
int fnd(char *s) {
int p = 1, ans = 0, l = strlen(s), res = 0;
for (int i = 0; i < l; i++) {
int c = s[i] - '0';
while (p > 1 && !sam[p].s[c]) {
p = sam[p].fa;
ans = sam[p].len;
}
if (sam[p].s[c]) {
p = sam[p].s[c];
++ans;
}
res = max(res, ans);
}
return res;
}
4. 线段树合并维护 集合
考虑在 parent 树上对
5. SAM 求后缀的最长公共前缀
考虑将字符串反向后插入 SAM 中,这样问题转化为了前缀的最长公共后缀,那么 parent 树上所有父亲串一定是儿子串的前缀。于是找到两个字符串对应的结点,求它们的 LCA 即可。
六、广义 SAM
广义 SAM,就是对多个字符串建出的 SAM。如果暴力将它们连接,往往会出现各种各样的问题,因此需要掌握建立正确的广义 SAM 的方法。
我们先针对这些字符串建出 Trie,在此基础上将 Trie 的每条边建到广义 SAM 中即可。我们通常适用的方法是离线 BFS。下面给出代码实现:
struct Trie {
int fa, ch;
int s[M];
} tr[N];
int cnt = 1;
void ins(char *s) {
int l = strlen(s), p = 1;
for (int i = 0; i < l; i++) {
int ch = s[i] - '0';
if (!tr[p].s[ch]) {
tr[p].s[ch] = ++cnt;
tr[cnt].fa = p;
tr[cnt].ch = ch;
}
p = tr[p].s[ch];
}
}
struct SAM {
int len, fa;
int s[M];
} sam[N];
int tot = 1;
int insert(int c, int lst) {
int p = lst, np = lst = ++tot;
sam[np].len = sam[p].len + 1;
for (; p && !sam[p].s[c]; p = sam[p].fa) sam[p].s[c] = np;
if (!p) sam[np].fa = 1;
else {
int q = sam[p].s[c];
if (sam[q].len == sam[p].len + 1) sam[np].fa = q;
else {
int nq = ++tot;
sam[nq] = sam[q], sam[nq].len = sam[p].len + 1;
sam[q].fa = sam[np].fa = nq;
for (; p && sam[p].s[c] == q; p = sam[p].fa) sam[p].s[c] = nq;
}
}
return lst;
}
queue<int>q;
int pos[N];
void build() {
for (int i = 0; i < M; i++)
if (tr[1].s[i]) q.push(tr[1].s[i]);
pos[1] = 1;
while (!q.empty()) {
int p = q.front();
q.pop();
pos[p] = insert(tr[p].ch, pos[tr[p].fa]);
for (int i = 0; i < M; i++)
if (tr[p].s[i]) q.push(tr[p].s[i]);
}
}
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 分享一个免费、快速、无限量使用的满血 DeepSeek R1 模型,支持深度思考和联网搜索!
· 基于 Docker 搭建 FRP 内网穿透开源项目(很简单哒)
· ollama系列01:轻松3步本地部署deepseek,普通电脑可用
· 25岁的心里话
· 按钮权限的设计及实现