思路来源
史上最通俗的后缀自动机详解 - KesdiaelKen 的博客 - 洛谷博客 首推这一篇
后缀自动机(SAM)学习笔记 - zjp_shadow - 博客园 也看了这一篇,也不错
hihoCoder hihocoder板子题
登录 - 洛谷 洛谷板子题
前置知识
自动机的知识(编译原理那一套,比如有限状态自动机云云,知道能在其上沿边转移感觉就可)
AC自动机、Trie树、后缀数组
死记硬背环节
证明和心得(后附)感觉用处不大,直接计入死记硬背和抄板子做题环节
后缀自动机(SAM),是一个最小的确定有限状态自动机(DFA),接受且只接受S的后缀
parent树和自动机节点共用,时间复杂度O(n),空间复杂度O(n)
parent(也称fa,后缀链接link):当一个节点的串,在变为其后缀,且endpos发生了扩大时,最长的那个后缀对应的节点
endpos(也称right):每个节点对应的串的endpos集合是一致的,每个子串唯一出现在某个节点里
len:一个节点内代表的串的最大长度
minlen:一个节点内代表的串的最小长度,由于a回跳到父亲fa(a)的时候len(fa)+1=minlen(a),故一般不存
ch[0-26](后缀转移transfer):自动机上对应字母的转移
配合两张图食用,便于记忆这些概念
图1:abcd的后缀自动机,
红色括号是endpos集合,字母是自动机转移
图二:aaba的后缀自动机,
红色数字是节点对应的最长子串的长度len,蓝边是其parent树上的父亲fa
板子
#include<iostream>
#include<cstring>
#include<algorithm>
using namespace std;
struct SAM{
static const int N=1e5+10;
struct NODE{
int ch[26]; // 每个节点添加一个字符后到达的节点
int len; // 每个节点代表的状态中的最大串长
int fa; // 每个节点在parent树上的父亲节点, 即该节点link边指向的节点
int sz; // 每个节点对应的endpos集合的大小(即串的出现次数),等于所有parent树上儿子的大小
NODE(){memset(ch,0,sizeof(ch));len=0;}
}dian[N<<1]; // 节点数开串长的两倍
int n; // 串长
char s[N]; // 串
int las=1; // las: 上一个用到的节点编号
int tot=1; // tot: 当前用到的节点编号
// 向SAM中插入一个字符c
void add(int c){
int p=las; // 上一个状态的节点
int np=las=++tot; // 要加入的状态的节点
dian[np].sz=1; // 叶子节点endpos大小为1
dian[np].len=dian[p].len+1; // 新状态比上一个装填多一个首字符
for(;p&&!dian[p].ch[c];p=dian[p].fa)dian[p].ch[c]=np; // 指针p沿link边回跳,直至找到一个节点包含字符c的出边,无字符c的出边则将出边指向新状态的节点
if(!p)dian[np].fa=1;// 以上为case 1,指针p到SAM的起点的路径上的节点都无字符c的出边,将新节点作为SAM的起点的一个儿子节点
else{ // 节点p包含字符c的出边
int q=dian[p].ch[c]; // 节点p的字符c的出边指向的节点
if(dian[q].len==dian[p].len+1)dian[np].fa=q;// 以上为case 2,节点p和q代表的状态的最大串长相差1
else{ // 节点p和q代表的状态的最大串长相差>1
int nq=++tot; // 新建一个节点nq
dian[nq]=dian[q]; // 节点nq克隆节点q的信息
dian[nq].sz=0; // nq产生时,是一个分支节点,需要从后续儿子节点里更新获取sz
dian[nq].len=dian[p].len+1; // 保证节点p和nq代表的状态的最大串长相差1
dian[q].fa=dian[np].fa=nq;
for(;p&&dian[p].ch[c]==q;p=dian[p].fa)dian[p].ch[c]=nq;// 以上为case 3,将节点p到SAM的起点的路径上的所有节点的字符c的出边指向的节点替换为nq
}
}
}
void init(){
scanf("%s",s);
n=strlen(s);
for(int i=0;i<n;i++)add(s[i]-'a');
}
int b[N<<1],a[N<<1]; // b: 用于基数排序 a: 用于记录点号
// 按长度基数排序,短的在前长的在后
// 另一种方法是用vector直接建出parent树,对parent树直接dfs
void base_sort(){
for(int i=1;i<=tot;++i)b[dian[i].len]++;
for(int i=1;i<=tot;++i)b[i]+=b[i-1];
for(int i=1;i<=tot;++i)a[b[dian[i].len]--]=i;
}
// 用于逆拓扑序遍历求出sz
void get_sz(){
for(int i=tot;i>=1;--i){
int u=a[i],fa=dian[u].fa;
dian[fa].sz+=dian[u].sz;
}
}
void solve(){
init();
base_sort();
get_sz();
}
}sam;
int main(){
sam.solve();
return 0;
}
性质
①endpos:一个子串在原串中所有出现的地方,其末位置下标的集合
如串abcab,endpos(ab)={2,5}
②endpos相同,一个必为另一个后缀,
考虑abcdbcd,当abcd变成后缀bcd的时候,endpos增加,bcd变成cd的时候,endpos不变
③根据②,任意两个子串的endpos集合,要么一个是另一个子集,要么二者相交为空,
只要有一个相交的位置,说明是后缀,后缀就是含于的情况
④根据②,在子串缩短为其后缀的过程中,要么endpos不变,要么增加,把endpos相同的视为一个等价类节点
在SAM中,一个子串只属于一个节点,这个节点内的子串的endpos都相同
⑤endpos等价类(节点)个数为O(n),
这个没看懂曾经看懂过,可以参考原博客
⑥parent树、自动机
后缀自动机的parent树和自动机的节点是共用的,
parent树往祖先跳的时候,endpos会变大,实际上是当前串变为其后缀串,在节点中用fa定义
自动机就是读进这个字符转移到哪个点,定义了一种转移关系,在节点中用ch[0-26]定义,类似trie
⑦由②,不难发现,endpos不变的一段子串是连续的,
分别记minlen和len为这个点对应的子串的最小长度和最大长度
记点a覆盖的串的长度为[minlen(a),len(a)],则从minlen(a)再缩短一个长度的时候,endpos扩充,跳到父亲
所以有len(fa(a))+1=minlen(a),fa(a)是a在parent树上的父亲
⑧后缀自动机的边数为O(n)
这个没看懂曾经看懂过,可以参考原博客
⑨SAM的构造过程
设当前字母为c,从上一个点las转移过来,当前新开一个点np,endpos多出一个{n}来
先考虑转移,为了最大程度的利用节点,
只对las的祖先节点中,计当前祖先节点为p,
若不存在字母c的转移的点,对其进行转移到np的操作,
并令p回跳到p的父亲,最终p停留在了某个节点
以下分三种情况,实际上是(1)(2)两种情况,其中(2)分为两种子情况
(1)c是没出现过的字符,这样回跳的时候一定会回到根,这里计根为1号节点
根代表了空串,其endpos集合为{1,2,...,n},
因此,c所在节点np构成了一个新的endpos集合{n},直接令fa(np)=1
(2)停留在了中途某个点,此时点p存在字母c的转移,所以停下了,
这其中分成两种子情况,计q为p通过字母c能转移到的点,len[q]是q中最长串的长度
①若len[q]=len[p]+1,由于p是las的祖先,p的串一定是las串的后缀,
np比las多了一个字母c,q比p多了一个字母c,且q最长的串是p最长的串的长度+1,
这表明,在p的串后面补一个字母c,其仍然是 在las串后面补一个字母c 的后缀,
则q的串也是np串的后缀,q的endpos一定是np的endpos的超集,令fa(np)=q即可
②len[q]>len[p]+1,由于len[q]>=len[p]+1(是由p的串补了一个字母c而来),既然不等于,就一定大于
说明q中的串分成了两部分,长度=len[p]+1的那些串x,由①,是np串的后缀,其endpos多出了{n}
长度>len[p]+1的那些串y,不是np串的后缀,其endpos没有变化,
而q是np在往上回跳的过程中,第一个包含了np的所有endpos的集合的点,
既然不能表示,就考虑将q拆成两部分,
一部分是x串集合构成的点,是新开的点,记为nq,有len[nq]=len[p]+1
另一部分是y串集合构成的点,保持原来的q不变,
起先,令nq的所有自动机转移关系(所有ch节点)等于原先的q,这样做可行的原因是:
nq是q的后缀,后续在加入新的字符z时,设其跳到状态nr,
由于nq和q只有碰到字母c时有区别,而z不等于c(设z=c,则nr是第一个包含了np的所有endpos的点,与nq是第一个包含了np的所有endpos集合的点矛盾),
故没有受到新字母c的影响,所以二者唯一的区别endpos{n},没有给后续的nr的状态带来影响
然后考虑fa的关系,nq是旧q的一部分,fa(nq)=fa(q),新q比nq长,可以令fa(q)=nq
构建这个nq节点的意义在于表示np,所以令fa(np)=nq
旧q的儿子(转移关系ch)原封不动保留到了新q和nq中,考虑fa的改变
原先有一些节点指向q,但现在nq成为了q和np的fa,这些点应该指向nq,
所以应从p往其祖先回跳,若其碰到字母c的转移为q,就应将其改为nq,
对于没有通过c转移到q的那些祖先节点,其一定指向了nq的祖先,故不用修改
心得
感觉自己死抠知识点的证明,不做题,没啥大的意义,
有些知识点感性理解一下就好了……
但总之思路来源的博主写的很好,
满足了我这个zz对于原理的一切幻想……
去年8月、11月自学过SAM的原理,但是后来没做题,就渐渐忘了,
现在想想,SA前年当时花了四五天抠原理,但现在也只记得rank、sa、height数组是干啥的了,
实际上,SAM的情况就分三种,代码也很短,掌握了性质就可以了……
证明什么的,除了O(n)的点数和边数的证明略复杂以外,剩下的还好……
没必要每次做题前都复习一下证明,更何况这玩意比SA做题直接很多……