后缀数组 - 后缀自动机 - 广义后缀自动机 - 后缀树

引入:字符串最长公共前缀(Longest Common Prefix,LCP)

普通求法

利用 hash。设需要求 S,T 字符串的 LCP,则可以二分长度 len,求一个最大的 len 满足 hash(S1Slen)=hash(T1,Tlen)


后缀数组(Suffix Array,SA)

例题1:

给出长为 n 的字符串 S,求最长的子串 A,使得 AS可重复地出现了两次。

核心做法

定义 si=Sin,则原题等效于求 maxi,j[1,n],ij|LCP(si,sj)|

数据范围1:n103

直接 O(n)O(n2) 处理所有 s 的所有后缀的 hash 值,然后 O(n2logn) 暴力枚举所有的 i,j,二分出对应的 |LCP(si,sj)|

数据范围2:n105

考虑固定 i,求 maxj=1n|LCP(si,sj)|。可以把所有的 s 按照字典序进行排序,然后某个 i 对应的 j 一定在排好序的所有 ssi 的前/后一个之间。故而可以使用二分 + hash 比较任意两个 s 的字典序大小和 LCP 长度,使用快速排序,时间复杂度为 O(nlog2n)

数据范围3:n106

此时我们需要一种更快速的方法对所有后缀进行排序+处理 LCP 长度。

发现如果已经知道 sisj 的前半部分和后半部分的字典序大小关系,则可以在 O(1) 的时间内求出 sisj 的大小关系。

可以使用倍增排序。设 rnkj,i 表示只取每个 s 的前 2j 位进行比较后 si 的排名。考虑如何由 rnkj,i 推到 rnkj+1,i。任意两个后缀 sasb 的前 2j 位的字典序大小可以直接在 rnkj 中得出,而后 2j 位的字典序大小关系就是 rnkj,a+2jrnkj,b+2j 的大小关系。由此我们推出了 rnkj+1,在 O(logn) 次这样的过程后即可求所有 s 的真正大小关系。

使用快速排序的单次处理的时间复杂度是 O(nlogn)。由于单次处理实质是双关键字排序,而这两个关键字的值域均为 O(n),可以使用基数排序(O(logn) 次操作的总复杂度为 O(nlogn)),给出一种使用链表实现的代码:

后缀数组模板代码

点此查看代码
#include <bits/stdc++.h>
using namespace std;
const int maxn=1000010;
const int maxb=2620010;
int n,i,j,k,d=1,r,b=1,m,p;
int h1[maxn],n1[maxn],h2[maxn],n2[maxn];
int rnk[maxb],nrk[maxn],pla[maxn];
int main(){
for(n=1;;++n){
rnk[n]=getchar();m=max(m,rnk[n]);
if(rnk[n]=='\n'||rnk[n]<0) break;
}
rnk[n--]=0;
p=n; while(p>>=1) ++b;
for(i=1;i<=b;++i,d<<=1){
for(j=1;j<=n;++j){
n2[j]=h2[rnk[j+d]];
h2[rnk[j+d]]=j;
}
for(j=0;j<=m;++j){
for(k=h2[j];k;k=n2[k]){
n1[k]=h1[rnk[k]];
h1[rnk[k]]=k;
}
}
p=n;
for(j=m;j>=0;--j) for(k=h1[j];k;k=n1[k]) pla[p--]=k;
for(j=0;j<=m;++j) h1[j]=n1[j]=h2[j]=n2[j]=0;
r=nrk[pla[1]]=1;
for(j=2;j<=n;++j){
if(rnk[pla[j-1]]<rnk[pla[j]]||
rnk[pla[j-1]+d]<rnk[pla[j]+d]) ++r;
nrk[pla[j]]=r;
}
m=0;
for(j=1;j<=n;++j){
rnk[j]=nrk[j];
m=max(rnk[j],m);
}
}
for(i=1;i<=n;++i) printf("%d ",pla[i]);
return 0;
}

求出每个后缀的字典序的排名后(si 的排名记为 ri),考虑就此使用另一种方式(而不是基于 hash 的 O(nlog2n) 的做法)求 LCP。


Height 数组

定义

hi 为满足 ra=irb=i1|LCP(sa,sb)|h10,假设 sr0 为空串)

引理

i>1,hrihri11

证明

反证法。若对于 si1 存在一个 j 满足 |LCP(sj,si1)|>hri+1rj<ri1,则去掉 LCP(sj,si1) 的第一个字符即可得到 LCP(sj+1,si),必有 |LCP(sj+1,si)|>hri。而由定义得 hri=maxsj<si|LCP(sj,si)|(设对所有后缀排好序后 si 的前一个后缀为 spi,若 j,sj<si,|LCP(sj,si)|>|LCP(spi,si)|,则 Sj+|LCP(spi,si)|=Si+|LCP(spi,si)|SjSj+|LCP(spi,si)|1=SiSi+|LCP(spi,si)|1,而 Sj+|LCP(spi,si)|<Si+|LCP(spi,si)|,故而一定有 sj>spi,与 pi 的定义矛盾),且由 sj1=si11 必有 rj+1<ri;与 h 的定义矛盾。故而 i>1,hrihri11


于是可以令 p=hri1,在计算 hri 时直接从 Si+p1 开始匹配,将 p 不断自增直到 Si+p 为最后一个相等的字符为止,此时必有 p=hri。由于 maxhnhrihri11,则匹配的总时间复杂度是 O(n) 的。这样也就求出了此题的答案。

后缀数组的定义

后缀数组主要包含两个数组:sark

sai 是字符串的所有后缀按照字典序升序排序后第 i 个后缀对应的下标,rki 为这个字符串的第 i 位开始的后缀的排名。

求两子串的 LCP

对于两个字符串 ST,以及一个字典序在它们中间的字符串 s,一定有 |LCP(S,T)|=min(|LCP(S,s)|,|LCP(s,T)|)(显然 STs 的前 |LCP(S,T)| 位是一样的,而 ST 的第 |LCP(S,T)|+1 位不一样,s 的这一位只能和 ST 的这一位之一相同)。

故而有 |LCP(si,sj)|=mink=rkirkj1hk(rki<rkj)。求任意两个后缀的 LCP 即转化成了 RMQ 问题。

例题2

给出长为 n 的字符串 S,求最长的子串 A,使得 AS不可重复地出现了两次。n106

解法

求出后缀数组和 height 数组。二分答案的长度 k,然后求 k 是否合法等效于在 h 中是否存在一段连续段满足最小值不小于 ksa 对应的一段极差大于 k。可以使用笛卡尔树或并查集处理。

例题3

给出长为 n 的字符串 s,求其第 k 小子串。需要给出重复计数和不重复计数的两种答案。k109

数据范围0:n5×105

数据范围1:n106

考虑后缀数组的性质。求出 sa 后,发现所有的 sainsai+1 个前缀刚好对应了原串中的所有子串。

考虑不重复计数的情况,显然第 sai 个后缀的前缀中有 hi 个是和第 sai1 个的前缀相同;而 SiSi+hiSiSn 字典序递增;并且最后一个个前缀 T=SiSn 的字典序一定比 sa1sai1 的任何一个前缀都要大,比 sai+1san 的任何一个不与上一个后缀重复前缀都要小。综上,第 sai 后缀一定贡献了 nihi+1 个子串,且所有的子串已按照字典序升序排好。

而对于重复计数的情况,考虑有某个前缀的字符串有多少种。若某个前缀长为 k,且以此为前缀的原串后缀的字典序最小者为 sp,则满足 hi<k,ip 的最小 i 对应的 spsi 均以这个长为 k 的前缀为前缀,以其为前缀的所有子串数即为 j=pi(njk+1)。故而按位得出答案的前缀直至其后再不能加字符即可。

例题4

给出长为 n 的字符串 s,对于其子串 a,设 as 中出现了 k 次,求 maxk|a|[k>1]

数据范围1:n103

可以对于每个长度,将 s 扫一遍,至于维护目前扫到的字符串出现了多少次,可以使用 hash + 桶存放某种字符串被扫到了多少次。时间复杂度是 O(n2) 的。

数据范围2:n106

考虑后缀数组。由于每个子串均可以看作 s 的某个后缀的前缀,故而固定 a 的一个长度 k 时,若区间 [l,r] 满足 mini=lrhik,则 sal1sar 后缀均有相同的长为 k 的前缀。同样可以使用笛卡尔树或并查集解决。其中并查集的做法是将 [1,n] 中每个数事先划到一个连通块中,然后从大到小枚举长度,当枚举到 k 时,对于 hi=k+1,将 i1i 所在的连通块合并,然后查询此时最大连通块的大小(记为 sizk),maxsizkk 即为答案。

亲测没怎么卡常的 SA 做法能过

代码

点此查看代码
#include <bits/stdc++.h>
using namespace std;
const int maxn=1000010;
const int maxb=2620010;
int n,i,j,k,d=1,r,b=1,m,p;
long long ans;
char s[maxn];
int h1[maxn],n1[maxn],h2[maxn],n2[maxn];
int rnk[maxb],nrk[maxn],pla[maxn];
int fa[maxn],ht[maxn],siz[maxn];
int Find(const int &p){
if(p==fa[p]) return p;
return fa[p]=Find(fa[p]);
}
int main(){
scanf("%s",s+1);
n=strlen(s+1);
for(i=1;i<=n;++i){
rnk[i]=s[i];
m=max(m,rnk[i]);
fa[i]=i;siz[i]=1;
}
p=n;
while(p>>=1) ++b;
for(i=1;i<=b;++i,d<<=1){
for(j=1;j<=n;++j){
n2[j]=h2[rnk[j+d]];
h2[rnk[j+d]]=j;
}
for(j=0;j<=m;++j){
for(k=h2[j];k;k=n2[k]){
n1[k]=h1[rnk[k]];
h1[rnk[k]]=k;
}
}
p=n;
for(j=m;j>=0;--j) for(k=h1[j];k;k=n1[k]) pla[p--]=k;
for(j=0;j<=m;++j) h1[j]=n1[j]=h2[j]=n2[j]=0;
r=nrk[pla[1]]=1;
for(j=2;j<=n;++j){
if(rnk[pla[j-1]]<rnk[pla[j]]||
rnk[pla[j-1]+d]<rnk[pla[j]+d]) ++r;
nrk[pla[j]]=r;
}
m=0;
for(j=1;j<=n;++j){
rnk[j]=nrk[j];
m=max(m,rnk[j]);
}
}
j=m=0;
for(i=1;i<=n;++i){
if(!rnk[i]) continue; if(j) --j;
while(s[i+j]==s[pla[rnk[i]-1]+j]) ++j;
ht[rnk[i]]=j;n1[rnk[i]]=h1[j];h1[j]=rnk[i];
m=max(m,j);
}
j=1;
for(i=m;i;--i){
for(k=h1[i];k;k=n1[k]){
b=Find(k-1);r=Find(k);
j=max(j,siz[b]+=siz[r]);
fa[r]=b;
}
if(j==1) continue;
ans=max(1LL*i*j,ans);
}
printf("%lld",ans);
return 0;
}

数据范围3:n107

求后缀数组是有 O(n) 的 SA-IS 和 DC3 算法的。

但是我们的重点不在于此,而是介绍一种名为 后缀自动机 的科技。


后缀自动机(Suffix AutoMaton,SAM)


引入:自动机

下述讨论的自动机为 确定有限状态自动机(DFA)

自动机是一种能够接收(由边权组成的)路径的有向图,有边权,起点(下面记为 t0)和终点(可能有若干个)。对于一条路径,若能顺着路径上的每一种对应的边权由起点走到某个终点,则称其能接受这条路径。显然从起点出发的每条路径对应的边权序列唯一。


定义

后缀自动机能且只能接受某个字符串的若干后缀。在所有构造出来的满足这个功能的自动机中,SAM 的节点是最少的。

性质

SAM 的每条边上均对应一个字符,由于从起点到某个终点的路径对应一个后缀,因此从起点出发的每条路径的每条路径均对应了一个子串。同时由于 SAM 的一个点可能会由多条路径到达,因此每个点对应了这些子串的集合。

相关概念

1. endpos

对于 s 的非空子串 t,记 endpos(t)ts 中的所有结束位置的集合。特别地,endpos(空串)={0,1,2,,n}

同时若两个串的 endpos 集合相同,则称这两个串为 endpos 等价的。由于这个关系满足自反性、对称性和传递性等性质,则 s 的所有子串可以被划分为若干个 endpos 等价类。而 SAM 的起点之外的每个节点即代表一个 endpos 等价类。

endpos 的一些性质:

  1. 对于子串 st|s||t|,下同),若 st endpos 等价,则必有 st 的后缀,反之则不一定。证明显然。

  2. s 不是 t 的后缀,则 endpos(s)endpos(t)=,否则 endpos(t)endpos(s)。证明同样显然。

  3. 将一个 endpos 等价类中的字符串按字典序从小到大排序,则每个字符串均是后一个字符串移除第一个字符得来的。

    证明:

    若这个 endpos 等价类只包含一个字符串,则显然成立。

    若这个 endpos 等价类包含不止一个字符串,考虑其中最长的字符串 s 和最短的字符串 t。显然 ts 的后缀,考虑 s 的任意一个比 t 长的后缀 p。由于 endpos(t)endpos(p)endpos(s)endpos(t)=endpos(s),故而 endpos(t)=endpos(p)=endpos(s)p 一定在这个 endpos 等价类中。

由前述内容可知:对于某个作为其所在 endpos 等价类中最长的字符串 s,不断将其第一个字符移除,则在某次这样操作之后其一定会变成处于另一个等价类的字符串 t(其中 t 可能是空串,而空串不与任意非空串等价),则定义 link(endpos(s))=endpos(t),具体为从 endpos(s) 连向 endpos(t) 的有向边。

显然在不断删去 s 的最前面的字符之后,s 会变成空串,也就是 t0 代表的状态(从 t0t0 的简单路径为空),故而所有 link 构成的有向图形态是以 t0 为根的内向树。

同时若 link(s)=t,一定有 st,子节点代表的 endpos 一定包含于父亲节点代表的 endpos 中,且两个无祖先后代关系的节点对应的 endpos 集合交集一定为空(它们代表的字符串无后缀关系)。故而最后后缀链接构成的树本质上是 endpos 集合构成的树。特别地,如果从代表整个串的节点开始,不断走 link 边直到 t0 节点,则走到过的节点对应的所有字符串一定是整个串的所有后缀,也就是说这些节点即是自动机上的终点。

构造

定义 longest(v) 表示 v 节点对应的最长子串,len(v) 表示 longest(v) 的长度,min(v) 表示 v 节点对应的最短子串,minlen(v) 表示 v 节点对应的最短子串的长度。显然有 minlen(v)=len(link(v))+1

构造 SAM 时我们采用在线算法“增量法”,每一次在字符串末尾插入一个字符。同时在 SAM 的每个节点上只保存其 lenlink 和对应的转移列表,而不标记终止状态(以保证空间复杂度)。

考虑在目前已有的字符串后插入字符 c 时的做法。令 last 为目前整个字符串对应的节点(一开始 last1)。

在插入 c 时,先创造一个状态 cur,将 len(cur) 设为 len(last)+1(此时未确定 link(cur))。然后从 last 开始,如果还没有到字符 c 的转移,则添加一个到 c 的转移,然后将 last 赋为 link(last),否则令此时的 lastp 并停止此过程。如果最后都没有找到 p,则直接把 link(cur) 赋为 1 并退出。否则记 son(p,c)=q

分类讨论 qp 的关系。若 len(q)=len(p)+1,则只需要把 link(cur) 赋值为 q。否则需要 复制 状态 q:新建一个 clone 状态,复制 qlink 和转移,将 len(clone) 赋为 len(p)+1。复制之后,将 link(cur)link(q) 均赋为 clone。然后,若 pp 的后缀链接树上的祖先存在到 q 的转移,则将该转移重定向到 clone

最后把 last 更新为 cur

理解

考虑 SAM 上的转移代表在某个字符串最后加上某个字符。显然在字符串结尾加上字符 c 只会对目前所有的后缀造成影响。在加入某个字符 c 后,首先要创建一个新的等价类 cur,然后将所有后缀加上 c。对于原来的串 S,需要把其后缀加上 c 时,需要从 last 开始找到所有的后缀对应的所有节点。理论上我们需要将所有后缀对应节点均检查有无 c 的转移(无则加上),但如果某个节点对应有 c 的转移,则其 link 到的节点肯定也有 c 的转移(存在这样的字符串);故而从这个节点开始即不必再跳 link。反之,如果跳到了 t0 还是没有找到 c 的转移,则字符 c 未出现在串中,S+c 的所有后缀一定只对应了一个等价类。

考虑找到 c 的转移(存在上述的 pq)之后该如何做。由于 longest(link(cur))=longest(p)+clongest(p) 刚好是 S 的最长的满足后面有 c 的前缀,而更长的前缀没有 c 的转移),故而若 len(q)=len(p)+1link(cur)=q。否则,由于 longest(p)+c 的等价类由 q 变成了 q{|S|+1},故而要新建一个状态 clone,把 longest(p)+c 独立出来。同时需要把 link(cur)link(q) 均赋为 clone。而 clone 后面能加的字符与 q 一致,min(q)min(clone) 相同,显然 q 的转移边和 link 要复制给 clone。同时 longest(clone) 可以在前面加一个字符变成等价类仍为先前 q 对应的等价类的字符串,故而需要将 link(q) 设为 clone。最后对于 p 及其在 link 树上的祖先先前指向 q 的转移边重定向到 clone 显然是因为 min(clone)=min(q)

有一个优化:考虑 p 在后缀链接树的某个祖先 b 对应的 c 转移边没有连接到 q,则 b 及其对应的祖先 x 对应的 |longest(x)c| 一定小于 minlen(q),故而 xc 转移边一定不会连到 q。这会是时间复杂度优化的重要依据之一。

时间复杂度证明

下面我们令目前操作中转移边的查找、修改、增加等操作均为单次 O(1) 的(如果字符集很大或者字符集不确定,则需要用到 std::map,转移边相关的操作会是单次 O(logΣ) 的,其中 Σ 是字符集大小)。

节点数

SAM 在每次添加字符后最多只会增加两个节点,故而其节点数(状态数)不超过 2n1。有且只有 abbb 可以达到这个上界,由于在每次添加字符后找到的 p,q 均不满足 len(q)=len(p)+1

转移数

定义一个转移 pq 为连续的当且仅当 len(q)=len(p)+1,反之则不连续。显然两个点之间的最长路径一定只有连续的转移。

对于连续的转移,考虑 SAM 中以 t0 为根的所有最长路径的生成树。由于生成树只包含连续的边,故而这些边的数量少于节点数,不会超过 2n2 条。

令一个不连续的转移为 pcq,且 u 为从 t0p 对应的最长路径,w 为从 q 到任意一个终止状态对应的路径。(由于 u+c 对应了某个子串,从这个子串一定能延伸成一个后缀,故而 w 一定存在)则 u+c+w 一定是原串的一个后缀,且 t0pcq这个终止状态 显然是从 t0 出发唯一的表示 u+c+w 的路径。同时由于原串是 longest(last),故而 u+c+w 一定不会代表原串。故而最后不连续转移的数量不超过 n1

最后所有转移数不会超过 3n3(更确切地说是 3n4,由于状态数为 2n1 的情况 abbb 转移数只为 2n1,而 abbbc 可以达到 3n4 的上界)

操作数

考虑一下不能直接看出总时间复杂度为 O(n) 的操作:

  • 遍历 last 在后缀链接树上的祖先并添加转移。
  • q 的状态复制给 clone 并复制转移。

由于 SAM 上的点数和边数均为 O(n) 的,故而这两种操作的总时间复杂度是 O(n) 的。

  • 将指向 q 的转移重定向到 clone

考虑 minlen(link(last)) 的变化。设 p 在后缀链接树的最远的满足需要与 clone 连接的祖先为 d,则显然有 minlen(link(last))minlen(p)minlen(d),故而 minlen(link(cur))=minlen(clone)=minlen(d)+1。如果 dpk 级祖先(跳了 k 次),由于每跳一级 linkminlen 一定会减少,故而 minlen(d)minlen(p)kminlen(link(last))kminlen(link(cur))minlen(link(last))k+1。也就是说 minlen(link(last)) 在每次操作后均会增加不超过 1,而在这个操作上找了 pk 个祖先一定会导致 minlen(link(last)) 减少至少 k1,且加入完 n 个字符后显然 minlen(link(last))<n,故而这个操作的总时间复杂度是 O(n) 的。


数据范围 3 对应的解法

某个子串的出现次数显然为其 endpos 集合大小。

考虑在新加入某个字符时对 endpos 造成的影响,其对目前的后缀出现次数 +1 应该如何统计。由于所有的终止节点对应的子串对应了所有的后缀,也就是出现次数加一的所有子串,故而只需要在每次插入字符之后,将 cur 及其在 link 树上的祖先对应的出现次数加一即可。但是这样做复杂度无法保证。

考虑把整条链上出现次数加一的形式改为树上差分的形式,则每次只需要对 cur 的出现次数差分加上一,最后 dfs 或拓扑一遍后缀链接树即可。

代码

注意 link 某些时候会重名()

点此查看代码
#include <bits/stdc++.h>
using namespace std;
const int maxn=2000010;
int p,q,i,l,r,ch,cl;
int lst=1,cur,tot=1;
int deg[maxn],que[maxn];
long long ans;
struct SamNode{
int lnk,len,siz;
int ver[26];
}N[maxn];
int main(){
while(isalpha(ch=getchar())){
ch-='a';cur=++tot;
p=lst;N[cur].siz=1;
N[cur].len=N[lst].len+1;
while(p){
if(q=N[p].ver[ch]) break;
N[p].ver[ch]=cur;
p=N[p].lnk;
}
if(!p) N[cur].lnk=1;
else{
if(N[q].len==N[p].len+1) N[cur].lnk=q;
else{
N[cl=++tot]=N[q];
N[cl].siz=0;
N[cl].len=N[p].len+1;
N[q].lnk=N[cur].lnk=cl;
while(N[p].ver[ch]==q){
N[p].ver[ch]=cl;
p=N[p].lnk;
}
}
}
lst=cur;
}
for(i=2;i<=tot;++i) ++deg[N[i].lnk];
for(i=1;i<=tot;++i) if(!deg[i]) que[++r]=i;
while(l!=r){
i=que[++l]; N[N[i].lnk].siz+=N[i].siz;
if(!(--deg[N[i].lnk])) que[++r]=N[i].lnk;
}
for(i=1;i<=tot;++i) if(N[i].siz>1) ans=max(1LL*N[i].siz*N[i].len,ans);
printf("%lld",ans);
return 0;
}

例题3 数据范围2:n107

对这个串建立 SAM。由于 SAM 上从起点出发的任意一条路径均对应了唯一的子串,所以每个点出发的路径条数就是有某个前缀的子串个数。在 SAM 的转移边组成的 DAG 上拓扑 dp 找到从每个点出发的路径条数。

考虑可重子串的计数。在求出每个节点对应的 endpos 之后,计算某个点开始的子串个数时,不能仅仅将这个点出发的路径条数相加。在 DAG 上从某个点向其前驱转移时,需要加上这个点的 endpos 大小(考虑这个前驱节点只加这一条转移边对应的字符的状态,此时需要取这个节点)。从根节点开始时不能减去对应大小(空串不计入排序)。

最后需要在 SAM 的 DAG 上 dfs 以按位确定答案的前缀。在 dfs 搜索到某个节点时,需要将 k 减去这个节点对应的 endpos(不可重计数则减 1),表示这个对应的前缀对答案造成的贡献,若此时 k 为负则已经求出答案。同时在节点上顺次访问转移边,将 k 减去目前扫描到的所有转移边对应的点的后缀数,直到 k 将在这一次操作时变为负,才不进行这次操作,而是确定答案当前位,并且访问这个子节点。

代码

点此查看代码
#include <bits/stdc++.h>
using namespace std;
const int maxn=10000010;
const int maxl=20000010;
char s[maxn];
int o,k,i,j,u,v,l=1,r,te;
int deg[maxl],que[maxl];
int h[maxl],nxt[maxn*3],ver[maxn*3];
int p,q,cl,ch;
int cur,lst=1,tot=1;
struct SamNode{
int lnk,len,siz;
int nxt[26];
long long str,sum;
}N[maxl];
#define lnk(p) N[p].lnk
#define len(p) N[p].len
#define siz(p) N[p].siz
#define nxt(p) N[p].nxt
#define str(p) N[p].str
#define sum(p) N[p].sum
int main(){
scanf("%s",s+1);
N[1].str=1;
for(i=1;s[i];++i){
ch=s[i]-'a';
cur=++tot;
p=lst;
siz(cur)=1;
len(cur)=i;
while(p){
if(nxt(p)[ch]) break;
nxt(p)[ch]=cur;
p=lnk(p);
}
if(!p) lnk(cur)=1;
else{
q=nxt(p)[ch];
if(len(q)==len(p)+1) lnk(cur)=q;
else{
N[cl=++tot]=N[q];
len(cl)=len(p)+1;
siz(cl)=0;
lnk(q)=lnk(cur)=cl;
while(p&&nxt(p)[ch]==q){
nxt(p)[ch]=cl;
p=lnk(p);
}
}
}
lst=cur;
}
scanf("%d%d",&o,&k);
if(!o) for(i=1;i<=tot;++i) siz(i)=1;
else{
for(i=2;i<=tot;++i) ++deg[lnk(i)];
for(i=2;i<=tot;++i) if(!deg[i]) que[++r]=i;
while(l<=r){
u=que[l++]; v=lnk(u);
siz(v)+=siz(u);
if(!(--deg[v])) que[++r]=v;
}
}
for(i=1;i<=tot;++i) str(i)=siz(i);
str(1)=siz(1)=r=0;l=1;
for(i=1;i<=tot;++i){
for(j=0;j<26;++j){
if(nxt(i)[j]){
++deg[i];
ver[++te]=i;
nxt[te]=h[nxt(i)[j]];
h[nxt(i)[j]]=te;
}
}
}
for(i=1;i<=tot;++i) if(!deg[i]) que[++r]=i;
while(l<=r){
u=que[l++];
for(i=h[u];i;i=nxt[i]){
v=ver[i]; str(v)+=str(u);
if(!(--deg[v])) que[++r]=v;
}
}
if(k>str(1)){
printf("-1");
return 0;
}
u=1;
for(;;){
if(k<=siz(u)) return 0;
k-=siz(u);
for(i=0;i<26;++i){
v=nxt(u)[i];
if(!v) continue;
if(str(v)<k) k-=str(v);
else{
putchar(i+'a');
u=v; break;
}
}
}
}

例题5

求多个串 S1,S2,,Sn 的本质不同子串个数。

数据范围1:|Si|106

可以把这些串用特殊的连接字符连接起来,然后求出其 height 数组,得出答案。方法可以参考例题 3 数据范围 1 的解法。

数据范围2:|Si|107

考虑线性时间复杂度的 SAM。我们需要对 SAM 的内容进行一些拓展以解决多个字符串的问题。


广义后缀自动机

定义

考虑“大部分可以用后缀自动机处理的字符串问题均可以拓展到 Trie 上”,可以先对于多个串建立 Trie 树,然后通过 Trie 树建立 SAM,以对多个字符串的信息进行整合。这种 SAM 又称广义 SAM。

有两种所谓伪广义 SAM:将所有模式串之间插入特殊连接字符的串的 SAM 和插完一个模式串后将 last 设为根节点的 SAM。通常伪广义后缀自动机的平均复杂度等同于广义后缀自动机的最差复杂度,面对大量的字符串时,伪广义后缀自动机的效率远不如标准的广义后缀自动机。

构造

注意广义 SAM 有在线和离线两种方法进行构造。

具体来讲,离线算法在建出的 Trie 树上 dfs/bfs,将 Trie 树上的节点插入到 SAM,此时以这个节点的父节点作为 last。因为在广义 SAM 中,某个字符串 s 的 endpos 的定义为 Trie 上的所有模式串中 s 的 endpos 的并集(每一个节点均可视为一个模式串)的集合。但是如果单纯说 Trie 上的 endpos 恐怕很难有人听得懂 广义 SAM 仍然满足普通 SAM 的大多数性质。不过由于 dfs 的时间复杂度为 O(iTriedepi),在只给出 Trie 的情况下会被卡成 O(|Trie|2),某种极端情况如下(摘自 这篇文章):

假设某个 Trie 形如:(其中最长串 aaab 的长度为 n

则通过 dfs 构造这个 Trie 的广义 SAM 的过程如下:

而 bfs 的时间复杂度最坏就是 O(|Si|),这也是伪广义 SAM 的平均时间复杂度。伪广义 SAM 的效率远不如广义 SAM。

在线做法:待补。(安利 这篇文章

时间复杂度证明

待补。


由于广义 SAM 满足普通 SAM 的性质,故所有串的本质不同的子串数量即为 len(i)len(link(i))

代码

点此查看代码
#include <bits/stdc++.h>
using namespace std;
const int maxn=10000010;
int n,i,j,t=1,l,r,u,v,p,q,c;
int trie[maxn][26],que[maxn];
char s[maxn];
long long ans;
struct SAM_Node{
int lnk,len;
int nxt[26];
}N[maxn<<1];
#define len(p) N[p].len
#define nxt(p) N[p].nxt
#define lnk(p) N[p].lnk
int main(){
scanf("%d",&n);
for(i=1;i<=n;++i){
scanf("%s",s+1);
u=1;
for(j=1;s[j];++j){
v=trie[u][s[j]-'a'];
if(!v) trie[u][s[j]-'a']=v=++t;
len(v)=j;u=v;
}
}
que[0]=1;
while(l<=r){
u=que[l++];
for(i=0;i<26;++i){
v=trie[u][i];
if(!v) continue; p=u;
while(p&&(!nxt(p)[i])){
nxt(p)[i]=v;
p=lnk(p);
}
if(!p) lnk(v)=1;
else{
q=nxt(p)[i];
if(len(q)==len(p)+1) lnk(v)=q;
else{
N[++t]=N[q];
len(t)=len(p)+1;
lnk(v)=lnk(q)=t;
while(p&&nxt(p)[i]==q){
nxt(p)[i]=t;
p=lnk(p);
}
}
}
que[++r]=v;
}
}
for(i=1;i<=t;++i) ans+=len(i)-len(lnk(i));
printf("%lld",ans);
return 0;
}

后缀树(Suffix Tree)

定义

后缀树同样是可用于字符串匹配的结构。在匹配字符串时,同 KMP 等算法对模式串的处理不同,后缀树可以直接处理文本串。

考虑在文本串后面加上一个特殊字符,以独立后缀与其他子串;然后将这些后缀插入 Trie 树上。由于每个后缀的每个前缀刚好对应了字符串的所有子串,故而 Trie 上可以查询文本串的所有子串。但是构建 Trie 的时间复杂度可达到 O(n2)(其中 n 为串长,下同)

后缀树可看成这个 Trie 上的特殊字符的转移边连向的所有节点组成的 虚树

例如:iakioi% (其中 % 为处理时加入的特殊字符)的原后缀 Trie 和后缀树分别为:

构造

u 的父节点为 fau,连向的转移边上的字符串为 S,则令 u 节点对应的最长子串 longest(u)longest(fau)+S,其对应的所有子串 f(u){longest(fau)+(S1S1),longest(fau)+(S1S2),,longest(fau)+S}

可以发现 f 的所有子串的出现 头位置相同。可以发现在后缀树上满足 endpos 在 SAM 上的几乎所有性质,例如令 u 节点对应的子串的出现头位置为 headpos(u),则显然 headpos(u)headpos(fau)。由于子串出现的头位置相同,就是它们的反串在原串的反串上的 endpos 相同。

故而可以构造原串反串的 SAM,SAM 的 link 树即为原串的后缀树。

应用

由于后缀树本质是所有后缀组成的 Trie 的虚树,故两个后缀的 LCP 就是它们在后缀树上的 LCA 对应的最长子串。同时 dfs 一遍后缀树即可得后缀数组。

posted @   Fran-Cen  阅读(126)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 分享一个免费、快速、无限量使用的满血 DeepSeek R1 模型,支持深度思考和联网搜索!
· 基于 Docker 搭建 FRP 内网穿透开源项目(很简单哒)
· ollama系列1:轻松3步本地部署deepseek,普通电脑可用
· 按钮权限的设计及实现
· 【杂谈】分布式事务——高大上的无用知识?
点击右上角即可分享
微信分享提示