后缀数组学习笔记

后缀数组学习笔记

一、定义

对于下标从 1 开始,长度为 n 的字符串 s,我们定义后缀 i 表示字符串 s[i,n]

对于后缀数组,我们定义 sa(i) 表示所有后缀按字典序排序后第 i 小的后缀的编号。

例如对于字符串 aabaaab,它有 7 个后缀,下边我们列出排序后的结果:

  • aaab
  • aab
  • aabaaab
  • ab
  • abaaab
  • b
  • baaab

那么容易得到 sa(1)=4,sa(2)=5,。和常见求排名的数据结构一样,同样需要定义一个 rk(i) 表示每个后缀的排名。例如 rk(4)=1,rk(5)=2,

容易发现的是 sa(rk(i))=rk(sa(i))=i

二、后缀数组的求解

1. 暴力

直接预处理出 sn 个后缀再排序。排序的复杂度是 O(nlogn),字符串的比较是 O(n),于是总的复杂度是 O(n2logn)

2. 倍增

(1) 倍增求解后缀数组

考虑我们比较两个字符串大小的过程。考察对于两个长度为 2n 的字符串 s,ts>t 时的条件:

  • s[1,n]>t[1,n]
  • 或是 s[1,n]=t[1,n]s[n+1,2n]>t[n+1,2n]

那么我们将长度为 n 的字符串拆成了两个长度为 n2 的字符串来比较。这样将大问题减半拆解的做法显然可以倍增解决。

于是让我们描述这个做法的过程:

  1. 首先对每个字符排序,得到 sa1,rk1
  2. 套用上面的过程,用 rk1(i),rk1(i+1) 作为第一、二关键字排序,得到长度为 2 的子串的排名。
  3. 每次都这样做,用长度为 w2 的子串的排名得到长度为 w 的子串的排名。
  4. 考虑这样做的疏漏:对于 rkw2(i+w2),这个下标存在 >n 的情形,此时视为 0 即可。对于长度 <w 的后缀,并不存在子串 s[i,i+w1],此时将子串视为 s[i,n]
  5. wn 时,此时的 saw,rkw 就是所求。

对于复杂度的分析,倍增是 O(logn) 的,排序是 O(nlogn) 的,于是复杂度是 O(nlog2n)

(2) 基数排序优化

考虑复杂度的可优化部分:倍增是难以优化的,但是对于排序,我们可以浅浅利用一番其性质。注意到每次排序的是一个二元组,于是考虑进行基数排序优化。那么让我们插播一下基数排序的内容。

基数排序是建立在桶排序的基础之上的。本质上是一个根据优先级逐层确定顺序的过程。对于一些正整数的排序过程是这样的:

  1. 先按照个位的不同排序,放入桶中;
  2. 再按照十位的不同排序,但要注意放入桶中的顺序是个位排序后的顺序;
  3. 依次排完所有十进制位。

对于这样做的正确性,事实上体现着基数排序的核心就是多关键字的排序。对于复杂度分析,令 V 表示值域,m 表示关键字的个数,那么桶排序是复杂度是 O(n+V),那么总的复杂度就是 O(mn+mV)

那么对于后缀数组的排序,m=2,an,那么总的复杂度就是 O(n) 的。这样一来,总的复杂度就是 O(nlogn)

(3) 一些常数优化

  1. 第二关键字无需桶排序。原因是对于 i[nw+1,n],它们的值一定是 0,一定最小,直接扔到数组最前面即可。对于剩下的后缀,有 sa(i)>w,于是放入和它拼接而成的 sa(i)w 即可。
  2. 每次基数排序的值域设为 rk 的值域即可。
  3. rk 的值域已经为 n,显然可以直接跳出。

代码:

void SA(int M) {
	int m = M, p = 0;
	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 w = 1;; w <<= 1, m = p) {
		int cur = 0;
		for (int i = n - w + 1; i <= n; i++) id[++cur] = i;
		for (int i = 1; i <= n; i++)
			if (sa[i] > w) id[++cur] = sa[i] - w;
		for (int i = 1; i <= m; i++) cnt[i] = 0;
		for (int i = 1; i <= n; i++) cnt[rk[i]]++;
		for (int i = 1; i <= m; i++) cnt[i] += cnt[i - 1];
		for (int i = n; i >= 1; i--) sa[cnt[rk[id[i]]]--] = id[i];
		swap(lk, rk);
		p = 0;
		for (int i = 1; i <= n; i++) {
			if (lk[sa[i]] == lk[sa[i - 1]] && lk[sa[i] + w] == lk[sa[i - 1] + w]) rk[sa[i]] = p;
			else rk[sa[i]] = ++p;
		}
		if (p == n) break;
	}
}

其中 M 是初始的值域范围,由题意得到。

然而只会求后缀数组并没有什么用处,我们还需要知道一些奇妙的性质。

三、LCP 的一些性质

性质 1

LCP(sa(i),sa(k))=min(LCPsa(i),sa(j)),LCP(sa(j),sa(k)))(i<j<k)

我们记 p=min(LCP(sa(i),sa(j)),LCP(sa(j),sa(k)))。那么 sa(i)sa(k) 的前 p 位是相等的。于是 LCP(sa(i),sa(k))p

对于 sa(i)sa(k) 的第 p+1 位,我们记 sa(i),sa(j),sa(k) 的第 p+1 位为 w(i),w(j),w(k),分类讨论:

  • LCP(sa(i),sa(j))=LCP(sa(j),sa(k))=p,此时显然第 p+1 位的 sa(i),sa(j) 不同,且 w(i)<w(j)<w(k)
  • LCP(sa(i),sa(j))>LCP(sa(j),sa(k))=p,此时 w(i)=w(j)<w(k)
  • LCP(sa(j),sa(k))>LCP(sa(i),sa(j))=p,此时 w(i)<w(j)=w(k)

也就是无论如何,w(i)<w(k),于是 LCP(sa(i),sa(j))p,于是 LCP(sa(i),sa(j))=p

性质 2

LCP(sa(i),sa(j))=mink=i+1jLCP(sa(k1),sa(k))

这样一条性质由性质 1 是容易通过数学归纳法得到的。

性质 3

LCP(sa(j),sa(k))LCP(sa(i),sa(k))(ijk)

这个性质可以通过 min 的不升性结合性质 2 得到。

四、Height 数组

1. 定义

我们定义 LCP(i,j) 表示 s 中两个后缀 i,j 的最长公共前缀。

定义 Height(i)=LCP(sa(i),sa(i1)),规定 Height(1)=0。以下为了方便,用 h 代指 Height 数组。

2. 求法

引理:h(rk(i))h(rk(i1))1

考虑证明。若 h(rk(i1))1,原式显然成立,否则有 h(rk(i1))>1,那么考虑后缀 i,j,其中有 rk(j)+1=rk(i),那么当两个后缀的首字母相同时,显然删掉这个首字母两个新的后缀 j+1,i+1 的排名的先后顺序是不变的,有 rk(j+1)<rk(i+1),那么有 rk(j+1)rk(i+1)1。结合性质 3 可推出

h(rk(i+1))=LCP(sa(rk(i+1)),sa(rk(i+1)1))LCP(sa(rk(i+1)),sa(rk(j+1)))=LCP(i+1,j+1)

根据定义 h(rk(i))=LCP(sa(rk(i)),sa(rk(i)1))=LCP(i,j)=LCP(i+1,j+1)+1,那么移项后得到 h(rk(i+1))h(rk(i))1,证毕。

于是我们给出暴力求后缀数组的实现:

void H() {
	for (int i = 1, k = 0; i <= n; i++) {
		if (k) --k;
		while (s[i + k] == s[sa[rk[i] - 1] + k]) ++k;
		h[rk[i]] = k;
	}
}

五、后缀数组的应用

1. 求字符串中两子串最长公共前缀

根据上文的性质 2,则 LCP(sa(i),sa(j))=min{Height[i+1,,j}

于是这个问题转化为了 RMQ 问题,ST 表求解即可。

2. 求字符串中本质不同子串的数目

子串也就是后缀的前缀,于是枚举后缀,计算前缀的总数,再减掉重复的。总数其实就是子串的个数,也就是 n(n+1)2,重复的个数也就是 Height(i),于是总的答案是:

n(n+1)2i=2nHeight(i)

3. 出现至少 k 次的子串的最大长度

出现至少 k 次的子串等价于有至少 k 个后缀以这个子串作为公共前缀,且这些后缀的排名应该是连续的。于是求出每相邻 k1Height 的最小值取 max 即可。这个过程可以用 ST 表维护。

4. 最长公共子串

后缀数组一个常见的套路就是把一堆子串拼在一起。于是我们将所有的子串拼在一起就变成了问题 3。但需要留意的是,这样做有两个问题:

  1. 一些个字符串间互相连接形成干扰。于是在每两个字符串中间加上一个分割符号即可。注意最后一个字符串末尾不加这个符号。
  2. 出现了 k 次并不完全等价于是最长公共子串,需要判定是否在 k 个字符串中都出现过。具体的实现上可以双指针去做,过程中动态开桶维护出现的次数,r 显然是单调不降的,于是 ST 表维护即可。

5. 最长回文子串

套路的做法是将字符串反转,在中间插入一个字符后加入反转后的字符串。这样一来转化为了 LCP 问题,于是求出 Height 数组的最大值即可。

需要注意的是这样做需要保证后缀之间没有干扰,因此需要插入一个大于最大值的字符。

posted @   长安19路  阅读(10)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· 单线程的Redis速度为什么快?
· SQL Server 2025 AI相关能力初探
· AI编程工具终极对决:字节Trae VS Cursor,谁才是开发者新宠?
· 展开说说关于C#中ORM框架的用法!
点击右上角即可分享
微信分享提示