SA(后缀数组)学习笔记

SA(后缀数组)学习笔记

约定

  • 下标显示出来太小了,于是可能会用中括号代替下标。
  • 定义 \(S+T\) 为 \(S\) 与 \(T\) 这两个字符串的拼接。
  • 定义 \(S[l:r]=S[l]+S[l+1]+\cdots +S[r]\)。
  • 定义 \(suf(i)=S[i:n]\),也就是 \(S\) 的后缀。
  • 下文的 \(n\) 表示 \(S\) 的串长。
  • 将后缀按字典序排序,定义 \(sa[i]\) 为排名为 \(i\) 的后缀开头的下标,\(rk[i]\) 为 \(suf(i)\) 的排名,注意不要混淆。

正篇

一、快排

我们比较两个字符串的大小复杂度为 \(O(n)\),因此快排的复杂度为 \(O(n^2\log n)\)。

二、哈希

快排的复杂度太不给力了。我们试图优化一下。

我们试图从比较两个字符串的部分下手。采用二分哈希可以找到两个字符串第一个不相同的位置,就可以比较了。复杂度 \(O(\log n)\)。那么快排就被优化到了 \(O(n\log^2n)\)。

三、倍增

两 \(log\) 还是不太给力。我们考虑一些新的方法。

我们对每个后缀从第一个字符开始考虑,也就是将第一个字符看做第一关键字。那么,这个时候我们就能直接做一个快排。但是如果两个后缀的第一关键字相同,我们就需要考虑第二个,第三个甚至更多。这样的最坏复杂度仍然是 \(O(n^2\log n)\)。

但是我们还能优化。考虑我们在比完第二个字符时,我们将第一二个字符看做第一关键字,第三四个字符看做第二关键字,那么此时第二关键字的相对大小也已经被算了出来,因为它们同时是另一个后缀的第二关键字。

继续往下做,发现是一个倍增的过程。那么我们快排的总次数就是 \(O(\log n)\) 次。但是总复杂度还是 \(O(n\log^2 n)\)。

四、基排

好吧,可能是快排不太给力。有没有什么更快一点的排序呢?

答案是有的。考虑基数排序。我们对一堆两位数排序,可以先按照第二关键字排序,得到一个初始顺序,然后再按照这个初始顺序再次进行排序,也就是不破坏第一关键字相同的值的相对顺序。这样就保证了第二关键字的有序。

两次排序用桶排实现。为什么不直接桶排?值域太大,桶排复杂度是 \(O(n^2)\) 的,即便只有 \(O(n)\) 个有效值。

将上面的过程搬到后缀排序的过程中来,我们就得到了一个 \(O(n)\) 的排序,加上倍增复杂度就是 \(O(n\log n)\) 的,并且常数较小。

说的可能比较抽象,可以结合代码理解:

#include<bits/stdc++.h>
using namespace std;
char s[1000010];
int n,m,cnt;
int bu[1000010],k1[1000010],p[1000010],sa[1000010];
int main()
{
	scanf("%s",s+1);
	n=strlen(s+1);
	m='z';
	for(int i=1;i<=n;i++) ++bu[s[i]],k1[i]=s[i];
	for(int i=1;i<=m;i++) bu[i]+=bu[i-1];
	for(int i=1;i<=n;i++) sa[bu[s[i]]]=i,--bu[s[i]];
	for(int k=1;k<=n;k<<=1){
		cnt=0;
		for(int i=n-k+1;i<=n;i++) p[++cnt]=i;
		for(int i=1;i<=n;i++){
			if(sa[i]>k) p[++cnt]=sa[i]-k;
		} 
		memset(bu,0,sizeof(bu));
		for(int i=1;i<=n;i++) ++bu[k1[i]];
		for(int i=1;i<=m;i++) bu[i]+=bu[i-1];
		for(int i=n;i>=1;i--) sa[bu[k1[p[i]]]]=p[i],--bu[k1[p[i]]];
		for(int i=1;i<=n;i++) p[i]=k1[i],k1[i]=0;
		k1[sa[1]]=1,m=1;
		for(int i=2;i<=n;i++){
			if(p[sa[i]]==p[sa[i-1]]&&p[sa[i]+k]==p[sa[i-1]+k]) k1[sa[i]]=m;
			else k1[sa[i]]=++m;
		} 
		if(m==n) break;
	} 
	for(int i=1;i<=n;i++){
		printf("%d ",sa[i]);
	}
	return 0;
}

五、LCP

SA 的应用之一。有时,我们需要 \(O(\log n)\) 地查询两个串的 LCP,这时候可以用 SA 做到 \(O(n\log n)-O(\log n)\)。

定义 \(LCP(i,j)\) 为 \(suf(sa[i])\) 和 \(suf(sa[j])\) 的最长公共前缀。

  1. \(LCP(i,j)=LCP(j,i)\)。

  2. \(LCP(i,i)=n-sa[i]+1\)。

这两条非常显然。

  1. LCP Lemma:\(\forall i<j<k,LCP(i,k)=\min\{LCP(i,j),LCP(j,k)\}\)。

证明:设 \(p=\min\{LCP(i,j),LCP(j,k)\},u=suf(sa[i]),v=suf(sa[j]),w=suf(sa[w])\)。

则 \(LCP(i,j)\ge p,LCP(j,k)\ge p\),即 \((u,v),(v,w)\) 的前 \(p\) 个字符相同。

所以 \(LCP(i,k)\ge p\)。

我们假设 \(LCP(i,k)>p\),那么 \(u[p+1]=w[p+1]\)。

由于 \(i<j<k\),只有 \(u[p+1]=v[p+1]=w[p+1]\) 才可能使 \(v\) 的字典序在 \(u,w\) 之间。

这时 \(LCP(i,j)>p,LCP(j,k)>p,\min\{LCP(i,j),LCP(j,k)\}>p\),矛盾。

因此 \(LCP(i,k)=\min\{LCP(i,j),LCP(j,k)\}\)。

  1. LCP Theorem:\(\forall i<j,LCP(i,j)=\min\limits_{k=i+1}^{j} LCP(k,k-1)\)。

证明:当 \(j=i+1\) 时显然成立。

当 \(j>i+1\) 时,根据 LCP Lemma,我们有 \(LCP(i,j)=\min\{LCP(i,i+1),LCP(i+1,j)\}\),经归纳得 \(LCP(i,j)=\min\limits_{k=i+1}^{j} LCP(k,k-1)\)。

我们从 LCP Theorem 中发现如果求出了所有 \(LCP(i,i+1)\),我们就能方便地求出所有 \(LCP\),因此,我们定义 \(height[i]=LCP(i,i-1)\)。

  1. \(height[rk[i]]\ge height[rk[i-1]]-1\)。

证明:设 \(k=rk[i-1]-1\),也就是说第 \(k\) 个字符串是排序意义下第 \(i-1\) 个字符串的前一个。

当 \(height[rk[i-1]]\le 1\) 时,上式显然成立。我们只考虑 \(height[rk[i-1]]> 1\) 的情况。

设 \(g=sa[k]+1\)。注意这里 \(i,i-1\) 的意义类似于下标,\(k,g\) 则类似于排名。

我们有 \(suf(i-1)=s[i-1]+suf(i),suf(sa[f])=s[sa[f]]+suf(sa[g])\),

又因为 \(height[rk[i-1]] >1\),有 \(s[i-1]=s[sa[f]]\),因此 \(LCP(g,rk[i])= LCP(rk[i-1],k)-1=height[rk[i-1]]-1\)。

由于 \(rk[i-1]>k\),同时删掉它们的第一个字母,由于该字母相同,删除后得到的两个字符串相对字典序不变,有 \(g<rk[i]\)。

根据 LCP Lemma,我们有 \(LCP(rk[i],rk[i]-1)\ge LCP(rk[i],g)\ge height[rk[i-1]]-1\),因此 \(height[rk[i]]\ge height[rk[i-1]]-1\),证明完成。

有了这一条性质,我们就可以设计出一个简单的均摊 \(O(n)\) 算法,具体见代码:

for(int i=1;i<=n;i++){
    if(rk[i]==1) continue;//h[1] 没有意义
	f=max(h[rk[i-1]]-1,0),j=sa[rk[i]-1];
	while(j+f<=n&&i+f<=n&&s[i+f]==s[j+f]) ++f;
	h[rk[i]]=f;
}

感谢阅读!

posted @ 2025-01-07 23:03  LiuLianLL  阅读(101)  评论(2)    收藏  举报