后缀数组

1 概念

首先我们需要先定义后缀,这个其实很简单。我们定义后缀 \(i\) 表示以第 \(i\) 个字符开头的后缀,相当于 \(s[i,n]\)

而后缀数组则主要关系到两个数组:\(sa\)\(rk\)。其中 \(sa\) 表示将所有后缀按字典序排序后第 \(i\) 小的后缀的编号

(即后缀开头所在位置的下标),这就是后缀数组。而另一个 \(rk\) 则是一个辅助数组,表示后缀 \(i\) 的排名。

例如一个字符串 \(s=\) aabaaab,对后缀排序后结果应该如下:

  1. \(s[4,7]\),即 aaab
  2. \(s[5,7]\),即 aab
  3. \(s[1,7]\),即 aabaaab
  4. \(s[6,7]\),即 ab
  5. \(s[2,7]\),即 abaaab
  6. \(s[7,7]\),即 b
  7. \(s[3,7]\),即 baaab

所以两个数组的值对应如下:

\(i\) \(1\) \(2\) \(3\) \(4\) \(5\) \(6\) \(7\)
\(sa[i]\) \(4\) \(5\) \(1\) \(6\) \(2\) \(7\) \(3\)
\(rk[i]\) \(3\) \(5\) \(7\) \(1\) \(2\) \(4\) \(6\)

容易发现,我们会有如下性质:\(sa[rk[i]]=rk[sa[i]]=i\)

2 求法

2.1 暴力法

根据上面的定义自然会想到这样一种做法:将所有后缀直接拎出来然后暴力排序。由于排序复杂度 \(O(n\log n)\),而字符串比较是 \(O(n)\),所以复杂度为 \(O(n^2\log n)\)。太劣。

2.2 倍增法

我们先来看这样一个问题:对于两个长度为 \(2n\) 的字符串 \(s,t\)。如果 \(s>t\) 当且仅当:

  1. \(s[1,n]>t[1,n]\)
  2. \(s[1,n]=t[1,n]\)\(s[n+1,2n]>t[n+1,2n]\)

也就是说,对于长度为 \(2n\) 的字符串,如果我们已知所有长度为 \(n\) 的子串的排名,那么将前 \(n\) 位的排名看作第一关键字,后 \(n\) 位的排名看成第二关键字,直接排序就可以得到长度为 \(2n\) 的子串的排名。

这就给我们带来了启发,我们是不是也可以用这种方式来对后缀进行排序呢?事实上是可以的,考虑上述做法描述的就是一个倍增的模型,因此后缀数组的第二个求法就是倍增法。

具体求法如下:

  1. 先对每个字符进行排序,得到此时的 \(sa_1\)\(rk_1\) 数组。
  2. 接下来用 \(rk_1[i]\)\(rk_1[i+1]\) 作为排序的第一和第二关键字进行排序,就可以得到长度为二的子串的排名,得到 \(sa_2\)\(rk_2\) 数组。
  3. 按照这种方式,用长度为 \(w/2\) 的子串排名去倍增得到长度为 \(w\) 的子串排名。但是此时会有两个问题:我们在调用 \(rk_{w/2}[i+w/2]\) 时,\(i+w/2\) 可能大于 \(n\),此时规定 \(rk_{w/2}[i+w/2]\) 的值是 \(0\);同时,由于后缀 \(i\) 的长度可能本身就没有 \(w\),那么子串 \(s[i,i+w-1]\) 就不存在。此时我们就将子串看作是 \(s[i,n]\) 即可。也就是每次倍增求出的其实是子串 \(s[i,\min(i+w-1,n)]\) 的排名。

这样重复倍增直到 \(w\ge n\) 时,得到的 \(sa_w\)\(rk_w\) 就是我们需要的数组。

倍增的复杂度为 \(O(\log n)\),排序复杂度 \(O(n\log n)\),所以复杂度为 \(O(n\log^2 n)\)

发现此时复杂度瓶颈在于排序。巧就巧在,对于二元组的排序还确实有更优的排序方式。

2.3 基数排序优化

2.3.1 基数排序

由于常用的排序无非就是快排,所以在此不得不学习一下基数排序。

基数排序是桶排序的一种,它是针对于多维的排序,对于每一维使用桶排然后从低到高求解。

例如对于 \(11,32,39,50,103,9\) 进行排序。我们可以将个位看作第一维,十位看作第二维,百位看作第三维(也就是维度从低到高代表重要度从低到高)。排序过程如下:

首先对个位进行桶排:

\(0\) \(1\) \(2\) \(3\) \(4\) \(5\) \(6\) \(7\) \(8\) \(9\)
\(50\) \(11\) \(32\) \(103\) / / / / / \(39,9\)

然后按照十位数排序,注意如果同一个桶里面有几个数,按照上面的顺序放入:

\(0\) \(1\) \(2\) \(3\) \(4\) \(5\) \(6\) \(7\) \(8\) \(9\)
\(103,9\) \(11\) / \(32,39\) / \(50\) / / / /

最后按百位排序:

\(0\) \(1\) \(2\) \(3\) \(4\) \(5\) \(6\) \(7\) \(8\) \(9\)
\(9,11,32,39,50\) \(103\) / / / / / / / /

最后按顺序取出即可得到排序结果。

我们说明一下正确性:当我们排到第 \(k\) 维时,比 \(k\) 低的维都应该排好。如果第 \(k\) 维的值相同,那么比的就是 \(k\) 维以下的大小。所以在放入桶的时候要按照之前的顺序放入;而如果第 \(k\) 维的值不同,那么自然第 \(k\)​ 维更大的就更大。

根据上面的过程我们知道,对于一次桶排,时间复杂度为 \(O(n+a)\),其中 \(a\) 维一次桶排的值域。而如果要进行 \(m\) 个维度的排序,复杂度就是 \(O(nm+\sum a)\)

基数排序的铺垫就说完了,下面回到后缀数组的求解。

2.3.2 基数排序优化

我们上面提到过,对于 \(sa_k\)\(rk_k\) 的排序是一个二维的排序,那么自然可以用基数排序。考虑此时的基数排序在时间复杂度中会有 \(m=2,a=n\),所以此时的排序复杂度就降为 \(O(n)\)。总时间复杂度就是 \(O(n\log n)\)

代码如下:

#include <bits/stdc++.h>

using namespace std;

const int Maxn = 1e6 + 5;
const int Inf = 2e9;

string s;
int n, m, sa[Maxn], lsa[Maxn], rk[Maxn << 1], lrk[Maxn << 1], cnt[Maxn];
//rk 开双倍数组是为了让超出 n 的 i+w 的值为 0
//lsa 和 lrk 分别是拷贝的 sa 和 rk 

int main() {
	ios::sync_with_stdio(0);
	cin.tie(0), cout.tie(0);
	cin >> s;
	n = s.size();
	m = 128;//m 一开始要设成 z 的 ASCII 码左右 
	s = ' ' + s;
	for(int i = 1; i <= n; i++) cnt[rk[i] = s[i]]++;
	//这里直接将排名设为字符,因此值域是 z 的 ASCII 码 
	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 k = 1; k < n; k <<= 1, m = n/*注意第一次循环时 m 仍为 128*/) {
        //求解长度为 2k 的字符串
		//对于两个关键字进行基数排序
        //对于 rk[sa[i]+k] 进行桶排序(第二关键字) 
		memset(cnt, 0, sizeof cnt);
		for(int i = 1; i <= n; i++) lsa[i] = sa[i];//由于 sa 要更新,所以拷贝上一次的值 
		for(int i = 1; i <= n; i++) cnt[rk[lsa[i] + k]]++;
		for(int i = 1; i <= m; i++) cnt[i] += cnt[i - 1];
		for(int i = n; i >= 1; i--) sa[cnt[rk[lsa[i] + k]]--] = lsa[i];
		//对于 rk[sa[i]] 进行桶排序(第一关键字) 
		memset(cnt, 0, sizeof cnt);
		for(int i = 1; i <= n; i++) lsa[i] = sa[i];
		for(int i = 1; i <= n; i++) cnt[rk[lsa[i]]]++;
		for(int i = 1; i <= m; i++) cnt[i] += cnt[i - 1];
		for(int i = n; i >= 1; i--) sa[cnt[rk[lsa[i]]]--] = lsa[i];
		//此时得出的 sa 就是长度为 k 的子串的对应数组了 
		for(int i = 1; i <= n; i++) lrk[i] = rk[i];//由于 rk 要更新,所以拷贝上一次的值 
		for(int p = 0, i = 1; i <= n; i++) {
			if(lrk[sa[i]] == lrk[sa[i - 1]] && lrk[sa[i] + k] == lrk[sa[i - 1] + k]) {
				//如果排序之后两个关键字都相同,说明两者的排名也应该相同 
				rk[sa[i]] = p;
			}
			else {
				rk[sa[i]] = ++p;//否则就不相同 
			}
		}
	}
	for(int i = 1; i <= n; i++) {
		cout << sa[i] << ' ';//直接输出最后结果即可 
	} 
	return 0;
}

2.4 常数优化

尽管上面代码理论复杂度为 \(O(n\log n)\),但是它的常数其实很大,有可能会被卡掉。我们有如下常数优化:

  1. 第二关键字无需桶排序

我们思考一下对于第二关键字排序的实质。在上文的代码中,rk[lsa[i]+k] 的值可能为 \(0\)。而如果为 \(0\),则它必定是最小的。因此我们根本不用看它们,直接放到最开头即可。

接下来我们遍历排名从低到高,如果当前的 \(sa[i]>k\),则说明它才能与之前的字符串拼成长为 \(2k\) 的字符串。由于我们本身就是按照排名遍历,所以此时直接插入 \(sa[i]-k\) 就是按照排名排好序的。

  1. 值域限制

注意到上文我们每次都会计算出一个 \(p\) 值,而这个 \(p\) 值就是 \(rk\) 的值域,也就是基数排序的值域。

  1. 判断是否生成后缀数组

考虑每次算出的 \(rk\) 数组,若值域已经为 \([1,n]\) 就说明每个排名都不同,此时直接跳出即可。

最终的代码如下:

#include <bits/stdc++.h>

using namespace std;

const int Maxn = 1e6 + 5;
const int Inf = 2e9;

string s;
int n, m, sa[Maxn], lsa[Maxn], rk[Maxn], lrk[Maxn << 1], cnt[Maxn];

int main() {
	ios::sync_with_stdio(0);
	cin.tie(0), cout.tie(0);
	cin >> s;
	n = s.size();
	m = 128;
	s = ' ' + s;
	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;
	int p = 0;
	for(int k = 1; k < n; k <<= 1, m = p/*值域优化*/) { 
		int cur = 0;
		for(int i = n - k + 1; i <= n; i++) lsa[++cur] = i;//没有 i+k 的直接塞到前面 
		for(int i = 1; i <= n; i++) {
			if(sa[i] > k) {//能够与前面的拼成子串 
				lsa[++cur] = sa[i] - k;
			}
		}
		memset(cnt, 0, sizeof cnt);
		for(int i = 1; i <= n; i++) cnt[rk[lsa[i]]]++;
		for(int i = 1; i <= m; i++) cnt[i] += cnt[i - 1];
		for(int i = n; i >= 1; i--) sa[cnt[rk[lsa[i]]]--] = lsa[i];
		for(int i = 1; i <= n; i++) lrk[i] = rk[i];
		p = 0;
		for(int i = 1; i <= n; i++) {
			if(lrk[sa[i]] == lrk[sa[i - 1]] && lrk[sa[i] + k] == lrk[sa[i - 1] + k]) {
				rk[sa[i]] = p;
			}
			else {
				rk[sa[i]] = ++p; 
			}
		}
		if(p == n) break;//直接跳出即可 
	}
	for(int i = 1; i <= n; i++) {
		cout << sa[i] << ' ';
	} 
	return 0;
}

3 应用

似乎后缀数组都和后缀有关,那么有关前缀的内容怎么办呢?我们还需要一个神奇的东西。

3.1 height 数组

定义两个字符串的最长公共前缀(LCP)为满足 \(s[1,x]=t[1,x]\) 的最大的 \(x\)。定义 \(LCP(i,j)\) 表示后缀 \(i,j\) 的 LCP 长度。

接下来我们定义 \(height[i]=LCP(sa[i],sa[i-1])\),也就是第 \(i\) 名后缀与它前一名的后缀的 LCP 的长度。规定 \(height[1]=0\)

现在我们需要考虑如何求出 \(height\) 数组,显然 \(O(n^2)\) 的算法是显然的,不过我们有线性算法。

引理:\(height[rk[i]]\ge height[rk[i-1]]-1\)

证明:

\(height[rk[i-1]]\le 1\) 时,右边小于等于 \(0\),上式显然成立。

否则,根据 \(height\) 定义,有 \(height[rk[i-1]]=LCP(sa[rk[i-1]],sa[rk[i-1]-1])>1\)

不难发现括号中左边就是后缀 \(i-1\),也就是说后缀 \(i-1\) 与后缀 \(sa[rk[i-1]-1]\) 有长度为 \(height[rk[i-1]]\) 的 LCP,于是不妨用 \(aA\) 来表示这个 LCP(\(a\) 为一个字符,\(A\) 显然为一个非空的字符串,长度为 \(height[rk[i-1]]-1\))。

那么后缀 \(i-1\) 可以表达为 \(aAD\),后缀 \(sa[rk[i-1]-1]\) 可以表示为 \(aAB\)(其中 \(B<D\)\(B\) 可能为空,\(D\) 一定非空)。进一步的,后缀 \(i\) 就可以被表达为 \(AD\),且存在后缀 \(sa[rk[i-1]-1]+1\)\(AB\)

因为后缀 \(sa[rk[i]-1]\) 在大小排名上只比后缀 \(sa[rk[i]]\) 小一位,而 \(AB<AD\),所以 \(AB\le\) 后缀 \(sa[rk[i]-1]\) \(< AD\)。此时显然后缀 \(i\) 与后缀 \(sa[rk[i]-1]\) 有共同前缀 \(A\)

于是 \(height[rk[i]]=LCP(sa[rk[i]],sa[rk[i]-1])=LCP(i,sa[rk[i]-1])\ge|A|=height[rk[i-1]]-1\)。引理得证

根据上面的引理,我们就可以直接求出 \(height\) 数组:

int k = 0;
for(int i = 1; i <= n; i++) {
	if(rk[i] == 0) continue;
	if(k) k--;//k 就是 ht[rk[i-1]],显然当前的 ht[rk[i]] >= k-1(根据引理) 
	while(s[i + k] == s[sa[rk[i] - 1] + k]) k++;//再暴力看 k 能扩展到哪里 
	ht[rk[i]] = k;//赋值即可 
}

显然 \(k\) 不会大于 \(n\),同时 \(k\) 最多减 \(n\) 次,因此最多加 \(2n\) 次,复杂度就为线性。

3.2 应用

现在有了 \(sa,rk,height\) 这三个数组,可以说很多字符串题都可以做了。下面简单举几个后缀数组和 height 数组的应用。

3.2.1 求 LCP

上面我们一直在说 LCP,那么现在来看怎样求出两个子串的 LCP。接下来为了与 \(height\) 数组定义中的 \(LCP\) 区分,定义 \(\text{LCP}(i,j)\) 表示 \(sa[i]\)\(sa[j]\) 的 LCP。

LCP 引理:\(\text{LCP}(i,j)=\min(\text{LCP}(i,k),\text{LCP}(k,j))(i\le k\le j)\)

证明:

考虑反证法。假设 \(p=\min(\text{LCP}(i,k),\text{LCP}(k,j))\),则 \(\text{LCP}(i,k)\ge p,\text{LCP}(k,j)\ge p\)。设后缀 \(sa[i]\)\(u\),后缀 \(sa[j]\)\(v\),后缀 \(sa[k]\)\(w\)

于是有 \(u,w\)\(p\) 个字符相等,\(v,w\)\(p\) 个字符相等。所以 \(u,v\)\(p\) 个字符相等,即 \(\text{LCP}(i,j)\ge p\)

\(\text{LCP}(i,j)=q\),且 \(q>p\)。则 \(q\ge p+1\),且 \(u_{p+1}=v_{p+1}\)

由于 \(p=\min(\text{LCP}(i,k),\text{LCP}(k,j))\),所以 \(u_{p+1}\ne w_{p+1}\)\(v_{p+1}\ne w_{p+1}\)。于是 \(u_{p+1}\ne v_{p+1}\)。矛盾!

故原假设不成立,则 \(q\le p\),即 \(\text{LCP}(i,j)\le p\)

所以 \(\text{LCP}(i,j)=p=\min(\text{LCP}(i,k),\text{LCP}(k,j))\)

LCP 定理:\(\text{LCP}(i,j)=\min\limits_{k=i+1}^j(\text{LCP}(k-1,k))\)

证明:

考虑数学归纳法。由 LCP 引理,\(\text{LCP}(i,i+1)=\min(\text{LCP}(i,i),\text{LCP}(i,i+1))\)​。

假如我们已经证明 \(\text{LCP}(i,j)=\min\limits_{k=i+1}^j(\text{LCP}(k-1,k))\),那么可以推出 \(\text{LCP}(i,j+1)=\min(\text{LCP}(i,j),\text{LCP}(j,j+1))\min\limits_{k=i+1}^{j+1}(\text{LCP}(k-1,k))\)

定理得证。

根据 LCP 定理,\(\text{LCP}(i,j)=\min\limits_{k=i+1}^j(\text{LCP}(k-1,k))=\min\limits_{k=i+1}^j(LCP(sa[k-1],sa[k]))=\min\limits_{k=i+1}^jheight[k]\)。于是 LCP 问题现在就转化为了 \(height\) 数组上的 RMQ 问题,利用 ST 表求解即可。

3.2.2 求不同子串个数

考虑正难则反,用总共的子串个数减去重复的子串。

总共的子串个数显然是 \(\dfrac{n(n+1)}2\)。考虑到子串就相当与后缀的前缀,因此想到重复的子串就是后缀的公共前缀。

于是自然的想到如下解法:先进行后缀排序,然后求出 \(height[i]\)。那么新增的子串就是除了公共前缀以外后缀 \(sa[i]\) 的的前缀。这些部分不可能在之前被计算过,否则它也会成为公共前缀。而去除掉的重复前缀的个数自然是 LCP 长度,即 \(height[i]\)

所以最后答案为:

\[\frac{n(n+1)}2-\sum_{i=2}^n height[i] \]

例题:不同子串个数

3.2.3 求最长回文串

其实这个是用 Manacher \(O(n)\) 做的,不过使用后缀数组也可以 \(O(n\log n)\) 求解。

考虑回文串的本质,就是在原字符串颠倒后能够在对应位置找到一模一样的子串。那么我们就会想到将原字符串复制一份并反转,接到原字符串后面。此时我们求后缀的 LCP 即可,当然也需要注意字符的相对位置不能错误。

现在考虑一下这样的问题:对于两个后缀,其 LCP 一定是答案吗?考虑字符串 aaba,得到的新字符串为 aabaabaa,显然最长的重复出现的子串是 abaa,但是最后的 a 是复制得到的,因此这样不正确。考虑在两个串之间添加一个比所有字符都大的字符,这样就可以保证在后缀排序顺序不受影响的情况下,LCP 就是答案。

例如上面的例子中,我们最后转化的字符串是 aaba{abaa,而最后的回文串是由后缀 \(2\) aba{abaa 和后缀 \(6\)abaa 求出的 LCP = aba

3.2.4 求出现至少 k 次的最长子串

考虑这个问题,显然就是说在 \(k\) 个后缀中选出最长的公共前缀。那么结合 height 数组我们知道,对于 \(k\) 个前缀 \(sa_i\)\(sa_{i+k-1}\),它们的公共前缀长度就是 \(\min\limits_{j=i+1}^{i+k-1}\{height_j\}\)。我们就是要求出对于 \(i=[1,n]\),这个式子的最大值是多少。

发现上面求 \(\min\) 的式子是一个区间,因此这就是一个滑动窗口问题,考虑使用单调队列 \(O(n)\) 求解,总复杂度即为 \(O(n\log n)\)​。

例题:[USACO06DEC] Milk Patterns G

扩展:\(n\) 个字符串的最长公共子串。

我们将 \(n\) 个字符串拼在一起,每两个字符串之间加入一个比所有字符大的字符。那么现在问题其实与上面的差不多,也就是在整个大字符串中求出出现至少 \(n\) 次的最长子串,而不同点在于我们每个子串的开头要包括所有小字符串。

仍然考虑使用单调队列,但是此时我们要在保证区间合法的情况下求最大值。考虑双指针,每次移动时维护在小字符串是否出现,最后判断即可。这一部分的时间复杂度仍然是 \(O(n)\) 的,总复杂度也是 \(O(n\log n)\) 的。

例题:[POI2000] 公共串

posted @ 2024-06-08 19:06  UKE_Automation  阅读(96)  评论(1)    收藏  举报