后缀数组小计

0 前言

被迫上班了属于是

前置知识

  • 倍增
  • 基数排序

基数排序

这个很好理解
举个例子 : 对 \(n\) 对二元组 \((x,y)\) 排序 优先比较关键字 \(x\) 相同再比较关键字 \(y\)
第一步:以 \(y\) 为基准 从小到大把所有二元组排序 得到 \(y\) 递增的序列
第二部:以 \(x\) 为基准 从小到大把所有二元组排序 得到 \(x\) 递增的序列
因为保证原数列顺序不变 在 \(x\) 递增的同时 \(y\) 也是递增的 排序完毕
令值域为 \(w\) 时间复杂度为 \(O(w)\)
举个例子:
原 : \((3,4)\space (3,1)\space (1,5)\space (2,4)\)
第一次排序 : \((3,1)\space (3,4)\space (2,4)\space (1,5)\)
第二次排序 : \((1,5)\space (2,4)\space (3,1)\space (3,4)\)
听起来很简单 但是代码可不简单

#include<bits/stdc++.h>
#define N 10000005
#define M 1000005
using namespace std;
int n,a[N],b[N];
int cnt[N],sa[N],lsa[N];
int m,seed;
long long ans;
int main()
{
	scanf("%d%d",&n,&m);
	for(int i=1;i<=n;i++)
		cin>>a[i]>>b[i];
	for(int i=1;i<=n;i++) cnt[b[i]]++;
	for(int i=1;i<=m;i++) cnt[i]+=cnt[i-1];
	for(int i=1;i<=n;i++) lsa[cnt[b[i]]--]=i;//第 16 行
	//----------------------------
	memset(cnt,0,sizeof cnt);
	for(int i=1;i<=n;i++) cnt[a[i]]++;
	for(int i=1;i<=m;i++) cnt[i]+=cnt[i-1];
	for(int i=n;i>=1;i--) sa[cnt[a[lsa[i]]]--]=lsa[i];//第 20 行
	for(int i=1;i<=n;i++) 
		cout<<sa[i]<<"\n";
	return 0;
}

来分析一下代码 先看分割线上的部分一:按第二关键字排序

  • step \(1\) .记录下 \(y\) 出现次数
  • step \(2\) .记录前缀和(废话)
  • step \(3\) .得到排名

举个例子 :
\((1,4),(2,3),(1,3),(2,2),(3,1)\)
处理后得到的 \(cnt\) 数组:\(1,1,2,1\)
前缀和:\(1,2,4,5\)
我们惊奇的发现 第二关键字的顺序已经确定了
\(y=1\)\(1\to 1\)
\(y=2\)\(2\to 2\)
\(y=3\)\(3\to 4\)
\(y=4\)\(5\to 5\)
任意的,对于 \(y=x\) 在排列中的位置不就是 \(cnt_{x-1}+1\to cnt_x\) 吗!
如第 \(16\) 行 第二关键字的放入不需要任何顺序 直接按顺序放入即可

现在来到第二部分 同第一部分思想一样 这次处理的是第一关键字
如第 \(20\) 行 这次放入就有顺序了 我们按照处理完第二关键字的次序从后往前放入
为什么?因为我们能保证第一关键字有序的同时要保证第二关键字有序
因此 因为填数是从后往前填的 所以应该先填第二关键字大的
第一次处理出了从小到大的第一关键字 所以要反着遍历

1 基本概念

我们规定如下的数组:

  • \(rk[i]\) 表示开头下标为 \(i\) 的后缀的排名
  • \(sa[i]\) 即常说的后缀数组 表示排名为 \(i\) 的后缀开头下标

显然 有性质 \(sa[rk[i]]=rk[sa[i]]=i\)
理解一下:
对于 \(sa[rk[i]]=i\) 表示取出第 \(i\) 个后缀的排名 再引用该排名的位置即 \(i\)
对于 \(rk[sa[i]]=i\) 表示取出排名为 \(i\) 的后缀的位置 再求出该位置的排名即 \(i\)

(OI wiki 的图)
image

2 后缀数组求法

不难发现 只需要求出 \(rk\) 数组 \(sa\) 数组自然就能求出

1.\(O(n^2\log n)\)

这个肯定自己想都能想出来
\(O(n)\) 枚举所有后缀 然后使用快排
因为字符串比较时间是 \(O(n)\) 的 因此总的时间复杂度是\(O(n\times n\log n)\)\(O(n^2\log n)\)

2.\(O(n\log^2 n)\)

我们发现时间瓶颈 \(n\) 卡在了 \(O(n)\) 的比较上
我们想想怎么优化排序 于是倍增法就出来了
主要思想就是一部分 一部分粘合

(OI wiki)
image
这一部分很好理解
以 最长的后缀 (下标从 \(1\) 开始为例)

首先 得到单个字母的 \(rank\) 值 (第一步)
然后 求出子串 \([1\to 2]\) 当前的 \(rank\)
怎么求?很好理解 记录第一关键字 \(x=rk1\) 第二关键字 \(y=rk2\)
排序所有的 \((x,y)\) 二元组可以得到所有当前的后缀排名
这时 会发现最后一位已经无法向后拓展了 (即第二次排序时最后的第二关键字)
那么 这时 记这位为 \(0\)
思考一下 这样 就与字符串比较的规则相符了

然后 同理 第三次求出子串 \([1\to 4]\)
第四次求出子串 \([1\to 8]\)

这样 不难发现 当所有的数第二关键字为 \(0\) 时 此时便得到了所有后缀
此时 第一关键字便是 \(sa\) 数组
非常巧妙的方法 这样 将比较是时间复杂度缩短为常数级别


现在分析时间复杂度
不难发现 倍增的次数为 \(O(\log n)\)
每次使用快排 时间复杂度 \(O(n\log n)\)

总体时间复杂度 \(O(n\log^2 n)\)


别看理论简单 代码却很抽象

#include<bits/stdc++.h>
#define N 1000005
using namespace std;
char s[N]; 
int n;
int sa[N],rk[N*2],lrk[N*2];
int k;
bool cmp(int a,int b)
{
	return rk[a]^rk[b]?rk[a]<rk[b]:rk[a+k]<rk[b+k];//先以第一关键字排序 再以第二关键字排序 
}
int main()
{
	scanf("%s",s+1);
	n=strlen(s+1);
	for(int i=1;i<=n;i++) sa[i]=i,rk[i]=s[i];//初始化 将第一次排名处理好  存入未排序的sa数组 
	for(k=1;k<n;k<<=1)
	{
		sort(sa+1,sa+1+n,cmp);//算出当前子串数组的sa 下面的步骤就是用sa推rk 
		memcpy(lrk,rk,sizeof rk);//复制一份rk数组 
		int 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;//枚举排名 i 比较与上一个拼接是否相同 相同就不是增大的排名 
			else rk[sa[i]]=++p;
	}
	for(int i=1;i<=n;i++) printf("%d ",sa[i]);
	return 0;
}

注意几个细节 \(k\) 是全局变量 否则无法参加 cmp 函数的计算
理解难点在于用 \(sa\)\(rk\)
以求 \(rk_2\) 为例

  • step \(1\) 通过 \(sa\) 数组取出在\(rk\)排名为 \(1\space 2\) 的位置
  • step \(2\) 比较在\(rk\)排名为 \(1\space 2\) 时的拼接是否相同
  • step \(3\) 如果有效 在\(rk\) 排名为 \(1\space 2\) 位置的值

3.\(O(n\log n)\)

非常简单的优化
容易发现 瓶颈在于快排
发现有 \(x,y\le n\) 的性质
双关键字排序将快排换为基数排序即可
注意一开始的 \(x,y\le 122\) \((z=122)\)

code

#include<bits/stdc++.h>
#define N 1000005
using namespace std;
char s[N]; 
int n,m=128;
int lsa[N],sa[N],rk[N*2],lrk[N*2],cnt[N];
int k;
void Qsort()
{
	memset(cnt,0,sizeof cnt);
	for(int i=1;i<=n;i++) cnt[rk[i+k]]++;
	for(int i=1;i<=m;i++) cnt[i]+=cnt[i-1];
	for(int i=1;i<=n;i++) lsa[cnt[rk[i+k]]--]=i;
	memset(cnt,0,sizeof cnt);
	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[lsa[i]]]--]=lsa[i];
	return;
}
int main()
{
	scanf("%s",s+1);
	n=strlen(s+1);
	for(int i=1;i<=n;i++) sa[i]=i,rk[i]=s[i];
	for(k=1;k<n;k<<=1)
	{
		Qsort();m=n;
		memcpy(lrk,rk,sizeof rk);
		int 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;
	}
	for(int i=1;i<=n;i++) printf("%d ",sa[i]);
	return 0;
}

理论结束 建议两种代码都写一次

3 后缀数组基础应用

这里的应用只需要知晓后缀数组的本质因素

1.前后缀大小比较

例题:here
思考:
很明显,破环为链
直接把 \(s\) 复制一遍粘到后面去
由于题目求的只是该循环最后一个字符 因此 直接求出后缀数组即可

举个例子:
\(\text{abcda}\to \text{abcdaabcda}\)
可以发现 我们已经把要求的循环串都变成了某一后缀的固定长度前缀 且只需要到最后一个字符
这样 比较后缀数组即可

比如 \(\text{abcdaabcda} , \text{aabcda}\)
当比较了前 \(5\) 位时 会取第 \(5\) 位字母 后面的字串没有意义

练习 : P2870
补充:当同时比较前后缀大小时 需要在字符串末尾插入一个极小数 这样 长度比较小的后缀才会小于长度比较长的前缀

2.在线求文本串中的模式串

例题:here
只需要做到 52pts 即可
思考:当我们求出 sa 以后 所有的后缀大小已经确定
那么 每个模式串一定是某个后缀的前缀
既然后缀已经排好序了 那我们直接在后缀中找这个前缀即可
因为每个后缀开始位置不同 因此只要有一个后缀前缀于模式串相同便有 \(1\) 的贡献
令当前模式串为 \(T\) 比较字符串的时间复杂度是 \(O(|T|)\) 的 因此总的时间复杂度是 \(O(|S|+|T|\log|S|)\)

虽然没有 AC 自动机的 \(O(|S|+|T|)\) 优秀 但是优点在于它是一个在线算法

核心 code

int cmp(string t,int pos)
{
	if(n-pos+1<t.size()) return t<s.substr(pos,n-pos+1);
	if(t==s.substr(pos,t.size())) return 2;
	return t<s.substr(pos,t.size());
}
int query(string s,bool k)//k=1 为最右边  
{
	int l=1,r=n;
	while(l<=r)
	{
		int mid=(l+r)/2;
		if(cmp(s,sa[mid])==2) 
		{
			if(k) l=mid+1;
			else r=mid-1;
			continue;
		}
		if(cmp(s,sa[mid])) r=mid-1;
		else l=mid+1;
	}
	if(k) return l-1;
	else return r+1;
}

4 \(height\) 数组

学 sa 不学 height 相当于白学
\(99\)% 的 sa 题目都与 height 有关

我们给出如下定义:

  • \(lcp(x,y)\)
    \(lcp(x,y)\) 为字符串 \(x\)\(y\) 的最长公共前缀的长度
    举个例子 , \(acdefb\)\(acfdae\) 的最长公共前缀长度是 \(2\space (ac)\)
  • \(height\) 数组
    \(height_1=0 , height_i=lcp(sa_i,sa_{i-1})\) 即第 \(i\) 名后缀与 \(i-1\) 名后缀的 \(lcp\)

(网图)
image

左边一列即为 \(height\) 数组

  • \(H\) 数组
    \(H_i=height[rk[i]]\) 即第 \(i\) 位后缀与它前一名后缀的 \(lcp\)
    如上图 \(H=\{3,2,1,0,3,2,1,0\}\)

性质:

1. \(H_i\ge H_{i-1}-1\)

证明:
\(H_{i-1}\le1\) 性质显然成立
\(H_{i-1}>1\) 时,不难理解
不妨令第 \(i-1\) 位后缀为 \(s_1\) 它前一名为 \(s_2\)
\(i\) 位后缀为 \(t_1\) 它前一名为 \(t_2\)

我们知道 \(s_1=c+t_1\) 其中 \(c\) 为某个字符
\(s_1\)\(t_1\) 相当于在开头去掉 \(c\)
那么 去掉 \(s_2\) 的开头字符一定\(H_{i-1}-1\) 的贡献

我们知道 后缀排好序后一定是从小到大的
根据此性质 容易得到 \(H_i\ge H_{i-1}-1\)

2.\(lcp(i,j)=lcp(j,i)\)

3.\(lcp(i,i)=len[i]=n-sa[i]+1\)

这两条性质显然

4.LCP Lemma

LCP Lemma : 对于任意 \(1\le i<j<k\le n\)\(lcp(i,k)=\min(lcp(i,j),lcp(j,k))\)
证明:首先,令 \(\min(lcp(i,j),lcp(j,k))=p\)
因为 \(i,j\)\(p\) 个字符相同 \(j,k\)\(p\) 个字符相同
所以 \(i,k\)\(p\) 个字符相同
现在证明 \(lcp(i,k)\le p\) 假设 \(lcp(i,k)>p\)
\(i,j,k\) 对应字符串为 \(u,v,w\)
钦定 \(lcp(i,j)=p\)
那么 根据假设 \(u[p+1]=w[p+1]\)\(u[p+1]\ne v[p+1]\)
根据 \(i,j,k\) 在 sa 数组中 前 \(p\) 位相同
得到 \(u[p+1]\le v[p+1]\le w[p+1]\)
所以 \(u[p+1]=v[p+1]=w[p+1]\) 与条件矛盾
同理 对于 \(lcp(j,k)\) 自证不难
对于 \(lcp(i,j)=\min(lcp(i,j),lcp(j,k)\) 成立

5.LCP Theorem

LCP Theorem : 对于 \(i<j\)\(lcp(i,j)=\min(height_{i+1}\to height_j)\)
证明:
\(\\min(height_{i+1}\to height_j)=min(lcp(k-1,k))\space (i+1\le k\le j)\)
LCP Lemma 可得
\(lcp(i,j)\)
\(=\min(lcp(i,i+1),lcp(i+1,j))\)
\(=min(lcp(i,i+1),lcp(i+1,i+2),lcp(i+2,j))\)
同理 经过归纳易证


\(height\) 的求法

根据性质 \(1\) 暴力求即可
code

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

根据性质 \(H_i\ge H_{i-1}-1\)
\(k\le n\)\(k\) 只会减 \(n\) 次 ,加 \(2n\) 次 因此时间是 \(O(n)\)

5 \(height\) 数组的应用

1 子串的 \(lcp\)

由于每个子串都是某个后缀的前缀 只需要计算两个后缀的 \(lcp\) 稍加处理即可

2 子串的大小

假设比较 \(S[a...b]\)\(S[c...d]\)
计算 \(lcp(a,c)=p\)\(p\ge \min(|A|,|B|)\) 就比较 \(|A|,|B|\) 即可
否则 比较 \(rk_a\)\(rk_c\) 即可

3 不同子串数目

根据 不同子串数目 \(=\) 总子串数目 \(-\) 相同子串数目
因为子串为后缀前缀 相同子串即为最大公共前缀
因此 原式 = \(\frac{n(n+1)}{2}-\sum\limits_{i=1}^nheight_i\)
按照后缀排序的顺序枚举 每次新增的子串都是以其为开头的 因为 \(lcp\) 部分已经算过 不能重复计算

为什么不用算前面的?实际上,对于 \(i\) 我们取的是 \(max(lcp(1,i),lcp(2,i),lcp(3,i)...)\)
明显 \(lcp(i-1,i)\) 有最大值 即 \(height_i\) 得证


\(height\) 数组有很多灵活的运用 上面只是部分 还需多做题巩固!

posted @ 2024-01-28 22:04  g1ove  阅读(10)  评论(0编辑  收藏  举报