Caiwen的博客

后缀数组

2025-02-05 03:35:55

一些定义

字符串

  • s|s| 为字符串 ss 的长度

  • sis_{i} 为字符串 ss 的中第 ii 个字符,1istr1\le i \le |str|

  • s[l:r]=slsl+1...sr1srs[l:r]=s_ls_{l+1}...s_{r-1}s_r,即 ss 区间 [l,r][l,r] 的子串

后缀数组

我们研究字符串 ss

  • suffisuff_i 为字符串 ss 的第 ii 个后缀,即 s[i:s]s[i:|s|]

  • 把所有的后缀按字典序排序,rkirk_i 表示 suffisuff_i 的排名,saisa_i 就表示排名为 ii 的后缀是字符串 ss 的第几个后缀

  • sarki=isa_{rk_i}=irksai=irk_{sa_i}=i

例如,对于 s=vamamadns=vamamadn

ii suffisuff_i
1 vamamadn
2 amamadn
3 mamadn
4 amadn
5 madn
6 adn
7 dn
8 n
排名 saisa_i 对应字符串
1 6 adn
2 4 amadn
3 2 amamadn
4 7 dn
5 5 madn
6 3 mamadn
7 8 n
8 1 vamamadn

LCP

LCP:我们定义 LCP(i,j)LCP(i,j) 为,排名为 ii 和排名为 jj 的字符串的最长公共前缀,即 suffsaisuff_{sa_i}suffsajsuff_{sa_j} 的最长公共前缀

显然的两条性质

  • LCP(i,j)=LCP(j,i)LCP(i,j)=LCP(j,i)
  • LCP(i,i)=suffsai=ssai+1LCP(i,i)=|suff_{sa_i}|=|s|-sa_i+1

LCP 引理

LCP(i,j)=min(LCP(i,k),LCP(k,j))  (1ikjs)LCP(i,j)=min(LCP(i,k),LCP(k,j))\space\space(1\le i \le k \le j \le |s|)

证明:

p=min(LCP(i,k),LCP(k,j))p=min(LCP(i,k),LCP(k,j)),则有 LCP(i,k)pLCP(i,k)\ge pLCP(k,j)pLCP(k,j)\le p

suffsai=I,suffsaj=J,suffsaK=Ksuff_{sa_i}=I, suff_{sa_j}=J, suff_{sa_K}=K

那么就说明 IIKKpp 个字符相等,KKJJpp 个字符相等,所以 IIJJpp 个字符相等,即 LCP(i,j)pLCP(i,j)\ge p

然后下面用反证法,假设 LCP(i,j)>pLCP(i,j)>p,那么就有 Ip+1=Jp+1I_{p+1}=J_{p+1}

但是,min(LCP(i,k),LCP(k,j))=pmin(LCP(i,k),LCP(k,j))=p,那么就意味要么有 Ip+1Kp+1I_{p+1} \neq K_{p+1} 要么有 Kp+1Jp+1K_{p+1}\neq J_{p+1},所以不可能会有 Ip+1=Jp+1I_{p+1}=J_{p+1},假设不成立。

因此 LCP(i,j)=p=min(LCP(i,k),LCP(k,j))LCP(i,j)=p=min(LCP(i,k),LCP(k,j))

LCP定理

LCP(i,j)=MINk=i+1j(LCP(k,k1))LCP(i,j)=MIN_{k=i+1}^{j}(LCP(k,k-1))

证明:

我们把 iijj 拆成 iii+1i+1i+1i+1jj,应用 LCP引理,则有 LCP(i,j)=min(LCP(i,i+1),LCP(i+1,j))LCP(i,j)=min(LCP(i,i+1),LCP(i+1,j))

我们把 i+1i+1jj 再拆,这么递归拆下去,就证明了上式

hight引理

highti=LCP(i,i1)hight_i=LCP(i,i-1)2in2\le i \le n。特别地,定义 hight1=0hight_1=0hightihight_i 可以看作排名为 ii 的后缀与排名为 i1i-1 的后缀的最长公共前缀长度

hi=heightrkih_i=height_{rk_i}hih_i 可以看作第 ii 个后缀与和他排名紧挨着靠前的那个后缀的最长公共前缀长度

然后有

hihi11h_i\ge h_{i-1}-1

证明:

如果第 i1i-1 个后缀与排名为 rki11rk_{i-1}-1 的后缀之间没有公共前缀,那么 hi1=0h_{i-1}=0,而 hih_i 必然为非负数,那么上式成立

反之,我们假设排名为 rki11rk_{i-1}-1 的后缀是第 kk 个后缀。那么第 ii 个后缀是第 i1i-1 个后缀去掉首字母得到的,第 k+1k+1 个后缀是第 kk 个后缀去掉首字母得到的。且由于第 kk 个后缀的排名比第 i1i-1 个后缀的排名靠前,则第 k+1k+1 个后缀的排名也应该比第 ii 个后缀的排名靠前,并且两者的最长公共前缀,即 LCP(rki,rkk+1)=hi11LCP(rk_i,rk_{k+1})=h_{i-1}-1

然后我们考虑,两个排名紧挨着的后缀的最长公共前缀应该是最大的。第 k+1k+1 个后缀的排名不一定紧挨着第 ii 个后缀的排名,排名可能要更靠前一点。那么此时我们的 hih_i 就表示的是和第 ii 个后缀和紧挨着第 ii 个后缀的后缀的 LCP,必然有 hiLCP(rki,rkk+1)=hi11h_i\ge LCP(rk_i,rk_{k+1})=h_{i-1}-1,证毕

求LCP

由LCP定理可知 LCP(i,j)=min(heightk)LCP(i,j)=min(height_k)i+1kji+1\le k\le j

而由 hih_i 的定义,我们可以得到 heighti=hsaiheight_i=h_{sa_i}

然后我们可以借助 heightheight 引理,先求出 hh ,来求出 heightheight

具体代码见题目部分

题目

P3809 【模板】后缀排序

https://www.luogu.com.cn/problem/P4051

求后缀数组的板子题

cpp
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
#include<bits/stdc++.h> #define int long long #define ull unsigned long long #define ls(k) (k)<<1 #define rs(k) (k)<<1|1 #define debug(x) cout<<#x<<"="<<x<<endl using namespace std; const int inf=0x3f3f3f3f3f3f3f3f; const int mod=0; typedef pair<int,int> pii; #define _ 1000006 char s[_]; int n,cnt[_],m,rk[_<<1],sa[_],nd[_],oldrk[_<<1]; inline void SA(){ m=300;//设定字符值域 for(int i=1;i<=n;i++) ++cnt[rk[i]=s[i]]; //先来一波基数排序 for(int i=1;i<=m;i++) cnt[i]+=cnt[i-1]; for(int i=n;i>=1;i--) sa[cnt[rk[i]]--]=i; for(int p=0,k=1;k<n;k<<=1,p=0){ for(int i=n;i>=n-k+1;i--) nd[++p]=i;//后半截空串,第二关键字肯定排名考前 for(int i=1;i<=n;i++) if(sa[i]>k) nd[++p]=sa[i]-k;//通过上回的整体排名,推本次的后半截排名 memset(cnt,0,sizeof(cnt)); for(int i=1;i<=n;i++) ++cnt[rk[nd[i]]];//再根据第一关键字排名,第一关键字就是上次的整体排名 for(int i=1;i<=m;i++) cnt[i]+=cnt[i-1]; for(int i=n;i>=1;i--) sa[cnt[rk[nd[i]]]--]=nd[i]; swap(rk,oldrk);m=0;//更新排名 for(int i=1;i<=n;i++) rk[sa[i]]=(m+=((oldrk[sa[i]]==oldrk[sa[i-1]] && oldrk[sa[i]+k]==oldrk[sa[i-1]+k])?0:1)); } } inline void subtask(){ cin>>(s+1);n=strlen(s+1); SA(); for(int i=1;i<=n;i++) cout<<sa[i]<<' '; } signed main(){ ios::sync_with_stdio(false); int t=1;//cin>>t; while(t--) subtask(); return 0; }

P4051 [JSOI2007] 字符加密

https://www.luogu.com.cn/problem/P4051

题目描述

JS 同学把需要加密的信息排成一圈,显然,它们有很多种不同的读法。

例如‘JSOI07’,可以读作: JSOI07 SOI07J OI07JS I07JSO 07JSOI 7JSOI0 把它们按照字符串的大小排序: 07JSOI 7JSOI0 I07JSO JSOI07 OI07JS SOI07J 读出最后一列字符:I0O7SJ,就是加密后的字符串。你能写一个程序完成这个任务吗?

数据范围

1n1051\le n \le 10^5

笔记

断环成链,即把原串复制一遍,然后跑 SA。取 SA 中编号小于等于 nn 的后缀。

cpp
1
2
3
4
5
6
7
8
9
10
inline void subtask(){ cin>>s+1,n=strlen(s+1); for(int i=1;i<=n;i++) s[i+n]=s[i]; n<<=1; SA(); for(int i=1;i<=n;i++){ if(sa[i]>(n>>1)) continue; cout<<s[sa[i]+(n>>1)-1]; } }

SUBST1 - New Distinct Substrings

https://www.luogu.com.cn/problem/SP705

题目数据

TT 组数据,每次给定一个字符串 ss,求该字符串本质不同的子串数量。

两个子串本质不同,当且仅当两个子串长度不等,或长度相等但有任意一位不同。

数据范围

1T201\le T \le 201s500001\le |s|\le 50000

笔记

我们发现如果存在两个本质相同的子串,那么这两个子串一定是某两个后缀的公共前缀,而且这两个后缀的排名接近

于是我们考虑按排名的顺序逐个添加后缀。考虑添加后缀 saisa_i 后对本质不同子串数量的影响。增加的子串数量即为后缀 saisa_i 的长度,其中有 LCP(i,i1)LCP(i,i-1) 个子串是已经在添加后缀 sai1sa_{i-1} 的时候已经算进去了,需要减掉。

于是答案即为:

n(n+1)2i=1nheighti\frac{n(n+1)}{2}-\sum_{i=1}^n height_i

求 height 数组的模板

cpp
1
2
3
4
5
6
7
8
9
10
void LCP(){ int k=0;//表示h数组 for(int i=1;i<=n;i++){ if(rk[i]==1) continue;//hight[1]=0; if(k) k--;//h[i]>=h[i-1]-1 int j=sa[rk[i]-1];//排名紧靠前的后缀 while(i+k<=n&&j+k<=n&&s[i+k]==s[j+k]) k++; height[rk[i]]=k;//h[i]=height[rk[i]] } }
cpp
1
2
3
4
5
6
7
inline void subtask(){ cin>>s+1,n=strlen(s+1); SA();LCP(); int sum=0; for(int i=1;i<=n;i++) sum+=height[i]; cout<<n*(n+1)/2-sum<<endl; }

LCS - Longest Common Substring

https://www.luogu.com.cn/problem/SP1811

题目描述

给定两个字符串,求最长公共子串长度,长度不超过 2.5×1052.5\times 10^5

笔记

把两个字符串首尾拼接起来,中间加一个分隔符,于是答案即为

max1iS1<jS1+S2LCP(rki,rkj)\max_{1\le i \le |S_1|<j\le |S_1+S_2|} LCP(rk_i,rk_j)

因为我们加了个特殊分割符,所以正确性是能保证的

不过直接暴力枚举 iijj 的话时间复杂度还是太大了。实际上我们考虑,排名紧挨着的字符串的 LCP 是最大的,所以我们直接找后缀起始点分处于两个字符串的 height 的值的最大值即可

不过可能还有一个问题:有可能排名为 iii1i-1 的后缀的起始点在同一字符串,而排名 iii2i-2 的后缀不在同一字符串,且这两个后缀的 LCP 也不小,那么我们不就忽略掉了

我们可以这么考虑,两个字符串的公共部分起始点构成的后缀,排名要么紧挨着,于是上述算法成立。排名也可能不紧挨着,如果不紧挨着的话,排名位于中间的后缀也包含这个公共部分。这些后缀中必然会有起始点来自不同的字符串的,于是这两个后缀的 LCP 应该是另一个 heightheight,我们上述做法也没问题

cpp
1
2
3
4
5
6
7
8
9
10
11
12
13
inline void subtask(){ cin>>s+1,n=strlen(s+1);s[n+1]='$'; int n1=n; cin>>s+n+2,n=strlen(s+1); SA();LCP(); int ans=-inf; for(int i=1;i<=n;i++){ if(rk[i]==1) continue; int j=sa[rk[i]-1]; if((i<=n1&&j>=n1+2)||(i>=n1+2&&j<=n1)) ans=max(ans,height[rk[i]]); } cout<<ans; }

P4248 [AHOI2013] 差异

https://www.luogu.com.cn/problem/P4248

题目描述

给定一个长度为 nn 的字符串 SS,令 TiT_i 表示它从第 ii 个字符开始的后缀。求

1i<jnlen(Ti)+len(Tj)2×lcp(Ti,Tj)\displaystyle \sum_{1\leqslant i<j\leqslant n}\operatorname{len}(T_i)+\operatorname{len}(T_j)-2\times\operatorname{lcp}(T_i,T_j)

其中,len(a)\text{len}(a) 表示字符串 aa 的长度,lcp(a,b)\text{lcp}(a,b) 表示字符串 aa 和字符串 bb 的最长公共前缀。

数据范围

对于 100%100\% 的数据,保证 2n5000002\le n\le 500000,且 SS 中均为小写字母。

笔记

结合公式 i=1ni2=n(n+1)(2n+1)2\sum_{i=1}^n i^2=\frac{n(n+1)(2n+1)}{2}上述式子可以变为

n(n1)(n+1)221i<jnLCP(i,j)\frac{n(n-1)(n+1)}{2} - 2\sum_{1\le i<j\le n} LCP(i,j)

后面那部分我们可以考虑使用线段树来做。具体来说就是维护求和以及取 min 操作

首先一个想法是可以使用吉司机线段树来做。不过我们考虑 heightheight 数组的值域不大,也可以使用权值线段树,对 xx 取 min 的时候就是先计算有几个值比 xx 大,然后把这些值的数量归到 xx 的数量

线段树还是太吃操作了。我们进一步考虑,后面那部分也可以视为 heightheight 数组的所有子区间的最小值加和

这是经典问题,可以使用单调栈来做

cpp
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
int sta[_],top,l[_],r[_]; inline void subtask(){ cin>>s+1;n=strlen(s+1); SA(); //for(int i=1;i<=n;i++) debug(height[i]); sta[top=1]=1; for(int i=2;i<=n;i++){ while(top&&height[sta[top]]>height[i]){ r[sta[top]]=i; top--; } l[i]=sta[top]; sta[++top]=i; } while(top) r[sta[top--]]=n+1; //for(int i=1;i<=n;i++) debug(l[i]),debug(r[i]); int ans=n*(n+1)/2*(n-1); for(int i=2;i<=n;i++) ans-=2*(i-l[i])*(r[i]-i)*height[i]; cout<<ans; }

P3181 [HAOI2016] 找相同字符

https://www.luogu.com.cn/problem/P3181

题目描述

给定两个字符串,求出在两个字符串中各取出一个子串使得这两个子串相同的方案数。两个方案不同当且仅当这两个子串中有一个位置不同。

数据范围

1n1,n22×1051\le n_1,n_2\le 2\times 10^5,字符串中只有小写字母。

笔记

把两个字符串首尾相接,然后就转为求所有后缀起点位于不同字符串的 LCP 之和

这个和上一题类似,不过这题对后缀有限制要求了

我们可以考虑容斥,先把拼接后的整个的字符串的所有后缀组合 LCP 求出来,然后再减去后缀起点位于同一字符串的所有组合的 LCPLCP ,即得到答案了

cpp
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
#include<bits/stdc++.h> #define int long long #define ull unsigned long long #define ls(k) (k)<<1 #define rs(k) (k)<<1|1 #define debug(x) cout<<#x<<"="<<x<<endl using namespace std; const int inf=0x3f3f3f3f3f3f3f3f; const int mod=0; typedef pair<int,int> pii; #define _ 400005 char s1[_],s2[_],s[_]; int n1,n2,n,m,cnt[_],nd[_],sa[_],rk[_<<1],oldrk[_<<1],height[_],l[_],r[_],sta[_],top; int SA(){ m=300; memset(cnt,0,sizeof(cnt)); for(int i=1;i<=n;i++) ++cnt[rk[i]=s[i]]; for(int i=1;i<=m;i++) cnt[i]+=cnt[i-1]; for(int i=n;i>=1;i--) sa[cnt[rk[i]]--]=i; for(int p=0,k=1;k<n;k<<=1,p=0){ for(int i=n;i>=n-k+1;i--) nd[++p]=i; for(int i=1;i<=n;i++) if(sa[i]>k) nd[++p]=sa[i]-k; memset(cnt,0,sizeof(cnt)); for(int i=1;i<=n;i++) ++cnt[rk[nd[i]]]; for(int i=1;i<=m;i++) cnt[i]+=cnt[i-1]; for(int i=n;i>=1;i--) sa[cnt[rk[nd[i]]]--]=nd[i]; swap(rk,oldrk);m=0; for(int i=1;i<=n;i++) rk[sa[i]]=(m+=((oldrk[sa[i]]==oldrk[sa[i-1]] && oldrk[sa[i]+k]==oldrk[sa[i-1]+k])?0:1)); } int k=0; for(int i=1;i<=n;i++){ if(rk[i]==1) continue; if(k) k--; int j=sa[rk[i]-1]; while(i+k<=n&&j+k<=n&&s[i+k]==s[j+k]) k++; height[rk[i]]=k; } sta[top=1]=1; for(int i=2;i<=n;i++){ while(top&&height[sta[top]]>height[i]) r[sta[top--]]=i; l[i]=sta[top]; sta[++top]=i; } while(top) r[sta[top--]]=n+1; int res=0; for(int i=2;i<=n;i++) res+=(i-l[i])*(r[i]-i)*height[i]; return res; } inline void subtask(){ cin>>s1+1;n1=strlen(s1+1); cin>>s2+1;n2=strlen(s2+1); int ans=0; for(int i=1;i<=n1;i++) s[i]=s1[i]; s[n1+1]='$'; for(int i=1;i<=n2;i++) s[i+n1+1]=s2[i]; n=n1+n2+1; ans+=SA(); for(int i=1;i<=n1;i++) s[i]=s1[i]; n=n1; ans-=SA(); for(int i=1;i<=n2;i++) s[i]=s2[i]; n=n2; ans-=SA(); cout<<ans; } signed main(){ ios::sync_with_stdio(false); int t=1;//cin>>t; while(t--) subtask(); return 0; }
最后更新于:2025-04-03 08:04:10

Caiwen
本文作者
一只蒟蒻,爱好编程和算法

推荐文章