字符串专题一

本文内容:后缀数组(SA),后缀自动机(SAM),广义后缀自动机,后缀树
未特殊说明,本文提及的字符串仅包含小写字母,字符串的排序均按照字典序

前置知识

1.基数排序

例:给出n个字符串,将这些字符串排序,输出排序得到的数组(只需输出字符串的编号)。

按照从高位到低位的顺序,我们开等于字符集大小的桶,扫描每个串,按着这个串当前位置的字符填入对应桶中,若不存在,则视为优先级最高(可以当做是一个小于'a'的字符)。

之后将桶做一遍前缀和,就得到了具体这个元素所在的桶对应的排名,也就是这个桶里面的元素最多排到第几名。

之后按照倒序原来的排序数组,保证同一个桶内原来相对位置不变,依次填入新的排序数组中。

这样进行max(|s|)次后,就得到了题目要求的排序数组。

思考这样做的正确性:

字典序第一优先级从左到右,第二优先级是字符值,那么第一优先级更高的,一定排的更高,第一优先级相同再去看具体的字符值,这样的排序方式易证明是正确的。

总结基数排序的步骤:

1.确定第一、第二关键字。

2.按照第一关键字的顺序在桶内统计第二关键字。

基数排序的复杂度(仅讨论双关键字排序):

时间:O(n\(|C_1|+|C_2|\))(\(C_1\)为第一关键字集合大小)

空间:O(n+\(|C_2|\))(\(C_2\)为第二关键字集合大小)

显然,当\(|C_1|\)\(|C_2|\)较大时,还是选择O(nlogn)的排序算法更为优秀。

例题代码:

#include<bits/stdc++.h>
using namespace std;
const int N=1e5+100;
int n,mx;int a[N],len[N];
char s[N][15];
vector<int> b[30];
void radix_sort(int base){
	for(int i=1;i<=n;i++){
		if(len[a[i]]<base) b[0].push_back(a[i]);
		else b[s[a[i]][base-1]-'a'+1].push_back(a[i]);
	}
	int now=n;
	for(int i=26;i>=0;i--){
		for(int j=b[i].size();j;j--) a[now--]=b[i][j-1];
		b[i].clear();
	}
}
int main(){
	scanf("%d",&n);
	for(int i=1;i<=n;i++){
		scanf("%s",s[i]);
		len[i]=strlen(s[i]);
		mx=max(mx,len[i]);
		a[i]=i;
	}
	for(int i=mx;i>=1;i--) radix_sort(i);
	for(int i=1;i<=n;i++) printf("%d ",a[i]);
	return 0;
}

一.后缀数组(SA)

1.定义

对于一个给定的串S,显然S存在n个后缀,将这n个后缀排序后得到的数组就是后缀数组。

最暴力的求法,就是用std::sort()重定义比较,复杂度是O(\(n^2logn\)),不能接受。

复杂度求后缀数组的方法是 倍增 (O(nlogn))和 DC3 (O(n))。虽然后者是线性,但因为算法很复杂,没有倍增简单,而且倍增多出的log并不会带来致命的影响,所以最常用的是倍增求SA,本文也只介绍倍增算法。

2.符号约定

suff[i] 表示以原串中第i个元素开头的后缀串(suffix)
x[i] 表示s[i]在排序时的第一关键字。
y[i] 表示第二关键字排名为i的后缀。
sa[i] 表示排名为i的后缀。
rk[i] 表示suff[i]的排名,rk[sa[i]]=i,sa[rk[i]]=i
lcp(i,j)表示字符串suff[sa[i]]和suff[sa[j]]的最长公共前缀。
ht[i] 表示lcp(sa(i),sa(i-1))。
h[i] h[i]=ht[rk[i]]=lcp(i,sa[rk[i]-1])

3.算法流程

1.求解sa[]数组

首先,读入字符串后,先把每个后缀按照第一个字符做一遍基数排序,得到了初始的sa[]。

接着,我们套用双关键字的基数排序,给字符串定义关键字。

我们假设现在设置的长度是k,那么该串的第一关键字为第1到k个字符,第二关键字为第k+1到2k个字符。

考虑怎么样方便比较:现在我们已经有了一个可以用的关键字k=1,处理新增加的第二关键字是容易的,但是我们不希望每次的第一关键字都要重新处理。换句话说,我们希望利用以前获得的信息更新第一关键字。

很容易就想到了:每次当k增加一倍。

例如,当k=2时,我们有了k=1的第一关键字,又处理出来了k=1的第二关键字,那么,只需要把k=1的第一、第二关键字合并,我们就能直接拿来当k=2的第二关键字使用了,如果字符数不够,那么视为补上一些小于'a'的字母,也能参与合并的过程。

接着思考怎么处理第二关键字,我们考虑仍然用已经处理过的信息。

分两类情况讨论:

1 .suff[n-k+1]到suff[n]都是没有第二关键字的,视为优先级最高,直接丢进y[]中。

2 .枚举rk[i] (当然不需要真的求出rk[i]),如果sa[i]>k,那么他可以作为别人的第二关键字,因为他在前面补上k个字符后就能成为其他的后缀,那么丢进y[]中的就应该是sa[i]-k,注意y[]的定义,储存的是一个后缀。

这一层的排序后,我们需要提前给下一层处理好第一关键字,也就是合并后离散化。

如果发现离散化后元素个数已经是n了,说明不存在第一、二关键字都相同的元素了,即所有元素已经排好序,也就宣告sa[]数组处理完成,可以直接退出了。

基本的思想就是这样,不易理解,具体的可以看代码的注释。

const int N=1e6+100;
int n,m;
char s[N];
int sa[N],x[N],y[N],c[N];
void get_sa(){
	for(int i=1;i<=n;i++) c[x[i]=s[i]]++;//因为字符的ASCII码不大,可以直接赋值
	m='z';//字符集的最大元素是'z'
	for(int i=2;i<=m;i++) c[i]+=c[i-1];
	for(int i=n;i>=1;i--) sa[c[x[i]]--]=i; 
	//第一遍基数排序,处理处k=1的第一关键字,储存在sa[]中
	for(int k=1;k<=n;k<<=1){
		int num=0;
		for(int i=n-k+1;i<=n;i++) y[++num]=i;
		for(int i=1;i<=n;i++) if(sa[i]>k) y[++num]=sa[i]-k;
		//分两类讨论处理第二关键字 
		//顺序循环后,这里已经按照第二关键字排了序 
		for(int i=1;i<=m;i++) c[i]=0;
		for(int i=1;i<=n;i++) c[x[i]]++;
		//按照上一层处理的第一关键字基数排序 
		for(int i=2;i<=m;i++) c[i]+=c[i-1];
		for(int i=n;i>=1;i--) sa[c[x[y[i]]]--]=y[i],y[i]=0;
		//倒序循环的目的是保证第一、二关键字同时是倒序 
		swap(x,y); 
		//交换数组只是为了方便离散化 
		x[sa[1]]=1;num=1;
		for(int i=2;i<=n;i++) x[sa[i]]=(y[sa[i]]==y[sa[i-1]]&&y[sa[i]+k]==y[sa[i-1]+k])?num:++num;
		//因为已经排好序了,所以可以直接通过比较第一第二关键字来离散化 
		if(num==n) break;
		//元素个数已经达到了n,说明已经不存在重复元素,直接结束 
		m=num;
	}
}

复杂度分析:

k这一层循环是O(log)的,内层循环都是O(n)的,时间复杂度为O(nlogn),但基本上跑不满,效率很高。

空间复杂度也是O(n)的。

例题:P3809 【模板】后缀排序

2.求解ht[]数组

大部分题目只求出sa[]是远远不够的,所以引入了ht[]数组来求解更多问题。

首先观察lcp()的性质,显然有:

1 .\(lcp(i,j)=lcp(j,i)\)
2 .\(lcp(i,i)=n-sa[i]+1\)

有了这两条性质,对于i>j的情况,可以转化为i<j;对于i==j的情况,可以直接计算,减少了讨论量。

不过依次比较时间是O(\(n^2\))的,不能接受,需要更多性质。

引理1:\(lcp(i,j)=min(lcp(i,k),lcp(k,j))\) 其中 \(1\leq i \leq k \leq j \leq n\)

证明:

\(lcp(i,j) \geq min(lcp(i,k),lcp(k,j))\) 是显然的。考虑证明\(lcp(i,j) \leq min(lcp(i,k),lcp(k,j))\)

因为 \(i \leq k \leq j\),那么取相同长度的前缀pre,显然有 \(pre(sa[i]) \leq pre(sa[k]) \leq pre(sa[j])\)

那么设\(pre(sa[i])=pre(sa[j])=lcp(i,j)\)

既然有 \(pre(sa[i]) \leq pre(sa[k]) \leq pre(sa[j])\),由夹逼定理可知\(pre(sa[i])=pre(sa[k])=pre(sa[j])\)

那么\(lcp(i,k)\)一定不会比\(pre(sa[i])\)短,\(lcp(k,j)\)同理,但两个必然有一个等于\(lcp(i,j)\),否则\(lcp(i,j)\)应该更长,所以\(lcp(i,j) \leq min(lcp(i,k),lcp(k,j))\)得证。

引理2:\(lcp(i,j)=min(lcp(i,i+1),……,lcp(j-1,j)\)

结合引理1很容易证明。

引理3:\(h[i]\geq h[i-1]-1\)

考虑 \(rk[i-1]<rk[i]\)的情况(反过来考虑相同)。

\(rk[k]=rk[i-1]-1\) ,假设已经知道了\(lcp(k,i-1)\),也就是h[i-1]。

考虑suff[i]和suff[i-1]只差一个最开头的那个字母,suff[k]和suff[k+1]也是一样,

显然,\(lcp(i,k+1)=lcp(i-1,k)\)

因为\(rk[k]<rk[i-1]\),在开头加上同一个字符后,\(rk[k+1]=rk[i]-1\),也能得到\(rk[i-1]<rk[k+1]\)

所以\(h[i]=lcp(i-1,i)\geq lcp(i,k+1)=lcp(i-1,k)-1=h[i-1]-1\),引理得证。

有了以上引理后,很容易就得到了求ht[]数组的方法:

1 .根据sa[]获得rk[]。

2 .设置变量k,暴力扩展。

具体看代码和注释:

int rk[N],ht[N];
void get_ht(){
	for(int i=1;i<=n;i++) rk[sa[i]]=i;
	//求出rk[]数组 
	for(int i=1,k=0;i<=n;i++){
		//枚举的是suff[i] 
		if(rk[i]==1) continue;
		//ht[1]=0
		if(k) k--;
		//h[i]>=h[i-1]-1,k是已经获得的h[i-1] 
		int j=sa[rk[i]-1];
		//获得比suff[i]排名小一的suff[j] 
		while(j+k<=n&&i+k<=n&&s[j+k]==s[i+k]) k++;
		//暴力扩展 
		ht[rk[i]]=k;
		//不要忘了ht[]的定义 
	}
}

复杂度分析:

每次k只能-1,所以k最多加到n再减到0,所以时间复杂度是O(n)的。

空间复杂度也是O(n)的。

例题:AcWing.2715 后缀数组

(待upd)

posted @ 2022-05-09 10:47  Displace  阅读(50)  评论(0)    收藏  举报