SA

· · 个人记录

前置知识:倍增、计数排序
应用部分:多种数据结构

构建

概述

后缀数组(Suffix Array)旨在预处理后缀的排名数组来处理字符串问题,算法本身重在构建 SA 数组,应用可以与多种数据结构结合。

构建算法有倍增(O(n\log n))、DC3(O(n),常数大)、SA-IS(O(n))等,本文只介绍倍增。

【模板】后缀排序

记号

倍增

每次对长度为 2^i 的子串后缀排序,而这个后缀可以用两个 2^{i-1} 的拼成。
由于长度为 2^{i-1} 的已排过序,因此排 2^i 的时只需用上一次的 rk 进行双关键字计数排序。

常数优化

过程

m = 130; // m根据字符集设 
For(i,1,n) ++cnt[ rk[i]=s[i] ];
For(i,1,m) cnt[i] += cnt[i-1];
rFor(i,n,1) sa[ cnt[rk[i]]-- ] = i;
bool cmp(int i,int j,int w) { return rk0[i]==rk0[j] && rk0[i+w]==rk0[j+w]; }
// 多测记得mem(rk0),因为要用到rk0[i+w]
for(int w = 1, p = 0; p < n; w *= 2, m = p) {
    // w为长度;p=n时rk都不同,结束;m=p优化值域
    p = 0;
    // 第二关键字:
    For(i,n-w+1,n) id[++p] = i; // 第二关键字为0
    For(i,1,n) if( sa[i] > w ) id[++p] = sa[i]-w; // sa[i]作为sa[i]-w的第二关键字 
    // id[i]为按第二关键字排序后第i小的后缀;
    // 第一关键字:相当于把i换成id[i]
    mem(cnt,0,m);
    For(i,1,n) ++cnt[ h[i]=rk[id[i]] ]; // 存下来rk[id[i]]优化内存访问
    For(i,1,m) cnt[i] += cnt[i-1];
    rFor(i,n,1) sa[ cnt[h[i]]-- ] = id[i];
    p = 0, memcpy(rk0+1,rk+1,sizeof(int)*n); // rk0用于比对两个子串是否相等(板子中为h)
    For(i,1,n) rk[sa[i]] = cmp(sa[i-1],sa[i],w) ? p : ++p;
    // cmp优化内存访问;相等子串的rk也要相同
}

height

定义

#### 性质 - $$h[rk[i]]\ge h[rk[i-1]]-1$$ 证明: $h[rk[i-1]]\le1$ 时显然成立。 记 $j'=sa[rk[i-1]-1],j=sa[rk[i]-1]$。设 $suf(i-1)=aAC$,则 $suf(i)=AC$。设 $suf(j')=aAB$,则 $suf(j'+1)=AB$,$lcp(i-1,j')=aA$。因为 $rk[j'+1]<rk[j]<rk[i]$,所以 $suf(j)$ 一定含前缀 $A$,$lcp(i,j)$ 至少为 $A$,即 $h[rk[i]]\ge|A|=h[rk[i-1]]-1$。 简单来说: | 位置 | $suf$ | | :----------: | :----------: | | $j'$ | $aAB$ | | $i-1$ | $aAC$ | | $sa[rk[i-1]-1]+1$ |$AB$ | | $sa[rk[i]-1]$ | $A...$ | | $i$ | $AC$ | - $$lcp(suf(sa[i]),suf(sa[j]))=\min_{k=i+1}^j\{h[i]\}$$ 感性理解:由于 $sa$ 是排好序的(字典序从高到低比,位置越近前缀相同的越长),那么 $lcp(sa[i],sa[j])$ 在 $sa[i..j]$ 中一直存在,不可能变了再变回去。 这样求两个后缀的 $lcp$ 就变成了 RMQ 问题。 #### 求 height 由性质 1 暴力求即可。 ```cpp for(int i = 1, k = 0; i <= n; ++i) { // k为lcp int j = sa[rk[i]-1]; if( k ) --k; while( i+k<=n && j+k<=n && s[i+k]==s[j+k] ) ++k; h[rk[i]] = k; } ``` $k\le n$ 且最多减 $n$ 次,则增加不会超过 $2n$ 次,时间复杂度 $O(n)$。 ## tricks - 可以将多个串拼在一起(中间用不在字符集中的字符分隔(如果是数值注意值域))求 sa 以减少码量(同时增大常数) - $lcp(i,x)$ 是单峰函数(峰值 $x=i$) - 把 SA 开一个结构体并压行,这样在需要拼接字符串/结合其他 DS 时代码会比较清爽 ## 题目 注:大部分题可用 SAM 解,因此这里只记录基础题/ SA 有明显优势的 ### 基础 #### [P4051 [JSOI2007]字符加密](https://www.luogu.com.cn/problem/P4051) 求循环移动字符串的第 $k$ 大 破环成链,求 sa。(分字符串是否都是同一字符易证每个后缀的 $n$ 位之后无影响) #### [P2870 [USACO07DEC]Best Cow Line G](https://www.luogu.com.cn/problem/P2870) 首尾字符不同时贪心取小的;相同时需要比较首字符向后和尾字符向前形成的字符串的字典序,将反串拼在原串后求 $sa$ 可以 $O(1)$ 判断。 #### [P2408 不同子串个数](https://www.luogu.com.cn/problem/P2408) $$\frac{n(n-1)}{2}+n-\sum_{i=2}^{n}h[i]$$ 补集转化。每个子串只在 $sa$ 中第一次出现的位置统计 #### [P3181 [HAOI2016]找相同字符](https://www.luogu.com.cn/problem/P3181) 一个字符串取两字串相同的方案数为任意两后缀 LCP 之和,即 $height$ 数组的每个区间最小值之和,单调栈可以 $O(n)$ 计算。 答案即为把两串拼起来的方案数减去两串单独的方案数 另一道结合单调栈的题:[P4248 [AHOI2013]差异](https://www.luogu.com.cn/problem/P4248) ### 综合 #### [P2463 [SDOI2008] Sandy 的卡片](https://www.luogu.com.cn/problem/P2463) 显然要差分,答案变为所有串的最长公共子串 $+1$。 把差分数组拼起来求 $height$,然后二分答案。转化为判断 $sa$ 中是否有一段 $h>mid$ 且每个串有至少一个后缀在其中出现,扫一遍即可。 实现细节:采用 $b[i]=a[i]-a[i-1]$ 的差分,那么 $b[1]=a[1]$,如果两串的 $a[1]$ 也相同就会多加一,一开始的想法是拼起来的时候不算 $a[1]$,但会导致统计出现了哪些串后缀时出错。正确做法是令 $b[1]=a[1]+rand()$。两组数据: ``` 3 4 6 4 2 3 2 6 2 2 3 1 2 2 1 2 2 1 2 ``` #### [P2336 [SCOI2012]喵星球上的点名](https://www.luogu.com.cn/problem/P2336) 把姓名拼起来求 $sa$,那么每个点名串对应 $sa$ 上一段区间所表示后缀的前缀,可以二分求出这个区间。 然后就变成了区间数颜色+每个颜色被多少个区间覆盖,BIT/莫队即可。 注意这个二分: ```cpp int len, ql = 1, qr = sa.n; read(len); For(i,1,len) { int x; read(x); int l = ql, r = qr; while( l <= r ) { int mid = l+r>>1; sa.s[sa.sa[mid]+i-1]<x ? l=mid+1 : r=mid-1; } swap(l,ql), r = qr; while( l <= r ) { int mid = l+r>>1; sa.s[sa.sa[mid]+i-1]>x ? r=mid-1 : l=mid+1; } qr = r; } // [ql,qr]为所求区间,ql>qr表示不存在 ``` #### [P4094 [HEOI2016/TJOI2016]字符串](https://www.luogu.com.cn/problem/P4094) 暴力:枚举 $l\in[a,b]$ 作为左端点与 $s[c..d]$ 求 $lcp$,这样状态数(指二元组 $(l,c)$)就是 $O(n^2)$,没什么前途。 那就从答案上动手:二分答案,转化为判断是否存在 $l\in[a,b-mid+1]$ 与 $s[c..d]$ 的 $lcp\ge mid$。 发现 $sa$ 上 $lcp(c,i)\ge mid$ 的 $i$ 是一个区间,二分找到这个区间后可以用主席树判断这两个区间包含的后缀集合是否有交。 时间复杂度 $O(n\log^{2}n)$,大常数选手(比如在下)需要吸氧 $sa$ 上二分时需要 ST 表求 $lcp$,注意 $h[i]$ 表示的是 $lcp(sa[i],sa[i-1])$,因此可以将 ST 表写成左开右闭的。 ```cpp void build() { For(i,2,n) lg[i] = lg[i>>1]+1; Rep(i,1,n) mn[0][i] = h[i+1]; For(i,1,16) For(j,1,n-(1<<i)) mn[i][j] = min(mn[i-1][j],mn[i-1][j+(1<<i-1)]); } // mn[i,j]=min(h[k]),j+1<=k<=j+(1<<i) int lcp(int l,int r) { int k = lg[r-l]; return min(mn[k][l],mn[k][r-(1<<k)]); } ```