自动机入门——后缀自动机
自动机入门——后缀自动机
1 数据结构简介
后缀自动机是一个可以解决许多字符串相关问题的有力的数据结构,字符串的 SAM 可以理解为给定字符串的所有子串的压缩形式,SAM 的空间复杂度和构造的时间复杂度均为线性的,准确的说,一个 SAM 最多有 \(2n-1\) 个节点和 \(3n-4\) 条转移边。
2 定义
字符串 \(s\) 的 SAM 是一个接受 \(s\) 的所有后缀的最小 DFA(确定性有限自动机或确定性有限状态自动机)。
换句话说:
- SAM 是一张有向无环图。结点被称作 状态,边被称作状态间的 转移。
- 图存在一个源点 \(t_0\),称作 初始状态,其它各结点均可从 \(t_0\) 出发到达。
- 每个 转移 都标有一些字母。从一个结点出发的所有转移均 不同。
- 存在一个或多个 终止状态。如果我们从初始状态 \(t_0\) 出发,最终转移到了一个终止状态,则路径上的所有转移连接起来一定是字符串 \(s\) 的一个后缀。\(s\) 的每个后缀均可用一条从 \(t_0\) 到某个终止状态的路径构成。
- 在所有满足上述条件的自动机中,SAM 的结点数是最少的。
3 性质
SAM 包含关于字符串 \(s\) 的所有子串的信息,任意从初始状态开始的路径,如果我们将转移路径上的标号写下来都会形成一个 \(s\) 的子串,反之每一个 \(s\) 的子串对应从 \(t_0\) 开始的某条路径。
为了简化表达,我们称子串对应一条路径,反过来,我们说任意一条路径对应它的标号构成的字符串。
到达某个状态的路径可能不止一条,因此我们说一个状态对应一些字符串的集合这个集合的每一个元素对应这些路径。
4 构造
4.1 结束位置
结束位置 \(endpos\) 是一个比较重要的概念。
考虑字符串 \(s\) 的任意非空子串 \(t\) ,我们记 \(endpos(t)\) 为在字符串 \(s\) 中 \(t\) 出现的所有结束位置。两个子串的 \(t_1\),\(t_2\) 的 \(endpos\) 集合可能相等,我们称如果两个子串的 \(endpos\) 集合相等,我们都可以根据它们的 \(endpos\) 集合分为若干等价类。
SAM 中的每一个状态都对应一个等价类,也就是说 SAM 的状态总数为等价类的个数 \(+1\)(初始节点)。
-
引理 \(1\)
字符串 \(s\) 的两个非空子串 \(u,w\) ,(假设 \(|u|<|v|\))的 \(endpos\) 相同,当且仅当字符串 \(u\) 在 \(s\) 中的每次出现,都是以 \(w\) 的后缀形式存在的。
引理显然成立。
-
引理 \(2\)
考虑两个非空子串 \(u,w\) (假设 \(|u|\le |w|\)),那么要么 $endpos(u)\cap endpos(w)=\varnothing $ ,要么 \(endpos(w)\subseteq endpos(u)\) ,这取决与 \(u\) 是否为 \(w\) 的一个后缀,如果不是,就是前者,否则就是后者。
其实也比较显然,因为如果不是后缀,显然 \(w\) 出现的地方 \(u\) 不可能出现,所以是空集,如果是后缀,那么长度小的有可能出现在更多地方,并且一定在 \(w\) 都出现的地方出现过。
-
引理 \(3\)
考虑一个 \(endpos\) 等价类,对于同一等价类中的任意两个子串,较短者为较长者的后缀,且该等价类中的子串长度是连续的。
显然前面这个后缀关系是显然的,我们来证明它们是连续的。如果不连续,那么设字符串 \(q\) 为夹在两个属于同一等价类的字符串 \(s,t(|s|<|t|)\) 之间的一个字符串,且 \(q\) 为 \(t\) 的后缀,\(s\) 是 \(q\) 的后缀,根据引理 \(2\) ,不难推出矛盾。
通过 SAM 的转移,即一些有向边,通过不同的方式走到状态 \(u\) ,我们就可以得到状态 \(u\) 对应的等价类所对应的所有字符串。
4.2 后缀链接 link
考虑 SAM 中某一个不是 \(t_0\) 的状态 \(v\) ,我们已经知道,状态 \(v\) 对应于具有相同 \(endpos\) 的等价类,设 \(w\) 是最长的一个,那么所有等价类中的字符串都是 \(w\) 的后缀。
我们还知道字符串 \(w\) 的前几个后缀全部包含于这个等价类,且所有其它后缀都在其他的等价类中,我们记 \(t\) 为最长的等价类不和 \(w\) 的相同的后缀。然后将 \(v\) 的后缀链接连到 \(t\) 的等价类上。
为了方便,我们规定:\(endpos(t_0)=\{-1,0,...,|s|-1\}\) 。
-
引理 \(4\)
所有的后缀链接构成一棵根节点为 \(t_0\) 的树。
比较显然,首先一定有 \(n-1\) 条边,其次因为字符串长度递减,所以不会出现环。然后一直递减,一定会到达初始状态 \(t_0\) 。
-
引理 \(5\)
通过 \(endpos\) 集合构造的树(每个子节点的 \(subset\) 都包含在父节点的 \(subset\) 中)与通过后缀链接 \(link\) 构造的树相同。
由引理 \(2\) ,这种实质是后缀关系的 \(endpos\) 能够形成一棵树。我们考虑不是 \(t_0\) 的状态 \(v\) ,显然有 \(endpos(v)\subsetneq endpos(link(v))\)。所以定理成立。
实际上这个定理有一个约束条件是父节点其实是满足条件的长度最长的。
4.3 小结
- \(s\) 的子串可以被划分成多个等价类。
- SAM 由若干状态构成,其中每一个状态对应一个等价类。对于每一个状态 \(v\) ,一个或多个子串与之匹配,我们记 \(longest(v)\) 为里面最长的一个,记 \(len(v)\) 为它的长度,记 \(shortest(v)\) 为最短的子串,它的长度为 \(minlen(v)\) ,那么所有字符串的长度恰好覆盖 \([minlen(v),len(v)]\) 中的每一个整数。
- 后缀链接可以定义为连接到对应字符串 \(longest(v)\) 的长度为 \(minlen(v)-1\) 的后缀的一条边。从根节点 \(t_0\) 出发的后缀链接可以形成一棵树。这棵树也表示 \(endpos\) 集合间的包含关系。
- 我们有 \(minlen(v)=len(link(v))+1\)
- 如果我们从 \(v_0\) 开始一直走到 \(t_0\) ,那么沿途所有字符串的长度形成了连续的区间 \([0,len(v_0)]\)。
4.4 算法
这个算法是一个在线算法,可以逐个加入字符串中的每个字符并在每一步维护 SAM。
为了保证线性的时间复杂度,我们将只保存 \(len\) 和 \(link\) 的值和每个状态的转移列表,我们不会标记终止状态,但这些标记可以被分配,我们后面将会展示。
一开始 SAM 只包含一个状态 \(t_0\) ,编号为 \(0\) ,对于状态 \(t_0\) 我们指定 \(len=0,link=-1\) 。(这里 \(-1\) 就是一个虚拟状态)
现在任务转化为实现给当前字符串添加一个字符 \(c\) 的过程,算法流程如下:
- 令 \(last\) 为添加字符 \(c\) 之前,整个字符串对应的状态。
- 创建一个新的状态 \(cur\) ,并将 \(len(cur)\) 赋值为 \(len(last)+1\) 。
- 现在我们从状态 \(last\) 开始按一下流程进行:如果没有字符 \(c\) 的转移,我们就添加一个到状态 \(cur\) 的转移,遍历后缀链接,如果在某个点已经存在字符 \(c\) 的转移,我们就停下来,并将这个状态标记为 \(p\) 。
- 如果没有找到这样的状态 \(p\) ,我们就到达了虚拟状态 \(-1\) ,我们将 \(link(cur)\) 赋值为 \(0\) 并退出。
- 假设现在我们找到了一个状态 \(p\) ,其可以通过字符 \(c\) 转移,我们将转移到的状态记为 \(q\) 。
- 如果 \(len(p)+1=len(q)\) ,我们只需要将 \(link(cur)\) 赋值为 \(q\) 并退出。
- 否则,我们需要复制状态 \(q\) ,我们创建一个新的状态 \(clone\) ,复制 \(q\) 的除了 \(len\) 的值以外的所有信息(后缀链接和转移)。我们将 \(len(clone)\) 赋值为 \(len(p)+1\) 。复制之后,我们将后缀链接从 \(cur\) 指向 \(clone\) ,也从 \(q\) 指向 \(clone\) 。最终我们需要使用后缀链接从状态 \(p\) 往回走,只要存在一条通过 \(p\) 到状态 \(q\) 的转移,就将该转移重新定向到状态 \(clone\) 。
- 以上三种情况,在完成这个过程之后,我们将 \(last\) 的值更新为 \(cur\)。
而终止状态我们只需要从最后的 \(last\) 顺着后缀链接走,一直到初始状态,我们完整的遍历了一遍字符串 \(s\) 的所有后缀,这些状态都是初始状态。
因为我们只对 \(s\) 的每一个字符建立一个或两个新状态,所以 SAM 只包括线性个状态。
4.5 正确性证明
-
如果一个转移 \((p,q)\) 满足 \(len(p)+1=len(q)\) ,则我们称这个转移是连续的。否则,即当 \(len(p)+1<len(q)\) 时称其为不连续的。连续的转移是固定的,而不连续的转移可能会改变。
-
为了避免引起歧义,我们称 SAM 中插入当前字符 \(c\) 之前的字符串为 \(s\) 。
-
算法从创建一个新状态 \(cur\) 开始,对应于整个字符串 \(s+c\) ,我们创建一个新的节点的原因很清楚,就是要创建一个包含 \(endpos(s+c)\) 的等价类。
-
在创建一个新的状态之后,我们会从对应整个字符串 \(s\) 的状态通过后缀链接进行遍历,对于每一个状态,我们尝试添加一个通过字符 \(c\) 到新状态 \(cur\) 的转移。然而我们只能添加原有转移不冲突的转移。因此我们只要找到已存在的 \(c\) 的转移,我们就必须停止。
-
换句话说,当我们加入一个字符 \(c\) 的时候,会产生 \(|s|\) 个新的后缀,我们不断跳后缀链接,其实就是不断跳 \(s\) 的后缀,然后如果不冲突我们就连一条到 \(cur\) 的边。
-
如果不存在冲突,也就是说我们到达了虚拟状态 \(-1\) ,那意味着我们为所有 \(s\) 的后缀所对应的状态添加了转移 \(c\) ,这同时也意味着 \(c\) 之前从来没有在字符串中出现过,所以显然 \(cur\) 的后缀链接为 \(0\) 。
-
否则,在第二种情况下,我们找到了转移 \((p,q)\) ,这意味着我们尝试向自动机内添加一个已经存在的字符串 \(x+c\),其中 \(x\) 为 \(s\) 的后缀,对应状态 \(p\) 中最长的字符串,并且 \(x+c\) 已经作为一个 \(s\) 的子串出现过了。显然,如果我们接着状态 \(p\) 继续向上跳后缀链接,这些状态也肯定有一个字符 \(c\) 的转移,因为他们都是字符串 \(x\) 的后缀。
-
因为字符串 \(s\) 的自动机的构造是正确的,所以我们不应该在这里添加一个新的转移,可是难点在于,\(cur\) 的后缀链接应该连接到哪一个状态呢?根据前面所说后缀链接的定义,显然我们要把这个后缀链接连到一个状态上,满足这个状态最长的一个字符串为 \(x+c\)。
-
如果转移 \((p,q)\) 是连续的,即 \(len(q)=len(p)+1\),说明状态 \(q\) 里最长的子串就是 \(x+c\) ,我们把 \(cur\) 的后缀链接连到 \(q\) 上就可以。
-
但是如果 \((p,q)\) 不连续,即 \(len(q)>len(p)+1\) ,注意这里不可能出现小于,因为状态 \(q\) 中一定包含着字符串 \(x+c\) 。如果不连续,就意味着有多个状态转移到 \(q\) ,并且 \(q\) 里面最长的字符串并不是 \(x+c\) ,我们除了将 \(q\) 拆成两个状态没有其他方法。
如何拆开一个状态呢?我们先复制状态 \(q\) 为 \(clone\) ,我们将 \(len(clone)\) 赋值为 \(len(p)+1\) 。由于我们不想改变遍历到 \(q\) 的路径,我们将 \(q\) 的所有转移复制到 \(clone\) ,我们也将从 \(clone\) 出发的后缀链接设置为 \(q\) 的后缀链接并将 \(q\) 的后缀链接连接到 \(clone\) 。然后把 \(cur\) 的后缀链接设置为 \(clone\) 。
最后我们将一些到 \(q\) 的转移从定向到 \(clone\) ,我们需要继续沿着后缀链接遍历,从节点 \(p\) 直到虚拟状态 \(-1\) 或者转移到不是状态 \(q\) 的一个转移。
不难发现,这样做之后,\(clone\) 相当于一个最长字符串为 \(x+c\) 的状态,由后缀链接的定义,不难发现 \(q\) 的后缀链接应该为 \(clone\) ,而 \(clone\) 的后缀链接应该为 \(q\) 原来的后缀链接,这样做并不会出现两个状态等价类相同的情况,因为我们加入字符 \(c\) 之后状态 \(clone\) 中的字符又出现了一遍,但是拆完之后的状态 \(q\) 里并没有再出现一遍,因为如果状态 \(q\) 里面的某个字符串出现过了,那么因为 \(x\) 是它的后缀,所以我们一定不会找到 \(x\) 而会找到这个字符串,但是我们找到的是 \(x\) ,所以这就推出矛盾。
至于最后的定向,因为所有从 \(p\) 往前到以前 \(q\) 的转移,设是从状态 \(e\) 转移过来的,其 \(len(e)\) 一定是小于等于 \(len(clone)-1\) ,也就是说,这些转移以前都是转移到现在 \(clone\) 的部分。正确性显然。
4.6 对操作次数为线性的证明
一下我们认为字符集的大小为常数。
我们考虑算法的各个部分,有三处时间复杂度不明显是线性的:
- 第一处是遍历所有状态 \(last\) 的后缀链接,添加字符 \(c\) 的转移。
- 第二处是当状态 \(q\) 被复制到一个新的状态 \(clone\) 时复制转移的过程。
- 修改指向 \(q\) 的转移,将它们重定向到 \(clone\) 。
因为 SAM 的状态数和转移数(状态数的线性证明在上面,转移数在实现算法的时候证明)为线性,所以第一处和第二处是线性的。
我们接下来证明第三处也是线性的。
在每一次添加字符时我们不妨关注一下 \(shortest(link(last))\) ,在向 \(s\) 中添加字符之前,有 \(shortest(p)\ge shortest(link(last))\) ,这是因为 \(link(last)\) 至多是 \(p\) ,我们由 \(q\) 拷贝得到了节点 \(clone\) ,并试图从 \(p\) 沿后缀链接上溯,将所有通往 \(q\) 的转移重定向为 \(clone\) ,这时 \(shortest(clone)\) 是严格变小的,加完字符后,我们有 \(last=cur\rightarrow link(last)=link(cur)=clone\) ,所以 \(shortest(link(last))\) 实在严格变小的,所以第三处是线性的。
4.7 代码实现
struct node{
int len,link,ch[26];
};
struct SAM{
node p[N];
int size,last;
inline SAM(){
p[0].len=0;p[0].link=-1;last=0;
}
inline void insert(int c){
int cur=++size;f[size]++;
p[cur].len=p[last].len+1;
int now=last;
while(now!=-1&&!p[now].ch[c]){
p[now].ch[c]=cur;
now=p[now].link;
}
if(now==-1) p[cur].link=0;
else{
int q=p[now].ch[c];
if(p[now].len+1==p[q].len) p[cur].link=q;
else{
int clone=++size;
p[clone]=p[q];p[clone].len=p[now].len+1;
while(now!=-1&&p[now].ch[c]==q){
p[now].ch[c]=clone;now=p[now].link;
}
p[q].link=p[cur].link=clone;
}
}
last=cur;
}
};
SAM sam;
代码是显然的,理解了上述过程后代码就变得浅显易懂。
5 例题
我们挖掘一下 SAM link 的一些性质。我们首先把由后缀链接组成的树建出来。然后把所有前缀对应的状态设为终点节点,我们在这颗树上 dp,每一次记录即可。
为什么这样做是对的,原因很简单,考虑一下这棵树上的叶子结点是什么?是只出现过一次的前缀,我们从如果一个状态不包含前缀,那么显然它的所有儿子节点划分了他的 \(endpos\) 集合,我们累加儿子的信息就可以知道字符串出现次数,但是如果有前缀,那么这个前缀是不会被统计到的,我们额外给它加上,总而言之,我们直接在所有的前缀状态上加 \(1\) ,然后直接树形 dp 就可以。
代码:
#include<bits/stdc++.h>
#define dd double
#define ld long double
#define ll long long
#define uint unsigned int
#define ull unsigned long long
#define N 2001000
#define M 4000000
using namespace std;
const int INF=0x3f3f3f3f;
inline int Max(int a,int b){
return a>b?a:b;
}
template<typename T> inline void read(T &x) {
x=0; int f=1;
char c=getchar();
for(;!isdigit(c);c=getchar()) if(c == '-') f=-f;
for(;isdigit(c);c=getchar()) x=x*10+c-'0';
x*=f;
}
struct edge{
int to,next;
inline void intt(int to_,int ne_){
to=to_;next=ne_;
}
};
edge li[M];
int head[N],tail;
inline void add(int from,int to){
li[++tail].intt(to,head[from]);
head[from]=tail;
}
int f[N],ans;
struct node{
int len,link,ch[26];
};
struct SAM{
node p[N];
int size,last;
inline SAM(){
p[0].len=0;p[0].link=-1;last=0;
}
inline void insert(int c){
int cur=++size;f[size]++;
p[cur].len=p[last].len+1;
int now=last;
while(now!=-1&&!p[now].ch[c]){
p[now].ch[c]=cur;
now=p[now].link;
}
if(now==-1) p[cur].link=0;
else{
int q=p[now].ch[c];
if(p[now].len+1==p[q].len) p[cur].link=q;
else{
int clone=++size;
p[clone]=p[q];p[clone].len=p[now].len+1;
while(now!=-1&&p[now].ch[c]==q){
p[now].ch[c]=clone;now=p[now].link;
}
p[q].link=p[cur].link=clone;
}
}
last=cur;
}
};
SAM sam;
char s[N];
inline void dfs(int k){
// printf("k:%d\n",sam.p[k].len);
for(int x=head[k];x;x=li[x].next){
int to=li[x].to;
dfs(to);
f[k]+=f[to];
}
if(f[k]>1) ans=Max(ans,f[k]*sam.p[k].len);
}
int main(){
scanf("%s",s);
for(int i=0;s[i];i++){
sam.insert(s[i]-'a');
}
for(int i=1;i<=sam.size;i++){
// printf("%d\n",sam.p[i].link);
add(sam.p[i].link,i);
}
dfs(0);
printf("%d",ans);
return 0;
}
引用
有一些明显的错误已经在本文改正。