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。

posted @ 2025-08-23 17:49  XiaoZi_qwq  阅读(10)  评论(0)    收藏  举报