前言:我觉得这个比LCT还难学。。。orz
声明:资料来源:
1. 毛子的blog
2. 冬令营clj讲稿(
3. 曲神的blog
4. 后缀自动机的简短指南
我们还是从
定义
开始
后缀自动机(或定向非循环字图)是一个强大的数据结构,可以解决许多字符串问题
例如,使用后缀自动机可以进行字符串匹配,或计算此字符串中的不同子串数,这两个问题都可以用线性时间解决
直观的,后缀机器可以理解为关于给定字符串的所有子字符串的压缩信息
令人震惊的是,后缀机器只需要 O(n) O ( n ) 内存。此外,它也可以及时在 O(n) O ( n ) 的时间内构建完毕在英文中,后缀机被称为“后缀自动机”,或是“有向非循环词图”(或简称为“DAWG”)
简单了看了一下资料
感觉后缀自动机和AC自动机很像啊
只不过AC自动机接受的是一组字符串,而后缀自动机接受的是一个字符串的所有后缀
我就想,那么后缀数组的构造是不是和AC自动机一样呢
然而这样做空间和时间都是
O(n2)
O
(
n
2
)
,比较浪费
实际上我们是可以证明后缀自动机是具有线性空间
我们先不考虑那些概念上的东西,先说下它是怎么存储信息而优化了这么大的空间的
和Trie树不一样,
SAM
S
A
M
(后缀自动机简写)是个DAG,
也就是从起始节点开始到某个点可以有很多条路径,所以一个节点可以表示很多个状态
假如直接dfs整个DAG,是可以找到所有子串的
相关概念
点
每个点代表了一个状态,这个状态的值就是从起始态到这个点的每一条路径所代表的子串
但是为什么这一个点可以代表很多个子串呢?
因为这些子串有一些共同的性质
这个性质就是它们的
Right
R
i
g
h
t
集相同
Right集
Right R i g h t 集:
对于 S S 的任何一个子串, Right(s) R i g h t ( s ) 为一个集合,该集合包含 s s 在里面所有出现区间的终点
比如子串 s s 在中出现了 k k 次,
有,
则 Right(s)=r1,r2,……,rk R i g h t ( s ) = r 1 , r 2 , … … , r k
也就是说 Right R i g h t 集是此状态代表的子串们在原串中的结束位置的并集
一个状态 Right R i g h t 集合的大小就是这个点所代表子串在原串中的出现次数
Parent指针
每个状态都会有个
Parent
P
a
r
e
n
t
指针
对于一个状态
i
i
,表示在包含
i
i
的集合的状态中,集合中元素个数最少的那个状态
trans(s,str)
表示当前状态是 s s ,在读入字符串之后,所到达到的状态
边
-
parent
p
a
r
e
n
t
边:一个状态指向ta的
Parents
P
a
r
e
n
t
s
(而由 parent p a r e n t 边构成的树可以称为 parent p a r e n t 树) - ′a′ ′ a ′ ~ ′z′ ′ z ′ (或某些字符)字符边:有一个状态,后面加上一个字符所指向的状态
注意:
1. 一个状态可以由多条字符边转移而来,因为它包含多个子串,但只能有一条 Parent P a r e n t 边转移来
2. 一个状态连出去的字符边不一定所有包含的子串后都能接这个字母,只是代表有某些包含的子串后能接这个字母
性质
Right R i g h t 集的性质
- 对于两个子串
a
a
和 ,设
a
a
的集合为
Right(a)
R
i
g
h
t
(
a
)
,
b
b
的集合为
Right(b)
R
i
g
h
t
(
b
)
,状态
ia
i
a
可以表示
a
a
,状态可以表示
b
b
。若是
Right(b)
R
i
g
h
t
(
b
)
的真子集,则
所以对于一个状态 s s 所表示的字符串,它们的右端点必定相同,而左端点必定是连续的一段。如下图:
- 如果一个状态的集越大,就说明符合的子串越多,那么限制肯定就越小,所以左端点距右端点的距离就越小
随着右端点的向右移动,符合一种条件的子串就越来越少,所以Right集就会变小(当然可能会分出两组不同的状态)
我们令一个状态 s s 所表示的区间是,显然的 Mins−1=MaxParents M i n s − 1 = M a x P a r e n t s ,对于一个状态,我们记 Lens L e n s 表示 Maxs M a x s
Parent P a r e n t 树的性质
- 怎么理解
Parent
P
a
r
e
n
t
树?
我们从叶子结点往根上走时,就是一些不相交的 Right R i g h t 集不断合并的过程 - 如果
trans(s1,c)=trans(s2,c)=trans(s3,c)....=t
t
r
a
n
s
(
s
1
,
c
)
=
t
r
a
n
s
(
s
2
,
c
)
=
t
r
a
n
s
(
s
3
,
c
)
.
.
.
.
=
t
,那么状态
s1,s2,s3...
s
1
,
s
2
,
s
3...
在
Parent
P
a
r
e
n
t
树上肯定是在一段连续的链上的
因为在这些子串的右端点加上一个字符 c c 后,它们的集又重新相等,证明它们本来就是包含且连续的关系 - Parent P a r e n t 边简单来说,即表示不断寻找后缀的过程
构建
说实话上面的一大堆不是特别懂
像我这种颜色系视觉动物
在用学术语言解释
SAM
S
A
M
的构建之前,我们先贴个图(简单地直观了解一下)
自己画的图,我们先解释一下这些颜色的意义:
柠檬绿的结点就是起始点
淡紫色的结点是接受状态(代表一个后缀)
浅蓝色的结点是非接受状态(代表中间状态)
主要概念就大概这些了,考虑下如何建立一个 SAM S A M :
- 先考虑在 O(n2) O ( n 2 ) 地建立后缀树时,在得到关于一个字符串 S S 的后缀树后,可以极快地构建关于的后缀树(只需要把每个原结束结点引出 x x 边指向的结点定义为新的结束结点即可)
- 而是一棵压缩成 DAG D A G 的后缀树。不妨假设它也有这个性质。
- 我们来考虑如何进行这个“修改结束节点”的操作。
- 对于可以表示整个串 S S 的节点,它在 Parent P a r e n t 树上的祖先必然都可以表示 S S 的后缀,因此要对这条链上的节点进行修改
- 在发现某个祖先节点已经有了这个儿子时,并不能直接像在Trie树上一样直接对这个节点进行操作,因为这是 DAG D A G ,这个儿子可能是公用的,如果直接修改的话,可能会影响其他节点的信息
- 此时,可以判断一下若进行修改是否会有信息丢失,若有则新建一个儿子节点,来单独记录这个结束信息,并且因为不能影响它后续的访问,将原有儿子节点的转移信息全部复制到新建的节点中。若有信息丢失,则应为 max m a x 集合变小(详见clj的ppt),因此在判断时只需要对比当前冲突节点及其儿子的 max m a x 值。
- 同样的, Parent P a r e n t 树上的父子关系也需要进行更新。
- 关于
Right
R
i
g
h
t
集合大小的计算,在赋初值的时候只需要给新增节点部分的赋值为
1
1
,集合大小完全可以从儿子到父亲加上去。
只有最后加入的点会赋值为1的原因,是只有那里会有新出现的一个 Right R i g h t 集合的元素。而这个值并不存在于当前节点子树中的任一节点的 Right R i g h t 集合中。
文字难以理解的话,我们一步一步拆开来看:
举个例子:
aabbabd
a
a
b
b
a
b
d
,以此构造后缀自动机
有几点要记得:
1. 由一个接受态沿
Parent
P
a
r
e
n
t
往前走所到的状态也是接受态
2. 一个结点及其父辈的代表的串有相同的后缀
1) 一开始是无尽的虚空:
此时后缀就是一个空串
2) 现在构建 ′a′ ′ a ′ 的自动机,就是下面这样
现在后缀变成了这样:
3) 然后以上为基础构建 ′aa′ ′ a a ′ 的自动机
现在想一下,由
S
S
或者说号节点可以接受的后缀为空串和
′a′
′
a
′
这两个,那么现在要将
′aa′
′
a
a
′
和
′a′
′
a
′
这两个后缀更新到后缀自动机中,那么1号节点的后缀
′a′
′
a
′
就要加入一个字符
′a′
′
a
′
,而空串也要加入字符
′a′
′
a
′
也就是所有之前的后缀都要在后面加入一个字符
′a′
′
a
′
但是由于1号结点之前所代表的后缀
′a′
′
a
′
和1的
Parent
P
a
r
e
n
t
所代表的后缀(空串)+
′a′
′
a
′
代表的一样,所以,无需更新1及之前的可接受态
自动机就变成了这样:
4) 更新自动机变成 ′aab′ ′ a a b ′ 自动机
同上,所有接受态都要调整——在后面加上
′b′
′
b
′
字符:
这时,由于
1,2
1
,
2
节点无法代表三个后缀的任意一个,所以除空串的所有后缀都由
3
3
代替
这时号节点和
0
0
号节点为接受态.
自动机成了这样:
具体过程是这样的:
:新建节点3
S2
S
2
:找到最后一个后缀也就是最后一个接受态是节点2
S3
S
3
:2号节点直接连向3,表示插入后缀
′aab′
′
a
a
b
′
S4
S
4
:向上找2的
Parent
P
a
r
e
n
t
,1号节点,向3连边,表示插入后缀
′ab′
′
a
b
′
S5
S
5
:找到
S
S
,连边,表示插入后缀.
S6
S
6
:没有其他接受态了,那么3的上一个接受态为
S
S
,
5) 更新成 ′aabb′ ′ a a b b ′ 的自动机
同理,在所有接受态后加上字符
′b′
′
b
′
不过由于接受态
0(S)
0
(
S
)
的转移
′b′
′
b
′
已经存在,那么,由于不能破坏原来的中间态的转移,只能新建一个结点,来代替接受态
0(S)
0
(
S
)
的转移节点
自动机变成了这样:
找到
0(S)
0
(
S
)
时,发现转移
′b′
′
b
′
已经有节点占了,所以新建节点5,将3号所有信息
Copy
C
o
p
y
(包括
Parent
P
a
r
e
n
t
),然后更新
len
l
e
n
值,就是
node[5]−>len=node[5]−>parent−>len+1
n
o
d
e
[
5
]
−
>
l
e
n
=
n
o
d
e
[
5
]
−
>
p
a
r
e
n
t
−
>
l
e
n
+
1
,所以5号节点可以代表后缀空串(0号代表的串)+字符
"b"
"
b
"
=后缀
"b"
"
b
"
,节点3成了中间态,所以将节点为原接受态的节点指向3的转移改为指向5,
这时,我们发现指向3的原接受态节点一定是当前节点
0(S)
0
(
S
)
及当前未访问的原接受态节点,所以可以直接沿着
Parent
P
a
r
e
n
t
往上更新
然后节点5的 Parent P a r e n t 及祖先加入了现在的接受态
再次重申一点:
一个节点及其父辈的代表的串有相同的后缀,且代表串长度递减,由于5号节点是接受态,所以他的父辈也是接受态,
同时反过来也一样,与任意接受态拥有相同后缀的长度小于当前节点的未访问节点一定是当前节点的父辈,如与5号节点有相同后缀的长度小于5号节点的未访问的节点一定是5号的父辈,一定可以作为接受态
因此为了维护这个性质,我们应该将3号节点的父亲重定义为5
到这里基本上应该明白了
就将剩下的构造过程放出来:
LET US SEE THE CODE ↓
#include<cstdio>
#include<cstring>
using namespace std;
const int N=100010;
int dis[N<<1],fa[N<<1],ch[N<<1][26],sz=1,root=1,last=root;
char s[N];
void insert(int x)
{
int pre=last,now=++sz; //找到代表全串的结点,在后面建一个新结点
last=now;
dis[now]=dis[pre]+1; //更新一下max
for (;pre&&!ch[pre][x];pre=fa[pre]) ch[pre][x]=now; //找Parent树上有没有这个结点
if (!pre) fa[now]=root; //没有的话Parent直接定成起始结点
else
{
int q=ch[pre][x]; //找了一个祖先
if (dis[q]==dis[pre]+1)
fa[now]=q; //max没有变小
else
{
int nows=++sz;
dis[nows]=dis[pre]+1; //新建一个结点,更新max
memcpy(ch[nows],ch[q],sizeof(ch[q])); //先把所有q的信息都过继到新结点上
fa[nows]=fa[q]; fa[now]=fa[q]=nows; //更新父子关系
for (;pre&&ch[pre][x]==q;pre=fa[pre]) ch[pre][x]=nows;
//更新与冲突结点有关的转移
}
}
}
int main()
{
scanf("%s",s+1);
int len=strlen(s+1);
for (int i=1;i<=len;i++) insert(s[i]-'a');
return 0;
}