后缀数组学习笔记
用途
高效组织字符串中的所有子串。
思想
“所有前缀的所有后缀即为所有子串。”
概念定义
-
sa:suffix array,即后缀数组。表示字符串所有非空后缀按字典序从小到大排序后,后缀们的第一个字符在原串中的下标。
-
rk:该字符开头的后缀,在 sa 中排名第几。
-
sa 和 rk 的转化:显然,当 rk 互不相同时,满足
sa[rk[i]] = i
、rk[sa[i]] = i
;但 rk 在我们的算法过程中是有可能相同的(尽管结果上是没有可能的),这时就需要注意了。
建立过程
众所周知直接 sort 所有后缀是 \(O(n^2 \log n)\) 的复杂度。这里复杂度之所以差,是因为实际后缀是有大量重复部分的。能不能充分利用每一次比较呢?
有一个想法是利用 较短 字串的比较得到 较长 字串的比较。听到这里,应该不难想到试着用 倍增法 来扩大字串长度:用两个长度为 1 的字串的比较结果得到长度为 2 的字串的比较结果,用两个长度为 2 的字串的比较结果得到长度为 4 的字串的比较结果……
引自 OIwiki
那具体该怎么利用两个较短字串的结果呢?假设当前正在对长度为 \(w\) 的字串排序,当前字串为 \([i, i+w-1]\),它可以平分成等长的左右两个子区间,而这左右两个子区间的排名在上一步已经确定了。于是便可以以左区间排名为第一关键字,以右区间排名为第二关键字,对这 \(n\) 个长度为 \(w\) 的区间排序(关于右区间超出 \(n\) 的区间,就当右区间为排名为 0 即可)。
那么可以初步建立一个算法:
-
初始化 \(sa[i] = i, rk[i] = s[i]\)。
-
开始倍增。先对 sa 按照上述方法排序。
-
用 sa 更新 rk。注意,这里的 rk 就有可能出现相等的情况,因此需要判断一下 \(sa[i]\) 和 \(sa[i-1]\) 对应字串是否相等。
代码如下:
#include<bits/stdc++.h>
using namespace std;
const int MAXN = 1e6+5;
int n, m, w, rk[MAXN<<1], tmp[MAXN], sa[MAXN];
char s[MAXN];
inline bool cmp(int x, int y){
if(rk[x] != rk[y]) return rk[x] < rk[y];
return rk[x+w] < rk[y+w];
}
int main(){
scanf("%s", s+1);
n = strlen(s+1);
for(int i = 1; i <= n; i++) sa[i] = i, rk[i] = s[i];
for(w = 1; w < n; w <<= 1){
sort(sa+1, sa+1+n, cmp);
for(int i = 1; i <= n; i++)
tmp[sa[i]] = tmp[sa[i-1]]+(cmp(sa[i-1], sa[i]) ? 1 : 0);//避免 rk 值相同
for(int i = 1; i <= n; i++) rk[i] = tmp[i];
}
for(int i = 1; i <= n; i++) printf("%d ", sa[i]);
return 0;
}
因为使用了倍增 + sort,时间复杂度为 \(O(n \log^2 n)\)。
这是还有优化空间的。注意到 rk 的范围在 \(1 \sim n\) 之间,那么可以使用 桶排 将单次排序优化到 \(O(n)\);由于这里有两个关键字,因此还要使用 基数排序。
具体来说,每次长度的排序大致如下:
-
先对第二关键字(rk[i+w])进行排序。
-
引入一个辅助数组——标记桶。它记录的是“每个关键字的排名”。当然,我们可以先求得“每个关键字的数量”,再求前缀和,即可求得排名。
-
更新 sa。由于 sa 互不相同,代码中对标记桶有一个巧妙的处理:每次访问对应标记桶,令标记桶
--
。 -
对第一关键字(rk[i])进行排序,过程大同小异。但是在更新 sa 时需要额外注意一点:在第一关键字相同时,要尽量保留第二关键字的排序结果。 因此代码中记录了一个 last_sa 并用了一个倒序更新,请结合代码理解。
-
最后,对 rk 进行更新。方法与倍增 + sort 是一样的。
代码如下:
#include<bits/stdc++.h>
using namespace std;
const int MAXN = 1e6+5;
int n, m, bjt[MAXN<<1], rk[MAXN<<1], sa[MAXN], lrk[MAXN<<1], lsa[MAXN];
char s[MAXN];
inline bool cmp(int x, int y, int i){
if(lrk[x] != lrk[y]) return lrk[x] < lrk[y];
return lrk[x+i] < lrk[y+i];
}
int main(){
scanf("%s", s+1);
n = strlen(s+1);
int w = 127;//w 为桶的值域
for(int i = 1; i <= n; i++) rk[i] = s[i], ++bjt[rk[i]];
for(int i = 1; i <= w; i++) bjt[i] += bjt[i-1];
for(int i = 1; i <= n; i++) sa[bjt[rk[i]]--] = i;
for(int i = 1; i <= n; i++) lrk[i] = rk[i];
for(int i = 1; i <= n; i++)
rk[sa[i]] = rk[sa[i-1]]+(lrk[sa[i]] == lrk[sa[i-1]] ? 0 : 1);
w = n<<1;
//为什么是 < n?1+n-1 = n
for(int i = 1; i < n; i <<= 1){
//对第二关键字进行排序
for(int j = 0; j <= w; j++) bjt[j] = 0;
for(int j = 1; j <= n; j++) ++bjt[rk[j+i]];
for(int j = 1; j <= w; j++) bjt[j] += bjt[j-1];
for(int j = 1; j <= n; j++) sa[bjt[rk[j+i]]--] = j;
//对第一关键字进行排序
for(int j = 0; j <= w; j++) bjt[j] = 0;
for(int j = 1; j <= n; j++) lsa[j] = sa[j];
for(int j = 1; j <= n; j++) ++bjt[rk[j]];
for(int j = 1; j <= w; j++) bjt[j] += bjt[j-1];
//倒序循环:由于是 --,要使原本大的尽量大(保留上次排序结果)
for(int j = n; j >= 1; j--) sa[bjt[rk[lsa[j]]]--] = lsa[j];
//更新 rk
for(int j = 1; j <= n; j++) lrk[j] = rk[j];
for(int j = 1; j <= n; j++)
rk[sa[j]] = rk[sa[j-1]]+(cmp(sa[j-1], sa[j], i) ? 1 : 0);
}
for(int i = 1; i <= n; i++) printf("%d ", sa[i]);
return 0;
}
//这里代码颜色渲染是怎么回事???
如此,总复杂度为 \(O(n \log n)\)。
卡常技巧
据说这是 OIwiki 上两个最有用的。
1. 第二关键字无需基数排序
思考一下第二关键字排序的实质,其实就是把超出字符串范围(即 sa[i] + w > n)的 sa[i] 放到 sa 数组头部,然后把剩下的依原顺序放入。
这段话说得较为清楚。但是,注意我们实际是按照 \(sa[j] = sa[i]+w\) 的位置关系对 sa 排序,不要把 \(sa[i]\) 本身的位置关系与此弄混。
2. 若排名都不相同可直接生成后缀数组
#include<bits/stdc++.h>
using namespace std;
const int MAXN = 1e6+5;
int n, m, bjt[MAXN], rk[MAXN], sa[MAXN], lrk[MAXN<<1], lsa[MAXN];
char s[MAXN];
inline bool cmp(int x, int y, int i){
if(lrk[x] != lrk[y]) return lrk[x] < lrk[y];
return lrk[x+i] < lrk[y+i];
}
int main(){
scanf("%s", s+1);
n = strlen(s+1);
for(int i = 1; i <= n; i++) rk[i] = s[i], ++bjt[rk[i]];
for(int i = 1; i <= 127; i++) bjt[i] += bjt[i-1];
for(int i = 1; i <= n; i++) sa[bjt[rk[i]]--] = i;
for(int i = 1; i <= n; i++) lrk[i] = rk[i];
for(int i = 1; i <= n; i++)
rk[sa[i]] = rk[sa[i-1]]+(lrk[sa[i]] == lrk[sa[i-1]] ? 0 : 1);
for(int i = 1; i < n; i <<= 1){
//对第二关键字进行排序
int p = 0;
for(int j = 0; j < i; j++) lsa[++p] = n-j;
for(int j = 1; j <= n; j++)
if(sa[j] > i) lsa[++p] = sa[j]-i;
//对第一关键字进行排序
for(int j = 0; j <= n; j++) bjt[j] = 0;
for(int j = 1; j <= n; j++) ++bjt[rk[j]];
for(int j = 1; j <= n; j++) bjt[j] += bjt[j-1];
for(int j = n; j >= 1; j--) sa[bjt[rk[lsa[j]]]--] = lsa[j];
//更新 rk
for(int j = 1; j <= n; j++) lrk[j] = rk[j];
bool flag = true;
for(int j = 1; j <= n; j++){
bool tmp = cmp(sa[j-1], sa[j], i);
flag &= tmp;
rk[sa[j]] = rk[sa[j-1]]+(tmp ? 1 : 0);
}
if(flag) break;//如果 rk 互不相同,停止
}
for(int i = 1; i <= n; i++) printf("%d ", sa[i]);
return 0;
}
Height 数组
以下内容抄自 OIwiki
-
\(lcp(i, j)\):后缀 \(i\) 和后缀 \(j\) 的最长公共前缀。
-
\(height\) 数组:\(height[i] = lcp(sa[i], sa[i-1])\),即每一名与前一名的 \(lcp\)。
-
\(O(n)\) 求 \(height\) 数组需要的一个引理:\(height[rk[i]] \ge height[rk[i-1]]-1\),即大于等于“原字符串顺序上后一个”的 height-1。
证明:
先考虑后缀 \(i-1\) 和 \(sa[rk[i-1]-1]\) 的 \(lcp\)(长度即 \(height[rk[i-1]]\))。用一个字符串 \(aA\) 来表示这个 \(lcp\)(\(a\) 是一个字符,\(A\) 是一个字符串)。
那么后缀 \(i-1\) 即可表示为 \(aAB\),后缀 \(sa[rk[i-1]-1]\) 即可表示为 \(aAC\)(\(C < B\))。
furthermore,后缀 \(i\) 即可表示为 \(AB\),后缀 \(sa[rk[i-1]-1]+1\) 即可表示为 \(AC\)。
对于 \(i\) 在后缀数组上的前一位 \(sa[rk[i]-1]\),因为它满足所有小于后缀 \(i\) 中最大的,有:\(AC \le\) 后缀 \(sa[rk[i]-1] < AB\)。
所以后缀 \(i\) 和后缀 \(rk[i]-1\) 的 \(lcp\) 显然会包含 \(A\)。因此,\(height[rk[i]]_{\min} = |A| = height[rk[i-1]]-1\)。
inline void Height(){
for(int i = 1, j = 0; i <= n; i++){
if(rk[i] == 1) continue;//根据定义
if(j) --j;
while(a[i+j] == a[sa[rk[i]-1]+j]) ++j;
ht[rk[i]] = j;
}
return;
}
作用:
-
任意两个后缀的 lcp:\(\min \{height[i+1 \sim j] \}\),使用 RMQ 解决。
-
任意两子串的大小关系:
假设需要比较的是 \(A=S[a..b]\) 和 \(B=S[c..d]\) 的大小关系。
-
若 \(lcp(a, c)\ge\min(|A|, |B|)\),\(A<B\iff |A|<|B|\)。
-
否则,\(A<B\iff rk[a]< rk[c]\)。
-
-
不同子串的数目:相当于统计每个后缀不同前缀的个数,用总数减去重复,即 \(\frac{n(n+1)}{2} - \sum\{height[i]\}\)。