前言
之前对后缀自动机的理解太浅薄了,故打算重新写一篇。
后缀自动机是什么
后缀自动机是一个字符串的所有后缀建起来的自动机。它把所有子串(后缀的前缀)用 O ( n ) O(n) O(n) 的空间装了起来。后缀自动机的边会构成一个 D A G DAG DAG
后缀自动机的一些定义
结束位置 endpos
对于一个子串
t
t
t,它在原串
s
s
s 里面的结束位置集合记为
e
n
d
p
o
s
t
endpos_t
endpost
比如
s
=
a
b
c
b
c
s = abcbc
s=abcbc,
t
=
b
c
t = bc
t=bc,那么
e
n
d
p
o
s
t
=
{
2
,
4
}
endpos_t = \{2,4\}
endpost={2,4}(假设字符串从0开始编号)
等价类 u
所有 e n d p o s endpos endpos 相同子串是一个等价类,在后缀自动机上表现为一个点 u u u
后缀链接 link
其实就是我们常说的
f
a
i
l
fail
fail
假设当前点为
u
u
u,设
w
w
w 为
e
n
d
p
o
s
u
endpos_u
endposu 中最长的字符串,那么
l
i
n
k
u
link_u
linku 就会连向相对于
w
w
w 的最长后缀的另一个
e
n
d
p
o
s
endpos
endpos 上,一定会满足:
m
i
n
l
e
n
u
−
1
=
l
e
n
v
minlen_u-1=len_v
minlenu−1=lenv
转移 nxt
同 t r i e trie trie 的转移,只不过所有的转移会构成一个 D A G DAG DAG
后缀自动机的一些性质
性质1
字符串 s s s 的两个非空子串 u u u 和 w w w(假设 ∣ u ∣ ≤ ∣ w ∣ \left|u\right|\le \left|w\right| ∣u∣≤∣w∣)的 endpos \operatorname{endpos} endpos 相同,当且仅当字符串 u u u 在 s s s 中的每次出现,都是以 w w w 后缀的形式存在的。
性质2
考虑两个非空子串 u u u 和 w w w(假设 ∣ u ∣ ≤ ∣ w ∣ \left|u\right|\le \left|w\right| ∣u∣≤∣w∣)。那么要么 endpos ( u ) ∩ endpos ( w ) = ∅ \operatorname{endpos}(u)\cap \operatorname{endpos}(w)=\varnothing endpos(u)∩endpos(w)=∅,要么 endpos ( w ) ⊆ endpos ( u ) \operatorname{endpos}(w)\subseteq \operatorname{endpos}(u) endpos(w)⊆endpos(u),取决于 u u u 是否为 w w w 的一个后缀:
{ endpos ( w ) ⊆ endpos ( u ) if u is a suffix of w endpos ( w ) ∩ endpos ( u ) = ∅ otherwise \begin{cases} \operatorname{endpos}(w) \subseteq \operatorname{endpos}(u) & \text{if } u \text{ is a suffix of } w \\ \operatorname{endpos}(w) \cap \operatorname{endpos}(u) = \varnothing & \text{otherwise} \end{cases} {endpos(w)⊆endpos(u)endpos(w)∩endpos(u)=∅if u is a suffix of wotherwise
性质3
考虑一个 endpos \operatorname{endpos} endpos 等价类,将类中的所有子串按长度非递增的顺序排序。每个子串都不会比它前一个子串长,与此同时每个子串也是它前一个子串的后缀。换句话说,对于同一等价类的任一两子串,较短者为较长者的后缀,且该等价类中的子串长度恰好覆盖整个区间 [ m i n l e n , l e n ] [minlen,len] [minlen,len]。
性质4
所有后缀链接构成一棵根节点为 t 0 t_0 t0 的树(fail树)。
性质5
通过 endpos \operatorname{endpos} endpos 集合构造的树(每个子节点的 subset \textit{subset} subset 都包含在父节点的 subset \textit{subset} subset 中)与通过后缀链接 link \operatorname{link} link 构造的树相同。
性质6
对于
t
0
t_0
t0 以外的状态
v
v
v,可用后缀链接
link
(
v
)
\operatorname{link}(v)
link(v) 表达
minlen
(
v
)
\operatorname{minlen}(v)
minlen(v):
minlen
(
v
)
=
len
(
link
(
v
)
)
+
1.
\operatorname{minlen}(v)=\operatorname{len}(\operatorname{link}(v))+1.
minlen(v)=len(link(v))+1.
后缀自动机的构建
S
A
M
SAM
SAM 的构建是动态的,是在线的,每次插入一个字母。
这里先声明几个定义:
- c u r cur cur:当前节点
- l a s t last last:上一个字母代表的节点
- f a u fa_u fau:就是 l i n k u link_u linku
- n x t u [ c ] nxt_u[c] nxtu[c]:后缀自动机上的边,同 t r i e trie trie 树
- l e n u len_u lenu: e n d p o s u endpos_u endposu 中最长的字符串的长度。
下面是构建方法(假设当前字符为 c) :
- 首先我们初始化 l a s t = 1 last = 1 last=1,即 t 0 = 1 t_0=1 t0=1,代表初始节点(空字符串)
- 创建一个新状态 c u r cur cur,并令 l e n c u r = l e n l a s t + 1 len_{cur}=len_{last}+1 lencur=lenlast+1 ( l e n len len 的定义),此时 f a c u r fa_{cur} facur 无法确定,我们需要在后续的步骤确定它。
- 令 p = l a s t p = last p=last,如果 n x t p [ c ] nxt_p[c] nxtp[c] 还不存在,那么就令 n x t p [ c ] = c u r nxt_p[c] = cur nxtp[c]=cur,并让 p p p沿着 f a fa fa 往上跳,即 p = f a p p=fa_p p=fap,重复这个过程,直到 n x t p [ c ] nxt_p[c] nxtp[c] 存在或者发现这样的 p p p 不存在。
- 如果找不到合法的 p p p, p p p 就会跳到虚拟节点 0 0 0,我们令 f a c u r = 1 fa_{cur}=1 facur=1 并退出。
- 如果找到了合法的 p p p,我们记 q = n x t p [ c ] q = nxt_p[c] q=nxtp[c]
- 如果 l e n p + 1 = l e n q len_p+1=len_q lenp+1=lenq,那么可以直接令 f a c u r = q fa_{cur}=q facur=q ( l i n k link link的性质)
- 否则,就将当前状态 q q q 克隆一份,记为 r r r,并将 l e n r len_r lenr 赋值为 l e n p + 1 len_p+1 lenp+1。复制之后,我们令 f a c u r = r fa_{cur}=r facur=r。最后,从状态 p p p 开始沿着 f a fa fa 往上走,如果遇见 n x t p [ c ] = q nxt_p[c]=q nxtp[c]=q 就把它改为 r r r,否则就停下来,过程结束。
struct SAM
{
int fa,len;
vector<int> nxt;
SAM()
{
fa=0; len=0;
nxt.assign(27,0);
}
};
vector<SAM> sam;
vector<int> dp;//这里的dp求的是 endpos 集合的 size
int last;
void insert(char c)
{
int ch=c-'a';
sam.push_back(SAM());
int cur=sam.size()-1,p;
sam[cur].len=sam[last].len+1;
dp.push_back(1);
for(p=last; p&&sam[p].nxt[ch]==0; p=sam[p].fa)
{
sam[p].nxt[ch]=cur;
}
int q=sam[p].nxt[ch];
if(q==0)
{
sam[cur].fa=1;
}
else if(sam[q].len==sam[p].len+1)
{
sam[cur].fa=q;
}
else
{
sam.push_back(SAM());
int r=sam.size()-1;
dp.push_back(0);
sam[r]=sam[q];
sam[r].len=sam[p].len+1;
for(; p&&sam[p].nxt[ch]==q; p=sam[p].fa)
sam[p].nxt[ch]=r;
sam[cur].fa=sam[q].fa=r;
}
last=cur;
}
//sam.assign(2,SAM());
//dp.assign(2,0);
//last=1
SAM的应用
求每个点 endpos 集合的大小
根据性质2和性质5,在 f a i l fail fail 树上,儿子是父亲的子集。所以我们可以直接在 f a i l fail fail 树上DP。
#include<bits/stdc++.h>
#define ll long long
using namespace std;
const int N=1e6+7;
struct SAM
{
int fa,len;
vector<int> nxt;
SAM()
{
fa=0; len=0;
nxt.assign(27,0);
}
};
vector<SAM> sam;
vector<int> dp;
int last;
void insert(char c)
{
int ch=c-'a';
sam.push_back(SAM());
int cur=sam.size()-1,p;
sam[cur].len=sam[last].len+1;
dp.push_back(1);
for(p=last; p&&sam[p].nxt[ch]==0; p=sam[p].fa)
{
sam[p].nxt[ch]=cur;
}
int q=sam[p].nxt[ch];
if(q==0)
{
sam[cur].fa=1;
}
else if(sam[q].len==sam[p].len+1)
{
sam[cur].fa=q;
}
else
{
sam.push_back(SAM());
int r=sam.size()-1;
dp.push_back(0);
sam[r]=sam[q];
sam[r].len=sam[p].len+1;
for(; p&&sam[p].nxt[ch]==q; p=sam[p].fa)
sam[p].nxt[ch]=r;
sam[cur].fa=sam[q].fa=r;
}
last=cur;
}
ll ans;
vector<vector<int>> e;
void dfs(int u)
{
for(auto v:e[u])
{
dfs(v);
dp[u]+=dp[v];
}
if(dp[u]!=1)
ans=max(ans,1ll*dp[u]*sam[u].len);
}
void O_o()
{
last=1;
sam.assign(2,SAM());
dp.assign(2,0);
string s;
cin>>s;
for(auto c:s) insert(c);
int n=sam.size()-1;
e.assign(n+1,vector<int>());
for(int i=1; i<=n; i++)
{
if(sam[i].fa)
{
e[sam[i].fa].push_back(i);
}
}
ans=0;
dfs(1);
cout<<ans;
}
signed main()
{
ios::sync_with_stdio(false); cin.tie(0); cout.tie(0);
cout<<fixed<<setprecision(12);
int T=1;
// cin>>T;
while(T--)
{
O_o();
}
}
求不同的子串个数
一开始我们就说了个性质,你在SAM上乱走就可以走出一个子串。其实只要你走的路径不同,走出的子串一定不一样。问题就变成了从
t
0
t_0
t0 出发,总共有多少条路径。这是一个经典的 DAG 上 DP 问题。转移为
d
p
u
=
1
+
∑
d
p
v
dp_u=1+\sum dp_v
dpu=1+∑dpv
最后要去掉空字符串的答案,也就是
d
p
1
−
1
dp_1-1
dp1−1
如果是求子串个数,那就把
d
p
dp
dp 式子换成
d
p
u
=
s
i
z
u
+
∑
d
p
v
dp_u=siz_u+\sum dp_v
dpu=sizu+∑dpv
其中
s
i
z
u
=
∣
e
n
d
p
o
s
u
∣
siz_u=|endpos_u|
sizu=∣endposu∣ ,还是得减掉空字符串的方案。
void dfs2(int u)
{
if(dp[u]) return ;
for(int i=0; i<26; i++)
{
int v=sam[u].nxt[i];
if(!v) continue;
dfs2(v);
dp[u]+=dp[v];
}
dp[u]+=sum[u];
}
当然,求本质不同的字符串总数还有另一种方法:
根据性质3
a
n
s
=
∑
l
e
n
i
−
l
e
n
f
a
i
ans=\sum len_{i}-len_{fa_i}
ans=∑leni−lenfai
//求每次插入后有多少本质不同的字符串
void insert(int ch)
{
sam.push_back(SAM());
int cur=sam.size()-1,p;
sam[cur].len=sam[last].len+1;
dp.push_back(1);
for(p=last; p&&sam[p].nxt[ch]==0; p=sam[p].fa)
{
sam[p].nxt[ch]=cur;
}
int q=sam[p].nxt[ch];
if(q==0)
{
sam[cur].fa=1;
}
else if(sam[q].len==sam[p].len+1)
{
sam[cur].fa=q;
}
else
{
sam.push_back(SAM());
int r=sam.size()-1;
dp.push_back(0);
sam[r]=sam[q];
sam[r].len=sam[p].len+1;
for(; p&&sam[p].nxt[ch]==q; p=sam[p].fa)
sam[p].nxt[ch]=r;
sam[cur].fa=sam[q].fa=r;
}
last=cur;
ans+=sam[cur].len-sam[sam[cur].fa].len;
cout<<ans<<"\n";
}
求第 k 小的子串
上一个问题中我们求出了从某个节点开始走一共有多少条路径,也就是从
i
i
i 开始一共有多少子串。这个时候就可以利用起来了。
如果你是在
t
r
i
e
trie
trie 树上,怎么求排名第
k
k
k 的子串?——类似平衡树求第
k
k
k 小。
后缀自动机也一样,只不过每个点的
s
i
z
e
size
size 不再是
1
1
1 了,而是
e
n
d
p
o
s
endpos
endpos 集合大小。
只要把 trie 树上做的
−
1
-1
−1 改成
−
s
i
z
-siz
−siz 即可,注意空子串还是不能算,也就是
s
i
z
1
=
0
siz_1=0
siz1=0。
#include<bits/stdc++.h>
#define int long long
using namespace std;
const int N=1e6+7,inf=1e18;
struct SAM
{
int fa,len;
vector<int> nxt;
SAM()
{
fa=len=0;
nxt.assign(26,0);
}
};
int last=1;
vector<int> sum,dp;
vector<SAM> sam;
void insert(char c)
{
int ch=c-'a';
sam.push_back(SAM());
int cur=sam.size()-1,p;
sam[cur].len=sam[last].len+1;
sum.push_back(1);
for(p=last; p&&sam[p].nxt[ch]==0; p=sam[p].fa)
{
sam[p].nxt[ch]=cur;
}
int q=sam[p].nxt[ch];
if(!q)
{
sam[cur].fa=1;
}
else if(sam[q].len==sam[p].len+1)
{
sam[cur].fa=q;
}
else
{
sam.push_back(SAM());
int r=sam.size()-1;
sum.push_back(0);
sam[r]=sam[q];
sam[r].len=sam[p].len+1;
for(; p&&sam[p].nxt[ch]==q; p=sam[p].fa)
{
sam[p].nxt[ch]=r;
}
sam[q].fa=sam[cur].fa=r;
}
last=cur;
}
vector<vector<int>> e;
void dfs1(int u)
{
for(auto v:e[u])
{
dfs1(v);
sum[u]+=sum[v];
}
}
void dfs2(int u)
{
if(dp[u]) return ;
for(int i=0; i<26; i++)
{
int v=sam[u].nxt[i];
if(!v) continue;
dfs2(v);
dp[u]+=dp[v];
}
dp[u]+=sum[u];
}
bool solve(int u,int k)
{
k-=sum[u];
if(k<=0) return 1;
for(int i=0; i<26; i++)
{
int v=sam[u].nxt[i];
if(!v) continue;
if(dp[v]>=k)
{
cout<<char(i+'a');
solve(v,k);
return 1;
}
else k-=dp[v];
}
return 0;
}
void O_o()
{
last=1;
sam.assign(2,SAM());
sum.assign(2,0);
string s;
cin>>s;
for(auto c:s) insert(c);
int n=sam.size()-1;
dp.assign(n+1,0);
int op,k;
cin>>op>>k;
if(op)
{
e.assign(n+1,vector<int>());
for(int i=1; i<=n; i++) if(sam[i].fa)
{
e[sam[i].fa].push_back(i);
}
dfs1(1);
}
else
{
sum.assign(n+1,1);
}
dfs2(1);
sum[1]=0;
if(!solve(1,k))
{
cout<<"-1\n";
return;
}
}
signed main()
{
ios::sync_with_stdio(false); cin.tie(0); cout.tie(0);
cout<<fixed<<setprecision(12);
int T=1;
// cin>>T;
while(T--)
{
O_o();
}
}
最小循环位移
循环的问题一般就是把字符串复制一遍接在后面,这个也不例外。
令
s
′
=
s
+
s
s'=s+s
s′=s+s,建立SAM
每次贪心的走最小的字符,走到长度为
s
.
s
i
z
e
(
)
s.size()
s.size() 时就退出。
字符串出现次数
(kmp不香吗)
先预处理每个点
e
n
d
p
o
s
endpos
endpos 集合大小
然后直接拿模式串在SAM上匹配,匹配成功就是
s
i
z
siz
siz,不成功就是
0
0
0