后缀数组 Suffix Array

前言

去年省选前就想搞的东西,也算是在省选前搞完了。(雾)

后缀数组

基本定义

后文中使用“后缀 \(i\)”指代 \(S[i:n]\),\(n\) 为 \(|S|\)。

\(sa_i\) 表示字典序第 \(i\) 小的后缀,\(rk_i\) 表示后缀 \(i\) 的字典序排名。二者显然互逆。

实现

\(O(n\log^2 n)\)

倍增思想。首先对原串所有长度为 \(1\) 的子串按字典序排序得到 \(sa,rk\),然后倍增长度 \(w\)。

当处理到 \(|S|=2w\) 的子串时,我们已经拥有了 \(|S|=w\) 的所有子串的 \(sa,rk\) 信息。考虑将每个 \(|S|=2w\) 的子串分为前后两个 \(|S'|=w\) 的子串(不够的留空,空字符字典序最小),这样每个串就有了第一第二两个关键字,同时所有关键字之间的相对大小关系是已知的。我们直接 \(O(n\log n)\) 地 sort 一遍即可。sort 之后根据得到的 \(sa\) 得到新的 \(rk\) 数组,记得去重。

因为只会跑 \(\log n\) 次,故而复杂度 \(O(n\log^2 n)\)。

\(O(n\log n)\)

沿用两只老哥的做法,发现复杂度瓶颈在于每次 sort 的复杂度。考虑到要排序的值域在 \(O(n)\) 级别,考虑使用基数排序,同时去重容易 \(O(n)\) 实现,于是单轮复杂度降到 \(O(n)\),复杂度 \(O(n\log n)\)。基排是稳定排序,由于我们是 cnt-- 倒着得到位置,所以只需按第二关键字倒序枚举填入即可完成排序。

但这样常数太大了,考虑优化。注意到对第二关键字的排序实际无需搞基排,因为原 \(sa\) 数组实际上已经给所有第二关键字排好序了,我们只需要将所有第二关键字为空字符的节点挪到 \(sa\) 数组最前面即可。

另一个小优化是每次将值域更新为 \(rk\) 去重后的大小,当值域为 \(n\) 时整个数组已经有序了可以直接结束。

具体实现细节可以参考后方模版部分代码。

\(height\) 数组

定义

\(height_i=\operatorname{LCP}(sa_i,sa_{i-1})\),LCP 表示最长公共前缀长度,也就是排名为 \(i\) 的后缀与排名为 \(i-1\) 的后缀的最长公共前缀长度。特殊地,\(height_1\) 无意义通常规定作 \(0\)。

这个名称与定义看起来莫名其妙的,其实际上还有另一意义,在原串的后缀树上,dfn 为 \(i\) 的点与 dfn 为 \(i-1\) 的点的 LCA 的深度。其应用场景很广泛,将会在之后提及。

实现

有引理 \(height_{rk_i}\ge height_{rk_{i-1}}-1\)。证明如下:

  • 若 \(height_{rk_{i-1}}\le 1\) 显然成立。
  • 否则,\(sa_{rk_{i-1}-1}\) 与 \(sa_{rk_{i-1}}\) 的 LCP 为 \(height_{rk_{i-1}}\),代表后缀 \(i\) 与后缀 \(sa_{rk_{i-1}-1}+1\) 的 LCP 不小于 \(height_{rk_{i-1}}-1\),于是 \(\operatorname{LCP}(i,sa_{rk_i-1})\ge height_{rk_{i-1}}-1\),因为若与 \(sa_{rk_i-1}\) 的 LCP 长度小于 \(height_{rk_{i-1}}-1\),则其字典序小于 \(sa_{rk_{i-1}-1}+1\),矛盾非法。

于是证毕。利用这个引理,我们直接暴力求 \(height\) 即可做到 \(O(n)\)。

模版

int sa[N],rk[N],cnt[N],id[N],ok[N],ht[N];
void SA(string s){
    int n=s.size(),m=127;s=' '+s;
    rep(i,1,m)cnt[i]=0;
    rep(i,1,n)++cnt[rk[i]=s[i]];
    rep(i,1,m)cnt[i]+=cnt[i-1];
    rep(i,1,n)sa[cnt[rk[i]]--]=i;
    for(int w=1;w==1||m<n;w<<=1){
        int cur=0;
        rep(i,n-w+1,n)id[++cur]=i;
        rep(i,1,n)if(sa[i]>w)id[++cur]=sa[i]-w;
        rep(i,1,m)cnt[i]=0;
        rep(i,1,n)++cnt[rk[i]];
        rep(i,1,m)cnt[i]+=cnt[i-1];
        per(i,n,1)sa[cnt[rk[id[i]]]--]=id[i];
        m=0;rep(i,1,n)ok[i]=rk[i];
        rep(i,1,n){
            if(ok[sa[i]]!=ok[sa[i-1]]||sa[i]>n-w||sa[i-1]>n-w||ok[sa[i]+w]!=ok[sa[i-1]+w])++m;
            rk[sa[i]]=m;
        }
    }
    for(int i=1,k=0;i<=n;++i,k&&--k){
        if(rk[i]==1)continue;
        while(i+k<=n&&sa[rk[i]-1]+k<=n&&s[i+k]==s[sa[rk[i]-1]+k])++k;
        ht[rk[i]]=k;
    }
}

以及求单串 LCS 和 LCP 的封装:

namespace LKSA{
    const int N=3e4+5,T=15;
    int n,m;
    int sa[N],rk[N],cnt[N],id[N],ok[N],ht[N];
    void SA(string s){
        n=s.size();m=127;s=' '+s;
        rep(i,1,m)cnt[i]=0;
        rep(i,1,n)++cnt[rk[i]=s[i]];
        rep(i,1,m)cnt[i]+=cnt[i-1];
        rep(i,1,n)sa[cnt[rk[i]]--]=i;
        for(int w=1;w==1||m<n;w<<=1){
            int cur=0;
            rep(i,n-w+1,n)id[++cur]=i;
            rep(i,1,n)if(sa[i]>w)id[++cur]=sa[i]-w;
            rep(i,1,m)cnt[i]=0;
            rep(i,1,n)++cnt[rk[i]];
            rep(i,1,m)cnt[i]+=cnt[i-1];
            per(i,n,1)sa[cnt[rk[id[i]]]--]=id[i];
            m=0;rep(i,1,n)ok[i]=rk[i];
            rep(i,1,n){
                if(ok[sa[i]]!=ok[sa[i-1]]||sa[i]>n-w||sa[i-1]>n-w||ok[sa[i]+w]!=ok[sa[i-1]+w])++m;
                rk[sa[i]]=m;
            }
        }
        for(int i=1,k=0;i<=n;++i,k&&--k){
            if(rk[i]==1)continue;
            while(i+k<=n&&sa[rk[i]-1]+k<=n&&s[i+k]==s[sa[rk[i]-1]+k])++k;
            ht[rk[i]]=k;
        }
    }
    int ht1[N],rk1[N],ht2[N],rk2[N];
    int st1[T][N],st2[T][N],lg[N];
    void make(int st[T][N],int v[N]){
        rep(i,1,n)st[0][i]=v[i];
        repl(t,1,T)rep(i,1,n-(1<<t)+1)st[t][i]=min(st[t-1][i],st[t-1][i+(1<<t-1)]);
    }
    int lcp(int a,int b){
        if(a==b)return n-a+1;
        int lq=rk1[a],rq=rk1[b];
        if(lq>rq)swap(lq,rq);++lq;
        int t=lg[rq-lq+1];
        return min(st1[t][lq],st1[t][rq-(1<<t)+1]);
    }
    int lcs(int a,int b){
        if(a==b)return a;
        int lq=rk2[a],rq=rk2[b];
        if(lq>rq)swap(lq,rq);++lq;
        int t=lg[rq-lq+1];
        return min(st2[t][lq],st2[t][rq-(1<<t)+1]);
    }
    void build(string s){
        n=s.size();rep(i,2,n)lg[i]=lg[i>>1]+1;
        SA(s),memcpy(ht1,ht,sizeof ht),memcpy(rk1,rk,sizeof rk),make(st1,ht1);
        reverse(s.begin(),s.end()),SA(s),memcpy(ht2,ht,sizeof ht),memcpy(rk2,rk,sizeof rk),reverse(rk2+1,rk2+1+n),make(st2,ht2);
    }
}

应用

最小化一个串循环位移后的字典序

P4051 [JSOI2007] 字符加密。把串复制一份拼接过来得到 \(SS\) 之后,后缀排序一下,按字典序升序枚举第一个后缀长度超过 \(|S|\) 的后缀即为答案。

在字符串中找子串

给定主串 \(T\),在线多次询问 \(S\) 找到其在 \(T\) 中出现的第一个位置与出现次数。考虑对 \(T\) 后缀排序,则可以在 \(sa\) 数组上二分 \(S\),出现次数的话因为必然相邻所以再二分最右侧匹配串即可,二分哈希比较字典序可以做到单次 \(O(|S|+\log |S|\log |T|)\)。

从字符串首尾取字符最小化字典序

P2870 [USACO07DEC] Best Cow Line G。我们要做的其实就是判断当前选前缀和后缀哪个字典序更小,不妨将原串的反串拼接在原串后中间用特殊字符隔开然后跑后缀排序,即可得到所有前后缀的字典序大小关系。

两子串最长公共前缀

记两子串起始位置为 \(x,y\),则答案即为 \(\min_{i=x+1}^y height_i\),当然如果是子串的话得对串长取 \(\min\)。证明的话考虑 height 数组在后缀树上的意义,所求其实即为两点 LCA 的深度,经典结论是其 LCA 深度即为 DFS 序上区间 LCA 深度最小值。

比较一个字符串的两个子串

记子串 \(A=S[a:b],B=S[c:d]\),若 \(\operatorname{LCP}(a,c)\ge \min\{|A|,|B|\}\),则两串为前后缀关系,比较串长即可。否则直接比较 \(rk_a,rk_c\) 即可。

本质不同子串数目

子串可以视作所有后缀的所有前缀。考虑使用子串总数 \(\frac{n(n+1)}{2}\) 减去重复的子串数。

考虑如何求重复子串数,其实即为 \(\sum_{i=2}^nheight_i\)。考虑后缀 \(i\) 与后缀 \(i-1\),首先二者长度小于 LCP 的子串显然都算重了,考虑如何证明超过的必然是新增的,倘若非新增则说明再之前存在一个后缀与当前后缀 LCP 长度超过 \(height_i\),这与两子串 LCP 为区间 \(height\) 的 \(\min\) 矛盾,证毕。

答案即为 \(\frac{n(n+1)}{2}-\sum_{i=2}^nheight_i\)。

出现至少 \(k\) 次的子串的最大长度

考虑多次出现的同一个串在 \(sa\) 上是相邻的。考虑枚举后缀 \(x\),使用 \(\min_{i=x-k+2}^x height_i\) 更新答案。

找所有后缀两两 LCP 之和

P4248 [AHOI2013] 差异。求出 height 数组后,就是区间最小值求和问题,扫描右端点,单调栈维护区间 min,然后上线段树维护即可。

习题

P3809 【模板】后缀排序

模版题。

P1117 [NOI2016] 优秀的拆分

经典哨兵套路。我们考虑如何找出 AA 串。如果我们每 \(|A|\) 个位置放一个哨兵,那么每个 AA 串都会经过两个哨兵。

我们考虑对每两个相邻哨兵统计经过他俩的 AA 串信息。考虑求出两个位置后缀的 LCP 与两个位置前缀的 LCS,那么一个 AA 串的中间分界线必然落在两个哨兵中间 LCP 和 LCS 的相交部分。

枚举哨兵长度,调和级数得复杂度 \(O(n\log n)\)。统计 AABB 串的话对每个 AA 串起点终点前缀和处理一下即可,每次更新都是区间加可以单次 \(O(1)\) 完成。

P7180 [BalticOI 2004] Repeats (Day2)

先考虑对已知的 \(p,l\) 求 \(k\)。不难想到即为 \(\lfloor\frac{\operatorname{LCP}(p,p+l)}{l}\rfloor\)。于是我们会了 \(O(n^2)\)。

既然要枚举长度,如果能调和级数就好了。实际上是可以的。我们只枚举 \(p=xl,x\in \mathbb Z^*\),那么对于 \(p'\in((x-1)l+1,xl]\) 的起点,我们得到 \(\operatorname{LCP}(p-1,p+l-1)\),\(p'<p-\operatorname{LCP}\) 的显然有 \(k=1\) 无需考虑,否则我们直接以 \(p'=p-\operatorname{LCP}(p-1,p+l-1)\) 作为起点最优。

于是我们如愿做到了 \(O(n\log n)\)。

P2178 [NOI2015] 品酒大会

我们先考虑第一问怎么做。不难想到利用 \(rk\) 数组转为区间 \(\min\) 计数。考虑从大到小插入点,那么会与其左侧和右侧的两个已有部分合并起来,新增方案即为两个部分各选一点的方案,并查集简单维护即可。然后我们发现第二问也可做了,维护每个并查集最大值和最小值即可。

P4248 [AHOI2013] 差异

难点在于不要往两个串减去 LCP 后的形态瞎球想。直接拆成两部分,前面可以直接算,然后就变成对两两后缀 LCP 求和的问题,做法上面写过了。

P3181 [HAOI2016] 找相同字符

首先不难想到用特殊字符把两串连接起来,然后两个起点的 LCS 就可以 SA 直接求,于是有 \(O(n^2)\) 枚举两个起点的做法。

考虑沿用上一题做法,但是要考虑两个起点位于同一串的情况,把两个串单独拉出来各自再跑一遍减去方案数即可。

P4051 [JSOI2007] 字符加密

上面讲过了。

P6095 [JSOI2015] 串分割

首先显然要最小化最大串长。显然为 \(\lceil\frac{n}{k}\rceil\)。同时没满长的段全部长为满长减一不劣,那么分出来的每个段长就都是已知的了。

考虑枚举最大段起点,他显然是个满长度的串,考虑如何判定合法性,我们贪心地跑一遍原串,每次能满就满,看没满的串够不够即可。于是我们获得了 \(O(nk)\) 的做法。

然而注意到实际上只需枚举前 \(\lceil\frac{n}{k}\rceil\) 个起点即可覆盖所有分法,代价是我们现在不知道最大段是谁了。不妨二分最大值,也就是二分 rk 数组,沿用上述判定即可做到 \(O(n\log n)\)。

BZOJ2865 字符串识别

也就是说希望对于每一位得知,覆盖了这一位的,只出现过一次的最短子串长度。

首先放到 \(rk\) 数组上之后我们容易得知这个后缀只出现过一次的最短前缀长度,对 \(rk\) 数组上相邻两项求 LCP 取更大值即可。

我们先把每个后缀的最短前缀信息给各个点更新上,那么剩下的情况只需要对每个点找到前面最近的超出了其最短前缀长度的位置即可,容易前缀和维护。

CF316G3 Good Substrings

考虑对 \(s\) 的每个起点而言,其符合条件的子串长度必然是在一个区间内的。出现次数咋求呢,一个暴力做法是枚举模式串的所有起点,然后看 LCP 是否超过了这个子串的长度。

也就是说一个起点对于一个 \(t\) 符合要求的长度条件可以写作, \(t\) 中有不超过 \(r\) 个起点与当前后缀的 LCP 大于等于这个长度,不小于 \(l\) 个。考虑对于每个 \(t\),把他和 \(s\) 拼起来跑 SA,枚举 \(s\) 起点二分长度最大值和最小值,判定的话二分找到 \(rk\) 左边和右边最后一个区间 \(\min\) 超过当前长度的点,看中间有几个后缀是来自 \(t\) 的即可。

但是还要给子串去重。套路地去除 \(height\) 内的长度即可。

P6793 [SNOI2020] 字符串

考虑先把他们两两匹配,一对匹配的价值是 \(k-\mathrm {lcp}\)。最小化代价。考虑跑一遍费用流即可,可惜边数太大了。

考虑贪心。不难发现只需每次匹配 LCP 最大的一对即可,因为 LCP 相同的在之后的抉择中本质相同。

考虑怎么实现这个东西,从大到小枚举 LCP 然后用类似品酒大会的做法并查集搞一下即可。

P4094 [HEOI2016/TJOI2016] 字符串

既然是 SA 题单考虑放到 \(rk\) 上,于是 LCP 写作区间最小值。把 \(d-c+1\) 提出来,答案大致可写作:

\[\max_{sa[i]\in[a,b]}\min\{b-sa[i]+1,\min_{j\in(i,rk[c]]}ht[j]\} \]

\(b-sa[i]+1\) 很不好处理。注意到可二分性,考虑二分答案。于是我们只需得到是否存在一个 \(i\) 满足 \(sa[i]\in[a,b-mid+1]\land \min_{j\in(i,rk[c]]}ht[j]\ge mid\)。满足第二个限制的点在 \(rk\) 上显然是一段连续区间容易二分得到,我们主席树维护一下值域处理第一限制即可。

posted @ 2026-03-05 19:03  LastKismet  阅读(36)  评论(0)    收藏  举报