SA&SAM 小记
0. Front
纯笔记,不含教学内容,部分有拓展,部分太简单所以以”显然“带过了,总结了部分 oi-wiki 的内容。
字符串为 \(S\),长度为 \(n\),且应有 \(|\Sigma|\le n\)。
通常来说,大写字母表示为字符串,小写字母表示为字符。
后缀的编号为 \(i\),表示是以 \(i\) 为起点的后缀。
1. 后缀数组(Suffix Array,SA)
定义
落脚到两个数组,\(sa\) 和 \(rank\)。
\(sa[i]\) 表示将所有后缀排序后第 \(i\) 小的后缀的编号,称为后缀数组。
\(rank[i]\) 表示后缀 \(i\) 的排名,是重要的辅助数组,称为排名数组。
显然,\(sa[rank[i]]=rank[sa[i]]=i\)。
如何求后缀数组?
A. 暴力
暴力比较并排序。
时间复杂度 \(O(n^2\log_2{n})\)。
B. 字符串哈希
在比较上对 A. 进行优化,时间复杂度 \(O(n\log_2^2{n})\)。
C. 倍增
定义 \(sa_p[i]\) 表示所有长度为 \(p\) 的子串中排名为 \(i\) 的子串的起点,\(rank_p\) 同理。
会发现在不足的地方可以补上一个无穷小的字符,同时如果 \(p\ge n\) 那么存在 \(sa_p[i],i>n\) 的情况,考虑到我们补上了无穷小的字符,所以应该会被放在第一的排名,最终算 \(rank\) 的真实值时直接减去多余部分就可以了。实际上实现起来可以直接不考虑这个,具体可以参照代码实现。
具体的实现比较简单便不再展开。大概如下:
\(p\) 依次等于 \(2^0,2^1,\cdots,2^{\lceil \log_2{n}\rceil}\),对于每次计算 \(sa_p\) 时候的直接排序考虑大小关系等价于二元组 \((rank_{\frac{p}{2}}[i],rank_{\frac{p}{2}}[i+\frac{p}{2}])\) 依次作为第一二关键字的大小关系。
时间复杂度 \(O(n\log_2^2{n})\)。
D. 倍增 + 基数排序
对于 C. 中的倍增每层的排序,因为是双关键字排序且值域为 \(O(n)\),所以进行基数排序,时间复杂度为每层 \(O(n)\),总时间复杂度为 \(O(n\log_2{n})\)。
Ex. 常数优化
考虑基数排序中的第二关键字实际上只需要改变第一关键字的遍历顺序就可以一并求得。
时间会快上一些。
Code
#include <bits/stdc++.h>
using namespace std;
// [io definition] start
int write_sta[65], write_top;
inline void write (int x){
if (x < 0)
putchar ('-'), x = - x;
write_top = 0;
do
write_sta[write_top ++] = x % 10, x /= 10;
while (x);
while (write_top)
putchar (write_sta[-- write_top] + 48);
}
// [io definition] end
const int LN = 62;
int n;
char s[1000005];
int get_id (char a){
if (a <= '9')
return a - '0' + 1;
if (a <= 'Z')
return a - 'A' + 11;
if (a <= 'z')
return a - 'a' + 37;
return 0;
}
int ton[1000005], sa[1000005], rk[1000005], pos[1000005];
void get_sa (){
for (int i = 1;i <= LN;++ i)
ton[i] = 0;
for (int i = 1;i <= n;++ i)
++ ton[rk[i] = get_id (s[i])];
for (int i = 2;i <= LN;++ i)
ton[i] += ton[i - 1];
for (int i = n;i >= 1;-- i)
sa[ton[rk[i]] --] = i;
for (int j = 1, tot;j <= n;j <<= 1){
tot = 0;
for (int i = n - j + 1;i <= n;++ i)
pos[++ tot] = i;
for (int i = 1;i <= n;++ i)
if (sa[i] > j)
pos[++ tot] = sa[i] - j;
for (int i = 1;i <= n;++ i)
ton[i] = 0;
for (int i = 1;i <= n;++ i)
++ ton[rk[i]];
for (int i = 2;i <= n;++ i)
ton[i] += ton[i - 1];
for (int i = n;i >= 1;-- i)
sa[ton[rk[pos[i]]] --] = pos[i];
for (int i = 1;i <= n;++ i)
pos[i] = rk[i];
rk[sa[1]] = tot = 1;
for (int i = 2;i <= n;++ i)
rk[sa[i]] = (pos[sa[i]] == pos[sa[i - 1]] && pos[sa[i] + j] == pos[sa[i - 1] + j]) ? tot : ++ tot;
}
}
signed main (){
scanf ("%s", s + 1);
n = strlen (s + 1);
get_sa ();
for (int i = 1;i <= n;++ i)
write (sa[i]), putchar (' ');
puts ("");
return 0;
}
实际上跑起来很快。
\(\alpha.\) SA-IS 算法
\(\beta.\) DC3 算法
不再展开。
应用
最小循环移动位置
Code
#include <bits/stdc++.h>
using namespace std;
int n, top;
char s[200005], t[200005];
int ton[200005], sa[200005], rk[200005], pos[200005];
void get_sa (){
for (int i = 1;i <= max (n, 256);++ i)
ton[i] = 0;
for (int i = 1;i <= n;++ i)
++ ton[rk[i] = s[i]];
for (int i = 2;i <= max (n, 256);++ i)
ton[i] += ton[i - 1];
for (int i = n;i >= 1;-- i)
sa[ton[rk[i]] --] = i;
for (int j = 1, tot;j <= n;j <<= 1){
tot = 0;
for (int i = n - j + 1;i <= n;++ i)
pos[++ tot] = i;
for (int i = 1;i <= n;++ i)
if (sa[i] > j)
pos[++ tot] = sa[i] - j;
for (int i = 1;i <= max (n, 256);++ i)
ton[i] = 0;
for (int i = 1;i <= n;++ i)
++ ton[rk[i]];
for (int i = 2;i <= max (n, 256);++ i)
ton[i] += ton[i - 1];
for (int i = n;i >= 1;-- i)
sa[ton[rk[pos[i]]] --] = pos[i];
for (int i = 1;i <= n;++ i)
pos[i] = rk[i];
rk[sa[1]] = tot = 1;
for (int i = 2;i <= n;++ i)
rk[sa[i]] = (pos[sa[i]] == pos[sa[i - 1]] && pos[sa[i] + j] == pos[sa[i - 1] + j]) ? tot : ++ tot;
}
}
signed main (){
scanf ("%s", s + 1);
n = strlen (s + 1);
for (int i = 1;i <= n;++ i)
s[n + i] = s[i];
n <<= 1;
get_sa ();
for (int i = 1;i <= n;++ i)
if (sa[i] <= (n >> 1))
t[++ top] = s[sa[i] + (n >> 1) - 1];
printf ("%s\n", t + 1);
return 0;
}
部分子串问题
在主串 \(T\) 中寻找模式串 \(S\),要求在线。
可以在 \(sa\) 中二分第一个大于等于 \(S\) 的后缀,这样就可以判断其是否存在 \(S\)。然后在 \(sa\) 中二分第一个在前 \(|S|\) 位上已经大于 \(S\) 的位置,就可以求得其出现次数,因为后缀中前缀为 \(S\) 的后缀在 \(sa\) 中一定连续出现。
部分比较问题
Code
#include <bits/stdc++.h>
using namespace std;
const int SL = 256;
int n;
char s[1000005];
int ton[1000005], pos[1000005], sa[1000005], rk[1000005];
void get_sa (){
for (int i = 1;i <= SL;++ i)
ton[i] = 0;
for (int i = 1;i <= n;++ i)
++ ton[rk[i] = s[i]];
for (int i = 2;i <= SL;++ i)
ton[i] += ton[i - 1];
for (int i = n;i >= 1;-- i)
sa[ton[rk[i]] --] = i;
for (int j = 1, tot;j <= n;j <<= 1){
tot = 0;
for (int i = n - j + 1;i <= n;++ i)
pos[++ tot] = i;
for (int i = 1;i <= n;++ i)
if (sa[i] > j)
pos[++ tot] = sa[i] - j;
for (int i = 1;i <= max (SL, n);++ i)
ton[i] = 0;
for (int i = 1;i <= n;++ i)
++ ton[rk[i]];
for (int i = 2;i <= max (SL, n);++ i)
ton[i] += ton[i - 1];
for (int i = n;i >= 1;-- i)
sa[ton[rk[pos[i]]] --] = pos[i];
for (int i = 1;i <= n;++ i)
pos[i] = rk[i];
rk[sa[1]] = tot = 1;
for (int i = 2;i <= n;++ i)
rk[sa[i]] = (pos[sa[i]] == pos[sa[i - 1]] && pos[sa[i] + j] == pos[sa[i - 1] + j]) ? tot : ++ tot;
}
}
int top;
char ans[500005];
signed main (){
scanf ("%d", &n);
for (int i = 1;i <= n;++ i)
scanf ("\n%c", &s[i]);
s[n + 1] = 1;
for (int i = n + 2;i <= (n << 1) + 1;++ i)
s[i] = s[((n + 1) << 1) - i];
n = (n << 1) + 1;
get_sa ();
int l = 1, r = n >> 1;
while (l < r){
if (rk[l] < rk[n - r + 1]){
ans[++ top] = s[l];
++ l;
} else {
ans[++ top] = s[r];
-- r;
}
}
ans[++ top] = s[l];
for (int i = 1;i <= top;++ i){
putchar (ans[i]);
if (i % 80 == 0)
putchar ('\n');
}
return 0;
}
\(\mathrm{lcp}\) 以及 \(height\) 数组
定义 \(\mathrm{lcp} (A,B)\) 表示 \(A,B\) 最长公共前缀的长度。
特别地,对于 \(\mathrm{lcp}(i,j)\) 表示为后缀 \(i\) 与后缀 \(j\) 的最长公共前缀的长度。
定义 \(height[i]\) 表示 \(\mathrm{lcp}(sa[i-1],sa[i])\)。默认 \(height[1]=0\)
如何求 \(height\) 数组?
引理 1.1 \(height[rk[i]]\ge height[rk[i-1]]-1\)。
证明
考虑 \(height[rk[i-1]]>1\) 时的情况,否则显然成立。
这时有 \(\mathrm{lcp}(sa[rk[i-1]],sa[rk[i-1]-1])=height[rk[i-1]]>1\)。
于是有 \(\mathrm{lcp}(i-1,sa[rk[i-1]-1])=height[rk[i-1]]\)。
设后缀 \(i-1\) 为 \(aAB\),则后缀 \(i\) 为 \(AB\)。
设后缀 \(sa[rk[i-1]-1]\) 为 \(aAD\),则后缀 \(sa[rk[i-1]-1]+1\) 为 \(AD\)。
不妨令 \(B[0] \ne D[0]\) 且 \(height[rk[i-1]] = |A|+1\)。
那么实际上应有 \(\mathrm{lcp}(i,sa[rk[i-1]-1]+1)=|A|=height[rk[i-1]]\)。
会发现在 \(sa\) 中因为已经存在两个前缀为 \(A\) 后缀,所以这两个后缀在 \(sa\) 中设 \(sa[rk[i-1]-1]+1\) 在 \(p\) ,\(i\) 在 \(q\),那么一定有 \(\min\{height[p+1],\cdots,height[q]\}\ge \mathrm{lcp(sa[p],sa[q])}=height[rk[i-1]]-1\)。因为字典序中夹在 \(AD\) 和 \(AB\) 中的字符串一定都是以 \(A\) 开头的。
上式得证。\(\Box\)
所以存在 \(O(n)\) 求出 \(height[]\) 的做法。
for (int i = 1, k = 0;i <= n;++ i){
if (k)
-- k;
while (s[i + k] == s[sa[rk[i] - 1] + k])
++ k;
height[rk[i]] = k;
}
长得跟暴力一样。
应用
两子串的公共前缀
引理 1.2 \(\mathrm{lcp}(sa[i],sa[j])=\min\{height[i+1,\cdots,j]\}\)
证明 实际上在引理 1.1 的证明当中已经提到了 \(\min\{height[p+1],\cdots,height[q]\}\ge \mathrm{lcp(sa[p],sa[q])}\),那么还会发现一定是存在一个位置的 \(height[i]=\mathrm{lcp}(sa[p],sa[q]),i\in [p+1,q]\) 。不存在直接相等的情况,所以后缀 \(sa[p]\) 和 后缀 \(sa[q]\) 的第 \(\mathrm{lcp}(sa[p],sa[q])+1\) 个位置上的字符是不相等的,所以说在 \(sa[p+1,\cdots,q]\) 的这段后缀上这个位置一定发生了变化,发生变化时的 \(height[i]=\mathrm{lcp}(sa[p],sa[q])\)。\(\Box\)
所以实际上就是个 RMQ 问题。
不同子串的数目
引理 1.3 不同子串数目应该等于 \(\frac{n(n+1)}{2}-\sum_{i=1}^{n}height[i]\)。
证明
考虑可以枚举每个后缀,减去当前后缀中与以前重复的前缀。
如果按照 \(sa\) 的顺序遍历的话,实际上 \(sa[i]\) 往前重复的前缀个数就是 \(height[i]\),因为后缀 \(sa[i-1]\) 是前 \(i-1\) 个前缀中与 \(sa[i]\) 的 \(\mathrm{lcp}\) 最大的串(由字典序的性质显然)。
所以实际上不同子串数目应该等于上式。\(\Box\)
比较字符串中两个子串的大小关系
较为简单,不再赘述。
出现至少 \(k\) 次的子串的最大长度
在 \(height[]\) 上使用单调队列扫描即可。
找到最长的子串使其至少不重叠地出现过两次
二分长度,在 \(height[]\) 中找到连续大于等于这个长度的段,分别做 RMQ 找到最大和最小的后缀起点即可。
连续重复串
穷举重复的串长度为 \(k\),是生成连续重复串的串,当且仅当 \(\mathrm{lcp}(1,k+1)=n-k \land k \mid n\)。
SP. [NOI2016] 优秀的拆分 相当有趣的应用。
另一些结合数据结构的应用
2. 后缀自动机(Suffix Automaton,SAM)
相关概念
结束位置 \(\textrm{endpos}\)
考虑 \(S\) 的一个非空子串 \(T\),\(\mathrm{endpos}(T)\) 表示 \(T\) 在 \(S\) 中所有出现位置的右端点。
比如 \(S=\textrm{ababa},T=ab\),则 \(\mathrm{endpos}=\{2,4\}\)。
SAM 中一个状态可能对应多个 \(\mathrm{endpos}\) 相同的子串,于是我们把这些子串称为一个等价类。 \(S\) 的所有非空子串可以被划分为若干等价类
引理 2.1 字符串 \(S\) 有两个非空子串 \(u\) 和 \(w\),不妨设 \(|u| \le |w|\)。\(u\) 和 \(w\) 的 \(\mathrm{endpos}\) 相等,当且仅当 \(u\) 每次出现时都是以 \(w\) 的后缀的形式。
证明 显然。\(\Box\)
引理 2.2 两个非空子串 \(u\) 和 \(w\),\(|u|\le|w|\)。要么 \(\mathrm{endpos}(u)\cap \mathrm{endpos}(w)=\empty\),要么 \(\mathrm{endpos}(w)\sube\mathrm{endpos}(u)\),后者成立当且仅当 \(u\) 是 \(w\) 的后缀。
证明 如果 \(\mathrm{endpos}(u)\cap\mathrm{endpos}(w) \ne \empty\),出现 \(w\) 的子串至少出现了一次 \(u\),说明 \(u\) 是 \(w\) 的后缀。如果 \(u\) 是 \(w\) 的后缀,那么出现 \(w\) 时一定出现了 \(u\)。\(\Box\)
引理 2.3 一个等价类中的所有子串,较短的子串一定是较长的子串的后缀,长度相等的子串一定唯一。若等价类长度的值域是一段区间 \([x,y]\),则区间内每个整数都能取到。
证明 前半段显然。对于后半段考虑到一个终点向前选,一定有 \(l>1,\mathrm{endpos}(S[l-1,r])\sube\mathrm{endpos}(S[l,r])\),显然(或根据引理 2.1 和引理 2.2 可以推得)。\(\Box\)
引理 2.4 \(\mathrm{endpos}\) 等价类的个数为 \(O(n)\)。
证明 考虑从 \(t_0\) 开始划分大小为 \(O(n)\) 的不交集合到子树,类推之后最大的情况只有 \(2n-1\) 个。\(\Box\)
后缀链接 \(\mathrm{link}\) 以及 \(\textrm{parent tree}\)
对于 SAM 中一个不为 \(t_0\) 的状态 \(v\)。它唯一对应一个 \(\mathrm{endpos}\) 等价类。我们考虑这个等价类中最长的子串 \(w\),其他子串一定都是 \(w\) 的后缀。
我们将 \(v\) 的后缀链接 \(\mathrm{link}(v)\) 连接到 \(w\) 最长的不在这个 \(\mathrm{endpos}\) 等价类中的后缀的 \(\mathrm{endpos}\) 等价类构成的状态 \(t\) 上。
而初始状态 \(t_0\) 的 \(\mathrm{endpos}\) 我们规定为严格包含所有位置的 \(\mathrm{endpos}\),其单独为一个等价类,可以看作是空串的等价类。
引理 2.5 后缀链接构成了一颗根为 \(t_0\) 的树。
证明 由定义显然。\(\Box\)
引理 2.6 通过 \(\mathrm{endpos}\) 构造的树和通过 \(\mathrm{link}\) 构造的树结构一致。通过 \(\mathrm{endpos}\) 构造意为父节点一定是最小的 \(\mathrm{endpos}\) 对这个状态具有真包含关系的状态。
证明 由 \(\mathrm{endpos}\) 构造树的定义,显然。 \(\Box\)
我们将通过 \(\mathrm{link}\) 构建的树称为 \(\textrm{parent tree}\)。
算法流程
对于如何实现以及实现的正确性,不再赘述,网上的相关优质资源不少。
提供一份代码:
namespace SAM {
const int N = 2e6 + 5;
struct state {
int len, link;
map < char , int > nex;
char end_tag;
} st[N << 1];
int sz, las;
void Init (){
st[0].len = 0;
st[0].link = - 1;
sz = 1;
las = 0;
}
void extend (char c){
int cur;
st[cur = sz ++].len = st[las].len + 1;
int p = las;
while (p != - 1 && st[p].nex[c] == 0){
st[p].nex[c] = cur;
p = st[p].link;
}
if (p != - 1){
int q = st[p].nex[c];
if (st[q].len == st[p].len + 1)
st[cur].link = q;
else {
int clone = sz ++;
st[clone].len = st[p].len + 1;
st[clone].link = st[q].link;
st[clone].nex = st[q].nex;
while (p != - 1 && st[p].nex[c] == q){
st[p].nex[c] = clone;
p = st[p].link;
}
st[q].link = st[cur].link = clone;
}
}
las = cur;
st[cur].end_tag = 1;
}
}
时空复杂度分析
声明
\(\mathrm{longest}(v)\) 表示状态 \(v\) 的所有子串中最长的那个。
\(\mathrm{shortest}(v)\) 表示状态 \(v\) 的所有子串中最短的那个。
\(\mathrm{len}(v)=|\mathrm{longest(v)}|,\mathrm{minlen}(v)=|\mathrm{shortest}(v)|\)。
\(\delta(p,c)\) 表示状态 \(p\) 输入字符 \(c\) 后的转移状态。
状态数上界
由引理 2.3,引理 2.6 可得,上界为 \(O(n)\),而且实际上为 \(2n-1\)。
转移数上界
转移中 \(\mathrm{len}(\delta(v,c))=\mathrm{len}(v)+1\) 的,我们称之为连续转移。
我们考虑连续转移的数量。
考虑到存在一颗生成树一定可以将连续的边全部包括,所以去掉这 \(2n-2\) 条边。
接着估计不连续转移的数量。考虑一个不连续转移 \(p\to q\) ,找到从 \(t_0\) 到 \(p\) 的一条连续转移路径,设表示字符串为 \(u\);找到从 \(q\) 到任意终止位置(一定是整个串的后缀)的连续路径,设表示字符串为 \(v\),考虑 \(s_{p,q}=u+c_{p,q}+v\) 对于 \(p,q\) 互不相同,否则与连续转移相悖。所以 \(s_{p,q}\) 是 \(s\) 的真后缀,而 \(s\) 对应路径连续,因此不连续转移数量不超过 \(n-1\),于是我们得到上界 \(3n-3\)。而实际上真正的上界是 \(3n-4\),达到这个上界的字符串应形如 \(\textrm{abbb...bb}\)。
操作次数上界
思路来自 常见字符串算法 II:自动机相关——Alex Wei ,补足了一些细节问题。
考虑到因为 SAM 的状态数和转移数都是 \(O(n)\) 级别的,所以所有的新建转移和新建点的操作以及为了这些操作所进行的所有遍历时间复杂度均为 \(O(n)\)。
那么考虑不新建转移边的情况,也就是将 \(p\to q\) 的转移修改为 \(p \to clone\) 的转移,我们考虑证明这个的时间复杂度为 \(O(n)\)。
为此引入定义 \(\mathrm{depth}(p)\) 表示 \(p\) 在 \(\textrm{parent tree}\) 上的深度。
引理 2.7 对于 SAM 中任意一条转移 \(p\to q\),有 \(\mathrm{depth}(p)+1\ge\mathrm{depth}(q)\)。
证明 考虑后缀链接路径 \(q\to t_0\) 上的两个不同状态 \(q_1,q_2,q_1\ne q_2\)。设 \(p_1\) 为任意可转移到 \(q_1\) 的状态,\(p_2\) 为任意可转移到 \(q_2\) 的状态。考虑到 \(p\to q\) 的转移,所以 \(\mathrm{longest}(p)\) 是 \(\mathrm{longest}(q)\) 去掉最后一个字符后的串后缀。 \(p_1,p_2\) 于 \(q_1,q_2\) 同理,因此 \(p_1,p_2\) 均在后缀链接路径 \(p\to t_0\) 上。而且因为 \(p_1\to q_1,p_2\to q_2\) 的转移都是通过同一个字符(也就是 \(\mathrm{longest}(q)\) 的最后一个字符)进行的,因为在某个状态上输入字符进行转移的下一个状态时唯一的,因此反推出 \(p_1\ne p_2\)。
发现 \(q\to t_0\) 的后缀链接路径上在除了 \(t_0\) 不存在可转移到这个状态的状态外,其他状态均存在可转移到这个状态的其他状态 。由上文可知,\(q\to t_0\) 路径上的所有状态 \(q'\)(除了 \(t_0\))各自的 \(P(q')=\{可转移到这个状态的状态\}\) 一定非空,集合中的所有状态都在 \(p\to t_0\) 的路径上。不同 \(q'\) 的这个集合一定不交。因此可以得到一个关系,就是:
式子中 \(-1\) 的原因是 \(t_0\) 不考虑在 \(q'\) 中,但 \(p\to t_0\) 中要考虑 \(t_0\),因为 \(t_0\) 到某个单字符构成的子串显然也是转移。
因为 \(|q\to t_0|=\mathrm{depth}(q),|p\to t_0|=\mathrm{p}\),因此可以得出 \(\mathrm{depth}(p)+1\ge \mathrm{depth}(q)\)。\(\Box\)
我们尝试使用这个引理来估算时间复杂度。
现在考虑从 \(p\) 一直跳到了 \(p'\),并将中途所有状态原本的 \(\to q\) 转移变为了 \(\to clone\) 转移。设 \(q'=\delta(\mathrm{link}(p'),c)\),其中 \(c\) 是新加入的字符。容易证明原 \(\mathrm{link}(q)\) 即现 \(\mathrm{link}(clone)=q'\)。
设 \(d=\mathrm{depth}(p)-\mathrm{depth}(p')\),即从 \(p\) 开始跳 \(\mathrm{link}\) 的次数。根据这个引理,有 \(\mathrm{depth}(q')\le\mathrm{depth}(\mathrm{link}(p'))+1=\mathrm{depth}(p')=\mathrm{depth}(p)-d\le\mathrm{depth}(las)-1-d\)。
同时可知 \(\mathrm{link}(cur)=clone,\mathrm{link}(clone)=q'\),因此 \(\mathrm{depth}(cur)-2=\mathrm{depth}(q')\le\mathrm{depth}(las)-1-d\),即 \(d\le\mathrm{depth}(las)-\mathrm{depth}(cur)+1\)。这个 \(d\) 可以由 \(las\) 到 \(cur\) 的深度减小量来估计。
如果存在 \(las\to cur\) 的转移,因此根据引理有 \(\mathrm{depth}(las)+1\ge\mathrm{depth}(cur)\)。其他情况如上述。因此势能分析得到 \(O(\sum d)=O(n)\)。
时空复杂度
由上文可知,SAM 的时间复杂度和空间复杂度均为 \(O(n)\)。
应用
出现次数
考虑记录终止位置,即考虑在原串各个前缀对应的状态上打终止标记,在 \(\textrm{parent tree}\) 对子树标记数进行统计,这个数目在状态上的定义就是这个状态中的所有子串出现次数,也就是 \(|\mathrm{endpos}|\)。
当然,可以直接在自动机上 DP。
【模板】后缀自动机(SAM) 以及超多重题。
本质不同子串个数
就是 \(\sum_{i\in \mathrm{SAM},i\ne t_0}{\mathrm{len}(i)-\mathrm{len}(\mathrm{link}(i))}\)。显然,这个在线。
当然,可以直接在自动机上 DP。
[SDOI2016] 生成魔咒 以及超多重题。
最长公共子串
考虑实际上可以记录每个状态往前最多能匹配多少个。对于每个串扫进来之后进行匹配,如果成功就往下走,否则跳 \(\mathrm{link}\) 直到无法再跳或存在往下走的转移。然后在 \(\textrm{parent tree}\) 上从下往上更新。
记得更新长度。
LCS2 - Longest Common Substring II 以及超多重题。
值得一提的是,在限定区间内同样可以进行这样的匹配,但需要加限定条件,见 [NOI2018] 你的名字。
出现位置
考虑在出现次数的做法下增加一个线段树合并,改成可持久化(?),大概这样。
inline int Merge (int p, int q, int l = 1, int r = n){
if (! p || ! q)
return p | q;
int w = ++ cnt, mid = (l + r) >> 1;
ls[w] = Merge (ls[p], ls[q], l, mid);
rs[w] = Merge (rs[p], rs[q], mid + 1, r);
return w;
}
本文作者:saubguiu
本文链接:https://www.cnblogs.com/imcaigou/p/17969001
版权声明:本作品采用知识共享署名-非商业性使用-禁止演绎 2.5 中国大陆许可协议进行许可。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步