后缀自动机学习笔记
定义与性质#
后缀自动机(Suffix Automaton,简称 SAM)是用于解决字符串问题的强有力的工具。它是一个能够接受字符串所有后缀的最小 DFA。
SAM 是一张有向无环图,每个节点叫做状态,每条边被称作转移。
SAM 存在一个源点 ,称为初始状态。任何状态均可从初始状态到达。
每一个转移都对应于一个字母,每个状态的转移都各不相同。
存在若干个终止状态。从 出发走到一个终止状态,则这条路径对应原串的一个后缀。同样的,任意一个后缀都可以通过 SAM 上从初始状态到终止状态的一条路径来表示。
SAM 上每一条路径对应原串的一个子串。换句话说, SAM 上的路径和原串上的子串一一对应。
到达某个状态的路径不止一条,所以一个状态表示的实际上是一个集合。
构造 SAM#
在构造 SAM 之前,我们需要了解一些前置知识。
结束位置集合 endpos#
对于串 的任意一个非空的子串 ,我们定义 为字符串 在 中出现的末尾位置的集合。
例如,有 , 的 (下标从 开始)。
两个非空子串 的 可能相等,我们称这两个 是等价的,它们同属于同一个等价类。原串 的所有子串被这样划分为若干个等价类。
引理1:同属于同一个等价类的串之间必然有后缀关系。如果两个字符串 的 相同,当且仅当 在 中每次出现,都是 的后缀。
由定义,引理显然成立。
引理2:考虑两个 的子串 ,有
如果 是 的后缀,那么每一次 出现 都会出现,所以 。否则,两者绝对不会有相同的 。
引理3:每一个 类都是由若干个长度连续,并且具有后缀关系的子串构成的。
上面引理的推论
引理4:同一个 类中的子串同时加上同一个字符后,它们仍然属于同一个 类。
同时加上一个字符,仍然满足后缀关系。
后缀链接和 parent 树#
对于一个不是初始状态的状态 ,我们将其中的串按照长度从大到小排序,由引理3,除去最大的以外,其余每个子串都是最大串的后缀。记这个最大串为 ,其长度为 。
我们找到第一个 的后缀 使得 。我们就将 。
引理5:所有后缀链接构成了一棵树。
每个状态的出度和入度都为 。我们称这棵树为 parent 树。
引理6:由后缀链接构成的 parent 树与后缀树相同。
不知道。
构造方法#
我们定义在插入某个字符 前的状态为 ,新插入的状态为 。
我们沿着 的 边向上跳,设每次跳到状态 。如果 没有连向 的边,那么就讲它直接连向 。
如果最后跳到了 就将 。
否则分成两种情况:
我们定义状态 为 转移为 的状态。
-
说明 和 之间只差了一个字符。此时可以直接 。
-
否则,我们需要新建一个节点来辅助建边。
我们新建状态 ,复制其除了 的所有信息。
让 。
此时我们让 。
正确性证明#
不会。
代码实现#
class Suffix_Automaton {
struct node {
int len, fail;
int ch[26];
} tr[N << 1];
int tot = 1, last = 1;
public:
void ins(int x) {
int p = last, q, now = ++tot;
siz[now] = 1;
tr[now].len = tr[p].len + 1;
for (; p && !tr[p].ch[x]; p = tr[p].fail)
tr[p].ch[x] = now;
if (!p) {
tr[now].fail = 1;
} else {
q = tr[p].ch[x];
if (tr[q].len == tr[p].len + 1) {
tr[now].fail = q;
} else {
int cl = ++tot;
tr[cl] = tr[q];
tr[cl].len = tr[p].len + 1;
tr[q].fail = tr[now].fail = cl;
for (; p && tr[p].ch[x] == q; p = tr[p].fail)
tr[p].ch[x] = cl;
}
}
last = now;
}
} SAM;
应用#
自动机#
SAM 接收了关于一个字符串的所有信息。每一条从原点出发的路径都代表着原串的一个子串,并且不是原串子串的一定不是从原点出发的路径。
和一般的自动机一样,我们可以在上面进行字符串的匹配与查找。
parent树#
本质上是被压缩过的后缀树。后缀自动机的独特之处在于 和 。
上面已经说过,同一个状态中的串连续并满足后缀关系,是它的 的真子集。
我们可以在 parent 树上进行差分, 即为同等价类的串的个数。
不同子串个数#
统计自动机上路径条数即可。
或者统计每个等价类包含的串的个数。
如果要统计总长度,改一下求和就行了。
子串出现次数#
利用树形结构,我们定义 为状态 的 集合大小。
由于 满足后缀关系。所以,如果一个串在某个地方被匹配到了,那么它将在它的所有子节点(parent 树上)的同样位置出现。
所以对子树的 求和即可。
void dfs(int x) {
// siz 在构建的时候为1
for (int i = head[x]; i; i = e[i].next) {
const int y = e[i].to;
dfs(y);
siz[x] += siz[y];
}
}
本质不同子串个数#
设为 1 就无了。
区间本质不同子串个数#
好玩 SAM + LCT + 线段树。码量就 150 行。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· go语言实现终端里的倒计时
· 如何编写易于单元测试的代码
· 10年+ .NET Coder 心语,封装的思维:从隐藏、稳定开始理解其本质意义
· .NET Core 中如何实现缓存的预热?
· 从 HTTP 原因短语缺失研究 HTTP/2 和 HTTP/3 的设计差异
· 分享一个免费、快速、无限量使用的满血 DeepSeek R1 模型,支持深度思考和联网搜索!
· 使用C#创建一个MCP客户端
· 基于 Docker 搭建 FRP 内网穿透开源项目(很简单哒)
· ollama系列1:轻松3步本地部署deepseek,普通电脑可用
· 按钮权限的设计及实现