后缀自动机 SAM
定义与性质
构建过程
算法正确性证明
算法线性性质证明
参考博客
blog3 (夯)
可视化
模板
字符集为小写字母的版本
struct State{
int len, link;
int nxt[26];
};
const int N = 100005;
State st[N*2];
int tot, last;
void sam_init(){
st[0].len = 0;
st[0].link = -1;
tot = last = 0; // 初始状态编号为 0
}
// 每次新加入一个字符,动态构建 SAM
void sam_extend(int c){
int cur = ++tot;
st[cur].len = st[last].len + 1;
int p = last;
while(p != -1 && !st[p].nxt[c]){
st[p].nxt[c] = cur;
p = st[p].link;
}
if(p == -1){
st[cur].link = 0;
}
else{
int q = st[p].nxt[c];
if(st[p].len + 1 == st[q].len){
st[cur].link = q;
}
else{
int clone = ++tot;
st[clone].len = st[p].len + 1;
for(int i = 0; i < 26; i ++) st[clone].nxt[i] = st[q].nxt[i];
st[clone].link = st[q].link;
while(p != -1 && st[p].nxt[c] == q){
st[p].nxt[c] = clone;
p = st[p].link;
}
st[q].link = st[cur].link = clone;
}
}
last = cur;
}
map 版本
struct State{
int len, link;
map<int,int> nxt;
};
const int N = 100005;
State st[N*2];
int tot, last;
void sam_init(){
st[0].len = 0;
st[0].link = -1;
tot = last = 0; // 初始状态编号为 0
}
// 每次新加入一个字符,动态构建 SAM
void sam_extend(int c){
int cur = ++tot;
st[cur].len = st[last].len + 1;
int p = last;
while(p != -1 && !st[p].nxt.count(c)){
st[p].nxt[c] = cur;
p = st[p].link;
}
if(p == -1){
st[cur].link = 0;
}
else{
int q = st[p].nxt[c];
if(st[p].len + 1 == st[q].len){
st[cur].link = q;
}
else{
int clone = ++tot;
st[clone].len = st[p].len + 1;
// for(int i = 0; i < 26; i ++) st[clone].nxt[i] = st[q].nxt[i];
st[clone].nxt = st[q].nxt;
st[clone].link = st[q].link;
while(p != -1 && st[p].nxt.count(c) && st[p].nxt[c] == q){
st[p].nxt[c] = clone;
p = st[p].link;
}
st[q].link = st[cur].link = clone;
}
}
last = cur;
}
应用
是否作为子串出现
子串出现次数
子串第一次出现位置
本质不同子串个数
所有本质不同子串的总长度
字典序第 \(k\) 大子串
最小循环移位
最长公共子串长度
例题
P3804
Q:给定一个字符串,求出现的所有子串中,子串出现次数与子串长度乘积的最大值。
先由后缀链接 \(link\) 汇总,得到每个结点代表子串的出现次数;显然对于每个 \(endpos\) 等价类,贪心选取最长后缀是最优的,而每个结点对应子串集合的最长长度恰好为 \(len_{v}\),故取所有结点 \(v\) 的 \(cnt_{v} * len_{v}\) 的最大值即为答案。
P4070
Q:文本串初始为空,多次操作,每次往文本串末尾加入一个字符,求每次操作后本质不同子串的数量。
考虑在动态构建 SAM 的过程中统计增量。每次加入一个字符后(原串为 \(S\),新加字符为 \(c\)),新产生的子串一定以刚加入的字符结尾,故累计增量时,我们只需要关注 SAM 内插入字符后新产生的状态带来的影响。设新产生的状态为 \(cur\),考虑后缀链接 \(link\) 的性质,不难发现 \(link[cur]\) 内的串均是 \(S + c\) 的后缀,且已经在 \(S\) 中出现过,不需要再重复统计,进而增量为 \(len[cur] - len[link[cur]]\)。
P3975
Q:给定一个字符串与两个整数 \(op,k\),求:
- \(op=0\) 时,求不同位置的相同子串视作相同时,字典序第 \(k\) 小的子串
- \(op=1\) 时,求不同位置的相同子串视作不同时,字典序第 \(k\) 小的子串
先讨论 \(op = 1\) 时的情况:此时不需要考虑子串 “本质不同” 的问题,可以直接由后缀链接 \(link\) 汇总经过每个结点的子串个数(每个结点的 \(cnt\) 代表属于对应 \(endpos\) 等价类的子串在原串中的出现次数,即 \(endpos\) 集合大小);
再讨论 \(op = 0\) 时的情况:此时对于出现在不同位置上的相同子串无需作区分,那么也就无需像前面那样将所有子串按照后缀链接 \(link\) 进行汇总,而是将每个结点的 \(cnt\) 直接赋值为 \(1\) 即可(初始结点除外,因为空串不参与)。
两种不同情况均会处理出每个结点的 \(cnt\),表示子串出现次数;然后再对 \(cnt\) 进行关于转移边的汇总,得到经过每个结点 \(u\) 的子串数量 \(f_{u}\)。
最后为了找到字典序第 \(k\) 小的子串,我们从 SAM 的根节点出发,按字符字典序升序依次遍历每个字符,根据上述求得的 \(f_{u}\) 与 \(k\) 的大小关系来判定应该走哪个字符。这个过程与线段树上二分求第 \(k\) 小比较类似,递归实现即可。
P5341
Q:给定一个字符串与整数 \(k\),求出现次数恰好 \(k\) 次的所有子串中,出现次数最多的长度。
通过后缀链接 \(link\) 汇总 \(cnt\),得到每个结点所属 \(endpos\) 等价类内所有子串的出现次数。查找所有 \(cnt_{v} = k\) 的结点,并统计所有出现过的长度。由 \(endpos\) 等价类的性质,里面的所有子串呈连续的后缀包含形式,且长度区间范围 \([len_{link_{v}} + 1, len_{v}]\),差分加速计数即可。
P4248
Q:给定一个长度为 \(n\) 的字符串,令 \(T_{i}\) 表示它从第 \(i\) 个字符开始的后缀,求:
前面两个增量的贡献为定值 \(\frac{n(n+1)(n-1)}{2}\),问题等价于求:
求某个字符串任意一对后缀的最长公共前缀长度之和,等价于求该字符串反串的任意一对前缀的最长公共后缀之和。对原串的反串建 SAM,考虑后缀链接树,存在性质:原串任意两个不同前缀的最长公共后缀长度等于这两个前缀所在 SAM 结点的 \(lca\) 的 \(len\) 值。于是,我们只需要在每个结点 \(u\) 统计有多少对子串(仍按照传统的后缀链接汇总 \(cnt\) 的方式,因为每个新建结点最终会成为 SAM 的叶节点,也代表了原串每个前缀的结尾位置)对应的一对结点的 \(lca\) 为 \(u\),类似树上 \(dp\) 计数即可。具体细节见 code。
ABC433G
在 SAM 的 DAG 上进行博弈 dp。
2021ICPC沈阳M
Q:给定一个字符串,对于该字符串的每个前缀,求字典序最大的子串所在区间。
显然对于每个前缀,字典序最大的子串一定是该前缀的某个后缀,因此右端点不需要考虑,只需要考虑左端点。
一个暴力的想法是:每次加入新字符、动态更新 SAM 后,从根节点开始,贪心地走存在的、字符字典序最大的边。这样需要跑 \(O(n)\) 次暴力。
考虑优化:想办法只跑一次暴力,并沿途更新所有前缀位置的答案。
暴力过程仍然是每次贪心地走存在的、字符字典序最大的边,配合后序遍历,可以达到访问的所有子串按字典序逆序访问的效果。因此第一次访问到某个结点时(这样每个结点只需要经过一次,保证复杂度),经过路径形成的子串一定是关于该结点对应 \(endpos\) 等价类内的所有结尾位置,字典序最大的后缀。我们只需要将 \(endpos\) 集合内的所有位置都用该后缀的长度更新一遍答案即可。
然而,所有结点的 \(endpos\) 集合大小总和是巨大的,不能直接暴力地更新每个位置。考虑用线段树优化:在后缀链接树中,\(endpos\) 集合是关于子树呈包含关系的,故考虑给所有位置分配 \(dfn\) 序,这样每个结点的 \(endpos\) 集合内的元素映射成 \(dfn\) 序后,是一段连续的区间,更新答案时可以直接区间赋值。
这里需要取第一次分配的值,而不是覆盖,所以需要将赋值操作倒序处理。
复杂度分析:
- 暴力跑 SAM 转移边(DAG)时,由于每个结点只会经过一次,故复杂度是 \(O(26n)\) 的;
- 采用的是区间赋值,单点查询的线段树,每次只需要在经过某个结点时进行一次区间赋值,故复杂度为 \(O(n\log n)\)
综上,总复杂度为 \(O(n\log n + 26n)\)。
牛客 SAM 专题
1
Q:给出一个只包含小写字母的字符串 \(s\)。有 \(q\) 次询问,每次给出一个区间 \([l,r]\),询问子串 \(s[l…r]\) 在 \(s\) 中的出现次数。
由于总字符数最多可达 \(O(nq)\),不能在建立 SAM 后直接暴力跑转移边以得到所有查询的答案。
由于查询对象一定是原串某个前缀的后缀,根据右端点 \(r\),就可以定位到 SAM 中代表前缀 \(s[1…r]\) 的结点 \(u\)。结合后缀链接树的性质,显然 \(s[l…r]\) 一定在根节点到 \(u\) 的某个结点内,且该路径上的每个结点代表子串集合的长度区间已知,因此可以树上倍增加速查找 \(s[l…r]\) 所在结点。













浙公网安备 33010602011771号