SA(后缀数组)学习笔记

SA(后缀数组)学习笔记

约定

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

正篇

一、快排

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

二、哈希

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

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

三、倍增

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

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

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

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

四、基排

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

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

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

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

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

#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(logn) 地查询两个串的 LCP,这时候可以用 SA 做到 O(nlogn)O(logn)

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

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

  2. LCP(i,i)=nsa[i]+1

这两条非常显然。

  1. LCP Lemmai<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)p,LCP(j,k)p,即 (u,v),(v,w) 的前 p 个字符相同。

所以 LCP(i,k)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 Theoremi<j,LCP(i,j)=mink=i+1jLCP(k,k1)

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

j>i+1 时,根据 LCP Lemma,我们有 LCP(i,j)=min{LCP(i,i+1),LCP(i+1,j)},经归纳得 LCP(i,j)=mink=i+1jLCP(k,k1)

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

  1. height[rk[i]]height[rk[i1]]1

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

height[rk[i1]]1 时,上式显然成立。我们只考虑 height[rk[i1]]>1 的情况。

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

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

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

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

根据 LCP Lemma,我们有 LCP(rk[i],rk[i]1)LCP(rk[i],g)height[rk[i1]]1,因此 height[rk[i]]height[rk[i1]]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 @   LiuLianLL  阅读(21)  评论(2编辑  收藏  举报
相关博文:
阅读排行:
· 单线程的Redis速度为什么快?
· 展开说说关于C#中ORM框架的用法!
· Pantheons:用 TypeScript 打造主流大模型对话的一站式集成库
· SQL Server 2025 AI相关能力初探
· 为什么 退出登录 或 修改密码 无法使 token 失效
点击右上角即可分享
微信分享提示