SA 学习笔记
一:初步认识
SA,即后缀数组,基础运用是在求一个字符串中,每一个位置对应的后缀在排序后对应的排名。
扩展运用是求排名相邻的后缀的 \(lcp\) 长度。
再扩展就可以干很多神奇的操作了。
首先一个入门组的小朋友都知道 \(O(n^2)\) 的求法(枚举当前比较哪一位,对每一个字符串进行计数排序(也叫桶排序)),我们大朋友要学习 \(O(nlogn)\) 的求法(如果你是老朋友,可以学学 \(O(n)\) 的,当然我不会)。
二:构造
Part 1:倍增
下面是一些约定的标志:
-
\(rk_i\) 表示位置为 \(i\) 的后缀对应的排名;
-
\(sa_i\) 表示排名为 \(i\) 的后缀对于的位置;
显然地,\(sa_{rk_i}=rk_{sa_i}=i\)。
我们来观察为什么 \(O(n^2)\) 的求法慢,因为每一次比较我们都只比一位,这太低效了。
如果我们可以做到第一次比一位,第二次比两位,第三次比四位……那么就样就是 \(O(nlogn)\) 的了。
为了达到这个效果,我们必须充分利用“这些字符串都是一个大字符串的后缀”这条性质。
假设此时,我们已经比完了前 \(k\) 位,现在要比前 \(2k\) 位。直接比是不好比的,因为我们不知道后 \(k\) 位的信息。
我们把这两个区间放到字符串上观察。
下标: i i+k i+2k
-----------------------------------------
---------------------- => [i,i+2k-1]
----------- => [i,i+k-1]
----------- => [i+k,i+2k-1]
你一定发现了,后 \(k\) 位就是 \(i+k\) 的前 \(k\) 位。
我们要比较前 \(2k\) 位,也就是以前 \(k\) 位的排名为第一关键字,后 \(k\) 位的排名为第二关键字进行排序。
这个时候我们就需要用到基数排序。你可能不知道怎么进行基数排序,但是先假装你懂,我们后面再说。
排完序之后,我们还要给每一个后缀一个排名,显然相同的后缀排名应当一样,因此我们需要判断这两个后缀的第一第二关键字是否都相同,如果是,那么这个两个后缀排名相同。
Part 2:基数排序
现在我们来介绍怎么做基数排序。
首先基数排序是一个基于计数排序(桶排序)的算法,用于处理多关键字排序。但是因为你是来学 SA 的,我们只介绍 SA 中会用到的基数排序。
首先,你需要知道计数排序的一个性质:计数排序的输出顺序是访问顺序的逆序。
为了理解这个性质,我们来看代码:
//c是桶,x是需要排序的数组,y是排序好的数组,n是个数,m是值域
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--) y[ c[x[i]]-- ]=x[i];
/*
首先,在做完前缀和后,c[i] 表示小于等于 i 的数的个数
那么c[x[i]]-- 表示 将 x[i] 放在 c[x[i]] 的位置,并且将 c[x[i]] 往前移动一个位置。
说明如果之后有一个数值和 x[i] 一样的 x[j],那么 x[j] 的位置在 x[i]前
*/
然后我们再看我们目前的任务:以前 \(k\) 位为第一关键字,后 \(k\) 位为第二关键字进行排序。
对于两个位置:
-
如果两个位置的第一关键字不同,那么就直接用计数排序处理第一关键字的比较就好,和第二关键字没关系。
-
如果两个位置的第一关键字相同,那么就要比第二关键字,这里有一个神奇的方法:因为第二关键字的比较无法体现在计数排序本身中,所以我们就把第二关键字的比较体现在访问顺序中。具体地,我们可以先给第二关键字进行一次计数排序,然后在给第一关键字进行计数排序时,按第二关键字从大到小进行访问,那么输出时相同第一关键字的元素中,第二关键字小的会因为访问顺序较后而输出顺序靠前。
这样就处理好了双关键字排序。
这里有一个常数优化:
因为第二关键字是上一轮 \(i+k\) 的排名,而在上一轮中,我们已经求得了当时的 \(sa\) 数组,那么我们就可以直接用 \(sa\) 数组对第二关键字进行排序。
具体地:
//y[i]是按照第二关键字进行排序后倒数第i个访问的元素,,目前正在比较前k位。
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;//sa[i] 作为 sa[i]-l 的第二关键字
Part 3:完整实现
//:x第一关键字,y:按照第二关键字进行排序后倒数第i个访问的元素,
//第一关键字:前k个字符的排名;第二个关键字:前k+1到2k个字符的排名。
int n,m=122,x[N],y[N];
//sa是排名为i的后缀
int sa[N],rk[N],c[N];//sa[rk[i]]=rk[sa[i]]=i
char s[N];
void getSA(){
for(int i=1;i<=n;i++) ++c[ x[i]=s[i] ];//最开始第一关键字就是自己,第二关键字就是位置
for(int i=2;i<=m;i++) c[i]+=c[i-1];
for(int i=n;i>=1;i--) sa[c[x[i]]--]=i;
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;//sa[i] 作为 sa[i]-l 的第二关键字
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数组记录下来,方便下面定排名
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;
m=num;
}
}
三:height 数组
这里才是整个 SA 最为关键的部分。
我们做出约定:
-
\(lcp(x,y)\):位置为 \(x\) 和位置为 \(y\) 的后缀的 lcp 长度。
-
\(height[i]\):等于 \(lcp(sa_i,sa_{i-1})\),即排名为 \(i\) 的后缀和它前一名的后缀的 lcp 长度。
-
\(H[i]\):等于 \(height[rk_i]\),即位置为 \(i\) 的后缀和它前一名的后缀的 lcp 长度。
有结论 \(H[i] \ge H[i-1]-1\)。
证明:
我们令 \(v\) 为 \(sa_{rk_{i-1}+1}\)(其实就是 \(H_{i-1}\) 中和 \(rk_i\) 求 lcp 的后缀位置,即 \(lcp(v,i-1)=H[i-1]\))。
那么对于位置为 \(v+1\) 的后缀,一定存在 \(lcp(v+1,i)=H[i-1]-1\)。(两个相同部分长 \(H[i-1]\) 的字符串各自去掉第一个字符,那么显然相同部分长度减一)
因为与 \(i\) 的 lcp 长度最长的后缀就是和 \(i\) 排名相邻的后缀,即位置为 \(rk_i-1\) 的后缀,那么 \(H[i]\) 就一定不低于 \(lcp(v+1,i)=H[i-1]-1\)。(如果不成立,那么 \(v+1\) 就应该排到 \(rk_i-1\) 的位置了)
Q.E.D.
因此我们可以线性求 \(H[i]\),并且直接暴力匹配。
但是我们大多数情况并不只需要求 \(lcp(i,sa_{rk_i-1})\),我们需要的是 \(lcp(i,j)\)。
有结论 \(lcp(i,j)=\min_{i<k\le j}height_k\)。证明是简单的。
四:参考资料
本文参考了自为风月马前卒的blog,推荐大家可以阅读一下大佬的blog,因为内容比我详实多了QAQ。