字符串专题-学习笔记:后缀数组

一些 Update

Update 2022/2/8:修正了部分语言,不影响阅读与理解。

1. 概述

后缀数组(SA),是一种强而有力的字符串算法。其优秀的性质使得其能够代替大部分字符串算法,受到广大 OIer 的欢迎。

2. 子串

在讲后缀数组之前,我们先讲一讲子串这个概念。

一般情况下,我们认为子串就是一个字符串中的一部分连续字符组成的字符串,但是在运用后缀数组的时候子串更多时候被视为后缀的前缀

比如说一个串 s s s,子串 s l . . . r s_{l...r} sl...r 会被看作 s l . . . ∣ s ∣ s_{l...|s|} sl...s 的前缀,下文会用到这个点。

3. sa 数组和 Rank 数组

1. 定义

在后缀数组中,sa 数组和 Rank 数组是求 height 数组的基础。

s a [ ] sa[] sa[] s a i sa_i sai 表示的是排名为 i i i 的后缀为 s a i sa_i sai

R a n k [ ] Rank[] Rank[] R a n k i Rank_i Ranki 表示第 i i i 个后缀的排名为 R a n k i Rank_i Ranki

这里我们规定 s n . . . n , s n − 1... n , . . . , s 1... n s_{n...n},s_{n-1...n},...,s_{1...n} sn...n,sn1...n,...,s1...n 分别为第 1、2、……、 n n n 个后缀。

那么从上面我们可以看出来: R a n k [ s a [ i ] ] = s a [ R a n k [ i ] ] = i Rank[sa[i]]=sa[Rank[i]]=i Rank[sa[i]]=sa[Rank[i]]=i

因此我们只要求 1 个就好,由于 R a n k Rank Rank 数组好求,那么我们求 R a n k Rank Rank 数组。

2. 求 Rank 数组

R a n k Rank Rank 数组有 4 种方法:

  1. 暴力,时间复杂度 O ( n 2 log ⁡ n ) O(n^2 \log n) O(n2logn)
  2. 倍增算法+快速排序,时间复杂度 O ( n log ⁡ 2 n ) O(n \log^2 n) O(nlog2n)
  3. 倍增算法+基数排序,时间复杂度 O ( n log ⁡ n ) O(n \log n) O(nlogn)
  4. DC3 算法,时间复杂度 O ( n ) O(n) O(n)但常数较大,在 OI 中不比倍增算法优

本篇博文不对 DC3 算法展开阐述,有兴趣的读者可以自行查阅相关资料。

2.1 暴力

这还需要我说吗qwq,快排的时间复杂度为 O ( n log ⁡ n ) O(n \log n) O(nlogn),比较两个字符串的大小的平均时间复杂度是 O ( n ) O(n) O(n),结果就是 O ( n 2 log ⁡ n ) O(n^2 \log n) O(n2logn)

2.2 倍增算法

那么我们看看倍增算法如何操作。

比如当前字符串为 aabaaaab

第一步:我们取步长为 0,进行排序,如下。

在这里插入图片描述

第二步:我们取步长为 1 × 2 = 2 1 \times 2 = 2 1×2=2

相当于对于第 i i i 个后缀,我们取出前面 2 个字符然后排序。

此时,对于第 i i i 个后缀,取出的字符构成的集合为 s i , i + 1 s_{i,i+1} si,i+1

我们将其组合成一个二元组来排序,以第一个数字为第一关键字。

也就是说如果我们用一个结构体 q − > { a , b } q->\{a,b\} q>{a,b} 来存,那么第 i i i 个位置的二元组 q i = R a n k i , R a n k i + 1 q_i={Rank_i,Rank_{i+1}} qi=Ranki,Ranki+1,其中 R a n k Rank Rank 是上一轮的排名。

如果不存在 i + 1 i+1 i+1 则为 0。

那么经过此次排序之后如下:

在这里插入图片描述

第三次:我们取步长为 2 × 2 = 4 2 \times 2 = 4 2×2=4

那么对于第 i i i 个后缀,取出来比较的是 q i = { R a n k i , R a n k i + 2 } q_i=\{Rank_i,Rank_{i+2}\} qi={Ranki,Ranki+2}

为什么我们可以倍增呢?这也是倍增的精巧之处。

字符串的比较是按位比较,而在第一次排序中,我们规定字符串长度为 1,第二次规定为 2,此时在第三次比较中由于长度为 2 的字符串已经比较完了,因此我们可以将其取出整体比较而非单独比较,故而可以倍增。

排序之后如下:

在这里插入图片描述

第四次:取步长为 4 × 2 = 8 4 \times 2 = 8 4×2=8,此时我们需要取 q = { R a n k i , R a n k i + 4 } q=\{Rank_i,Rank_{i+4}\} q={Ranki,Ranki+4} 来组合。

结果如下。

在这里插入图片描述

那么接下来因为步长超过 n n n 了,结束, R a n k Rank Rank 数组处理完毕。

在正式写代码的时候我们可以预处理步长为 1 1 1 的情况,然后开始处理,同时代码里面 l e n = 1 len=1 len=1 表示步长为 2 × l e n = 2 2 \times len = 2 2×len=2,注意一下。

s a sa sa 数组?那不是根据 s a [ R a n k [ i ] ] = i sa[Rank[i]]=i sa[Rank[i]]=i 就可以解决了吗qwq

但是上面有一个遗留问题没有解决:我们怎么对二元组排序?

先分析一下时间复杂度:

假设排序时间复杂度不记,那么倍增 log ⁡ n \log n logn,处理二元组 n n n,总时间复杂度为 O ( n log ⁡ n ) O(n \log n) O(nlogn)

那么我们接下来考虑排序。

2.2.1 倍增+快排

这个是比较容易想到的思路,直接对二元组快排即可,复杂度 O ( n log ⁡ n ) O(n \log n) O(nlogn),这样最后的复杂度为 O ( n log ⁡ n + ( log ⁡ n ) × ( n log ⁡ n ) ) = O ( n log ⁡ 2 n ) O(n \log n + (\log n) \times (n \log n))=O(n \log^2 n) O(nlogn+(logn)×(nlogn))=O(nlog2n)

这样子会多一个 log,但是代码比基排要好写一点。

注意 luogu 的模板题用 cmp 函数快排会 TLE,需要重载运算符。

2.2.2 倍增+基排

对于这种位数小的二元组,我们可以使用基数排序来解决。

如果没有学过基排,您可以到 基数排序 - OI Wiki 上学习基排。

由于位数特别小,那么我们可以认为基排的时间复杂度近似于 O ( n ) O(n) O(n),那么总时间复杂度为 O ( n log ⁡ n ) O(n \log n) O(nlogn),但是代码会稍微长亿点。

3. 代码

倍增+快排:

#include <bits/stdc++.h>
using namespace std;

typedef long long LL;
const int MAXN = 1e6 + 10;
char s[MAXN];
int n, rk[MAXN << 1], sa[MAXN << 1];//这里空间开两倍可以减少后面的特判
struct node
{
	int a, b, id;
	bool operator <(const node &t)const
	{
		return (a ^ t.a) ? (a < t.a) : (b < t.b);
	}//重载运算符
}q[MAXN];

int read()
{
	int sum = 0, fh = 1; char ch = getchar();
	while (ch < '0' || ch > '9') {if (ch == '-') fh = -1; ch = getchar();}
	while (ch >= '0' && ch <= '9') {sum = (sum << 3) + (sum << 1) + (ch ^ 48); ch = getchar();}
	return sum * fh;
}

int main()
{
	scanf("%s", s + 1);//处理方便从 1 开始
	int n = strlen(s + 1);
	for (int i = 1; i <= n; ++i) rk[i] = s[i];//预处理步长为 1
	for (int len = 1; len < n; len <<= 1)
	{
		for (int i = 1; i <= n; ++i) {q[i].a = rk[i]; q[i].b = rk[i + len]; q[i].id = i;}//首先处理二元组
		sort(q + 1, q + n + 1);
		int m = 0;//排名
		for (int i = 1; i <= n; ++i)
		{
			if (q[i].a != q[i - 1].a || q[i].b != q[i - 1].b) ++m;
			rk[q[i].id] = m;//重新处理 Rank 数组
		}
	}
	for (int i = 1; i <= n; ++i) sa[rk[i]] = i;//得到 sa 数组
	for (int i = 1; i <= n; ++i) printf("%d ", sa[i]);
	printf("\n"); return 0;
}

倍增+基排:我没写

4. height 数组

1. 定义

h e i g h t i height_i heighti:表示排名为 i − 1 i - 1 i1 的后缀与排名为 i i i 的后缀的最长公共前缀(LCP)长度。

比如还是 aabaaaab 这个字符串,如下所示。

在这里插入图片描述

那么又要怎么求 h e i g h t height height 呢?

2. 求法

显然有一种朴素方法: O ( n 2 ) O(n^2) O(n2) 暴力匹配。

当然这样复杂度太高,需要优化。

这里再次引入一个数组: h h h 数组。

h i h_i hi:第 i i i 个后缀与排名在 i i i 前面的后缀的最长公共前缀长度。

还是看图。

在这里插入图片描述

h h h 数组有一个很重要的性质: h i ≥ h i − 1 − 1 , i ≥ 2 h_i \geq h_{i-1} - 1,i \geq 2 hihi11,i2

证明:(以下证明取自国家集训队2009年论文)

首先, i = 1 i=1 i=1 h 1 = 0 h_1=0 h1=0,因为其前面没有后缀。

那么对于 i ≥ 2 i \geq 2 i2,假设第 k k k 个后缀是排在 i − 1 i-1 i1 前一名的后缀,那么最长公共前缀长度为 h i − 1 h_{i-1} hi1。而第 k + 1 k+1 k+1 个后缀是排在 i i i 前面的,最长公共前缀长度为 h i − 1 − 1 h_{i-1}-1 hi11,所以排在 i i i 前面一名的后缀与 i i i 的最长公共前缀长度至少为 h i − 1 − 1 h_{i-1}-1 hi11

据此,我们重新回到 h e i g h t height height 数组。

于是有了上面的性质,我们在求 h e i g h t i height_i heighti 的时候,可以证明前 h i − 1 − 1 h_{i-1}-1 hi11 个字符一定是相同的,这样就可以大大优化时间复杂度。

代码:

int k = 0;//记录 h[i-1]
for (int i = 1; i <= n; ++i)
{
	if (k) --k;//先 -1
	if (rk[i] == 1) continue;//排名为 1 的后缀不计 height
	int j = sa[rk[i] - 1];//取出上一个字符串
	while (s[j + k] == s[i + k]) k++;//匹配
	height[rk[i]] = k;//记录
}

5. 总结

后缀数组(SA),巧妙使用倍增算法,充分利用字符串之间的性质,对后缀进行排序。

注意 SA 也可以是模拟退火的简称,别弄混。

一般情况下利用后缀数组求解字符串问题的时候我们都是利用 height 数组的,rank 数组和 sa 数组只是拿来求 height 的,不大会用到,然后一些 LCP 问题都是在 height 上做文章的,有些时候在一些约束条件下 height 会有规律(比如有序),这个时候就可以用一点 Trick 解题。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值