字符串专题一
本文内容:后缀数组(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)的。
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)的。
(待upd)

浙公网安备 33010602011771号