后缀数组(SA)习记

前言

在学习 SA 之前,有必要复习一下什么是 计数排序、基数排序、桶排序 以及三者之间的区别。

然后细说一下 后缀数组 的三种构造方式 倍增、DC3、SA_IS

部分代码和内容转自:

Oi-Wiki

牛客竞赛字符串-后缀数组

如侵权可联系删除。

目录:

计数排序

计数排序(Counting sort)是一种线性时间的排序算法。

算法流程

  1. 计算每个数出现了几次;
  2. 求出每个数出现次数的 前缀和;
  3. 利用出现次数的前缀和,从右至左计算每个数的排名。

算法分析

  • 是一种稳定的排序算法。
  • 时间复杂度为 O(n+w), w 为值域大小

缺点:

  • O(w)>O(nlog(n)) 时,不如 O(nlogn) 的排序算法。

代码实现

#include<bits/stdc++.h> using namespace std; const int N = 100010; const int W = 100010; int n, w, a[N], cnt[W], b[N]; void counting_sort() { memset(cnt, 0, sizeof(cnt)); for (int i = 1; i <= n; ++i) ++cnt[a[i]]; for (int i = 1; i <= w; ++i) cnt[i] += cnt[i - 1]; // 倒序是因为要保证原数组里面相同项相对位置不变,原来在后面的还在后面 for (int i = n; i >= 1; --i) b[cnt[a[i]]--] = a[i]; } int main(){ cin >> n; for (int i = 1; i <= n; i++) { cin >> a[i]; w = max(w, a[i]); } counting_sort(); for (int i = 1; i <= n; i++) { cout << b[i] << " "; } return 0; }

基数排序

基数排序(Radix sort)是一种非比较型的排序算法,最早用于解决卡片排序的问题

算法流程

将待排序的元素拆分为 k 个关键字。先对第 k 个关键字进行稳定排序,然后对第 k1 个关键字稳定排序,直到对第 1 个关键字排序完成。

  • 基数排序主要是一种思想,对某个关键字的内部排序是依靠其他排序算法来完成。如计数排序。

算法分析

  • 是一种稳定的排序算法。
  • 时间复杂度: 一般来说,如果每个关键字的值域都不大,就可以使用 计数排序 作为内层排序,此时的复杂度为 O(kn+Σi=1kwi) ,其中 wi 为第 i 关键字的值域大小。如果关键字值域很大,就可以直接使用基于比较的 O(knlogn) 排序而无需使用基数排序了。
  • 空间复杂度: O(k+n);

代码实现

#include <bits/stdc++.h> using namespace std; const int N = 100010; const int W = 100010; const int K = 100; int n, w[K], k, cnt[W]; struct Element { int key[K]; bool operator<(const Element &y) const { // 两个元素的比较流程 for (int i = 1; i <= k; ++i) { if (key[i] == y.key[i]) continue; return key[i] < y.key[i]; } return false; } } a[N], b[N]; void counting_sort(int p) { // 内层计数排序 memset(cnt, 0, sizeof(cnt)); for (int i = 1; i <= n; ++i) ++cnt[a[i].key[p]]; for (int i = 1; i <= w[p]; ++i) cnt[i] += cnt[i - 1]; // 为保证排序的稳定性,此处循环i应从n到1 // 即当两元素关键字的值相同时,原先排在后面的元素在排序后仍应排在后面 for (int i = n; i >= 1; --i) b[cnt[a[i].key[p]]--] = a[i]; memcpy(a, b, sizeof(a)); } void radix_sort() { for (int i = k; i >= 1; --i) { // 借助计数排序完成对关键字的排序 counting_sort(i); } }

桶排序

桶排序(Bucket sort)是排序算法的一种,适用于待排序数据值域较大但分布比较均匀的情况。

算法流程

  1. 将值域分块,设置一个定量的数组当作空桶,一个捅对应一个块的元素
  2. 遍历序列,并将每个块中元素一个个放到对应的桶中;
  3. 对每个不是空的桶进行排序,通常是插入排序;
  4. 从不是空的桶里把元素再放回原来的序列中。

算法分析

  • 稳定性:
    • 如果使用稳定的内层排序,并且将元素插入桶中时不改变元素间的相对顺序,那么桶排序就是一种稳定的排序算法。
    • 由于每块元素不多,一般使用插入排序。此时桶排序是一种稳定的排序算法。
  • 时间复杂度:
    • 桶排序的平均时间复杂度为 O(n+n2/k+k) (将值域平均分成 n 块 + 排序 + 重新合并元素),当 kn 时为 O(n).
    • 桶排序的最坏时间复杂度为 。

代码实现

#include<bits/stdc++.h> const int N = 100010; int n, w, a[N]; vector<int> bucket[N]; void insertion_sort(vector<int> & A) { for (int i = 1; i < A.size(); ++i) { int key = A[i]; int j = i - 1; while (j >= 0 && A[j] > key) { A[j + 1] = A[j]; --j; } A[j + 1] = key; } } void bucket_sort() { int bucket_size = w / n + 1; for (int i = 0; i < n; ++i) { bucket[i].clear(); } for (int i = 1; i <= n; ++i) { bucket[a[i] / bucket_size].push_back(a[i]); } int p = 0; for (int i = 0; i < n; ++i) { insertion_sort(bucket[i]); for (int j = 0; j < bucket[i].size(); ++j) { a[++p] = bucket[i][j]; } } }

SA基本概念及性质

SA基本概念

  • 后缀S[i]=S[i,|S|]
  • 字典序:从左往右找两个字符串第一个不同字母,空字符设为最小。
  • 后缀排序:将所有后缀 S[i] 看作独立的串,放在一起按照字典序进行升序排序。
  • 后缀排名 rk[i]rk[i] 表示后缀 S[i] 在后缀排序中的排名,即他是第几小的后缀。
  • 后缀数组 sa[i]sa[i] 表示排名第 i 小的后缀。
  • LCP: Longest Common Prefix, 最长公共前缀。

一个重要的等式rk[sa[i]] = sa[rk[i]] i

Naive 求法。

对所有后缀字符串进行 std::sort ,用 哈希 + 二分 重写 cmp() 比较两个后缀的 LCP 字典序大小, 时间复杂度为 O(nlog2n), 同时哈希检测次数达到了 nlog2n,非常容易冲突。 显然不是理想的算法。

参考代码和题目

LCP 最长公共前缀

问:设有一组排序过的字符串 A=[A1,A2,···,An]。如何快速的求任意 AiAj 的 LCP?

需要一个关于 LCP 的"区间可加性":

对于任意的 k[i,j]

LCP(Ai,Aj)=LCP(LCP(Ai,Ak),LCP(Ak,Aj))=min(LCP(Ai,Ak),LCP(Ak,Aj))

故有:

LCP(Ai,Aj)=min{LCP(Ak,Ak+1)},ik<j

证明:

  • X=Ai[LCPik+1],Y=Ak[LCPik+1],Z=Aj[LCPjk+1]
  • LCP(Ai,Ak)LCP(Ak,Aj)
    • 由于 XY,Y=Z,所以 XZ
    • LCP(Ai,Aj)=LCP(Ai,Ak)=min(LCP(Ai,Ak),LCP(Ak,Aj))
  • LCP(Ai,Ak)=LCP(Ak,Aj)
    • 已知 XY&YZ, 且字典序 Ai<Ak<Aj,所以 X<Y<Z,所以 XZ,结论同样成立

Height 数组

SA 中非常重要的数组

定义: height[i]=LCP(sa[i],sa[i1]) , 排名为 i 的后缀与排名为 i1 的后缀的 LCP 长度, 特别地,height[1] = 0

显然有了 Height 数组,刚才的问题就变成了 区间最小值查询 啦!

那么如何求 Height ?

引理:height[rk[i]]height[rk[i1]]1

展开引理:LCP(sa[rk[i]],sa[rk[i]1])>=LCP(sa[rk[i1]],sa[rk[i1]])1 ,省略 (S[])。

等价于: LCP(i,sa[rk[i]1])>=LCP(i1,sa[rk[i1]])1

因此,不妨设 H[i]=LCP(S[i],S[sa[rk[i1]]]) ,表示后缀 i 与排名比他小 1 的后缀的 LCP

即证: H[i]H[i1]1

K1=sa[rk[i1]1] , K2=sa[rk[i]1]
H[i1]=LCP(S[i1],S[K1]), H[i]=LCP(S[i],S[K2])

  • H[i1]1
    • H[i]0 显然成立。
  • H[i1]>1
    • 对于 H[i1]S[K1],S[i1] 去首字母后变为 S[K1+1],S[i] ,字典序关系是不变的。S[K1+1]<S[i], 且 LCP(S[K1+1],S[i])=H[i1]1
    • S[K2]<S[i], 且中间没有其他后缀,所以有 S[K1+1]S[K2]
    • S[K1+1]S[K2]S[i], 由“区间可加性”得和上式得

    H[i1]1=LCP(S[K1+1],S[i])=min(LCP(S[K1+1],S[K2]),LCP(S[K2],S[i]))=min(LCP(S[K1+1],S[K2]),H[i])

    • 结论依然成立,则 height[rk[i]]height[rk[i1]]1 成立

代码实现

for (i = 1, k = 0; i <= n; ++i) { if (rk[i] == 0) continue; if (k) --k; while (s[i + k] == s[sa[rk[i] - 1] + k]) ++k; height[rk[i]] = k; }

倍增法构造SA

思路

将 比较字典序的二分求 LCP 转化为倍增求 LCP。
首先等效的认为在字符串的末尾增添无限个空字符 \0

按照通常的倍增思路:

  • 定义 S(i,k)=S[i,i+2k+1],即以 i 位置开头,长度为 2k 的子串。
  • 后缀 S[i]S[j] 的字典序关系等价于 S(i,)S(j,) 的字典序关系。
  • 事实上,只需要将 S(i,log2n)i=1,2,···,n 排序即可。

然后可以倍增的进行排序

  • 假设当前已经得到了 S(i,k) 的排序结果,
    rk[S(i,k)]sa[S(i,k)] ,思考如何利用它们排序 S(i,k+1)
  • 由于 S(i,k+1) 是由 S(i,k)S(i+2k,k) 前后拼接而成。
    • 因此比较 S(i,k+1)S(j,k+1) 字典序可以转化为先比较 S(i,k)S(j,k)
    • 再比较 S(i+2k,k)S(j+2k,k)
  • 因此可以将 S(i,k+1) 看作一个两位数,高位是 rk[S(i,k)],低位是 rk[S(i+2k,k)]

显然有 O(nlog2n) 的做法

#include<bits/stdc++.h> using namespace std; const int N = 1000010; char s[N]; int n, sa[N], rk[N << 1], oldrk[N << 1]; // 为了防止访问 rk[i+w] 导致数组越界,开两倍数组。 // 当然也可以在访问前判断是否越界,但直接开两倍数组方便一些。 int main() { int p; scanf("%s", s + 1); n = strlen(s + 1); for (int i = 1; i <= n; ++i) sa[i] = i, rk[i] = s[i]; for (int w = 1; w < n; w <<= 1) { sort(sa + 1, sa + n + 1, [](int x, int y) { return rk[x] == rk[y] ? rk[x + w] < rk[y + w] : rk[x] < rk[y]; }); // 这里用到了 lambda memcpy(oldrk, rk, sizeof(rk)); // 由于计算 rk 的时候原来的 rk 会被覆盖,要先复制一份 for (p = 0, i = 1; i <= n; ++i) { if (oldrk[sa[i]] == oldrk[sa[i - 1]] && oldrk[sa[i] + w] == oldrk[sa[i - 1] + w]) { rk[sa[i]] = p; } else { rk[sa[i]] = ++p; } // 若两个子串相同,它们对应的 rk 也需要相同,所以要去重 } } for (int i = 1; i <= n; ++i) printf("%d ", sa[i]); return 0; }

而对于两位数的排序,我们有基数排序

  • 将他们排序时,需要先按照高位排序,高位相同时,按照低位排序。此过程为基数排序
  • 实际代码运行时,先进行的是低位的排序。

此时借用一下,葫芦爷的课件!其实一直都在借用

后缀数组-基数排序

算法分析

时间复杂度:

  • 总共需要运行 logn 轮,每轮使用基数排序,复杂度为 O(n), 整体复杂度为 O(nlogn)

代码实现

直接干!

#include <algorithm> #include <cstdio> #include <cstring> #include <iostream> using namespace std; const int N = 1000010; char s[N]; int n, sa[N], rk[N << 1], oldrk[N << 1], id[N], cnt[N]; int main() { int m, p; scanf("%s", s + 1); n = strlen(s + 1); m = max(n, 300); // 先对长度为 1 的子串进行计数排序 for (int i = 1; i <= n; ++i) ++cnt[rk[i] = s[i]]; for (int i = 1; i <= m; ++i) cnt[i] += cnt[i - 1]; for (int i = n; i >= 1; --i) sa[cnt[rk[i]]--] = i; for (int w = 1; w < n; w <<= 1) { // 对第二关键字 rk[id[i] + w] 进行计数排序, id[i] 作为 sa[i] 的备份 memset(cnt, 0, sizeof(cnt)); for (int i = 1; i <= n; ++i) id[i] = sa[i]; for (int i = 1; i <= n; ++i) ++cnt[rk[id[i] + w]]; for (int i = 1; i <= m; ++i) cnt[i] += cnt[i - 1]; for (int i = n; i >= 1; --i) sa[cnt[rk[id[i] + w]]--] = id[i]; memset(cnt, 0, sizeof(cnt)); // 对第一关键字 rk[id[i]] 进行计数排序 for (int i = 1; i <= n; ++i) id[i] = sa[i]; for (int i = 1; i <= n; ++i) ++cnt[rk[id[i]]]; for (int i = 1; i <= m; ++i) cnt[i] += cnt[i - 1]; for (int i = n; i >= 1; --i) sa[cnt[rk[id[i]]]--] = id[i]; memcpy(oldrk, rk, sizeof(rk)); for (int p = 0, i = 1; i <= n; ++i) { if (oldrk[sa[i]] == oldrk[sa[i - 1]] && oldrk[sa[i] + w] == oldrk[sa[i - 1] + w]) { rk[sa[i]] = p; } else { rk[sa[i]] = ++p; } } } for (int i = 1; i <= n; ++i) printf("%d ", sa[i]); return 0; }

上述代码可以进行一些常数优化,即:

第二关键字无需计数排序

for (int i = n; i > n - w; --i) // 第二关键字无穷小先放进去 id[++p] = i; for (int i = 1; i <= n; ++i) if (sa[i] > w) id[++p] = sa[i] - w; // 顺次放入 s[sa[i]-w] 的第二关键字排名

优化计数排序值域

  • 每次对 rk 进行去重之后,我们都计算了一个 p ,这个 p 即是 rk 的值域,将值域赋值为 p
  • 将 rk[id[i]] 存下来,减少不连续内存访问, 这个优化在数据范围较大时效果非常明显。这个模板

若排名都不相同直接生成后缀数组
考虑新的 rk 数组,若其值域为 [1,n] 那么每个排名都不同,此时无需再排序。

常数优化版

/*height[i] = lcp(S[sa[i]],S[sa[i-1]]), h[i]=height[rk[i]], h[i]>=h[i-1]-1, lcp(s[i],s[j])=min(height[rk[i]+1],...,height[rk[j]])*/ int n, sa[maxn], rk[maxn], id[maxn], cnt[maxn], height[maxn], px[maxn]; void get_sa(const char* s, int _n) { // get sa and height n = _n; int m = 300, p = 0; // m 是值域, 初始化为字符集大小 for (int i = 0; i <= m; i++) cnt[i] = 0; for (int i = 1; i <= n; ++i) cnt[rk[i] = (int)s[i]] ++; // 先对1个字符大小的子串进行计数排序 for (int i = 1; i <= m; ++i) cnt[i] += cnt[i - 1]; for (int i = n; i >= 1; --i) sa[cnt[rk[i]]--] = i; for (int w = 1; w <= n; w <<= 1, m = p, p = 0) { // m=p 就是优化计数排序值域 for (int i = n - w + 1; i <= n; ++i) // 第二关键字无穷小先放进去 id[++p] = i; for (int i = 1; i <= n; ++i) if (sa[i] > w) id[++p] = sa[i] - w; // 顺次放入 s[sa[i]-w] 的第二关键字排名 for (int i = 0; i <= m; ++i) cnt[i] = 0; for (int i = 1; i <= n; ++i) ++cnt[rk[i]], px[i] = rk[id[i]]; for (int i = 1; i <= m; ++i) cnt[i] += cnt[i - 1]; for (int i = n; i >= 1; --i) sa[cnt[px[i]]--] = id[i]; for (int i = 1; i <= n; ++i) swap(rk[i], id[i]); rk[sa[1]] = p = 1; for (int i = 2; i <= n; ++i) { rk[sa[i]] = (id[sa[i]] == id[sa[i - 1]] && id[sa[i] + w] == id[sa[i - 1] + w] ? p : ++p); } if (p >= n) { // 排名已经更新出来了 break; } } } void get_height(const char* s){ for (int i = 1, k = 0; i <= n; ++i) { // 获取 height数组 if (k) --k; int j = sa[rk[i] - 1]; while (s[i + k] == s[j + k]) ++k; height[rk[i]] = k; } #ifdef _DEBUG for (int i = 1; i <= n; ++i) cout<<"height["<<i<<"] = "<<height[i]<<endl; #endif }

DC3 构造SA

待填

SA_IS

待填

应用

SA 数组性质应用

实现字符串最小表示

将字符串 S 复制一份变成 SS,找长度大于 |S| 字典序最小的的后缀对应位置就是最小表示位置

例题-[JSOI2007]字符加密


字符串中查找子串

  • 在线地在主串 T 中寻找模式串 S
  • 子串一定是是某个后缀的前缀。先跑一次 SA, 可以在 |S| 个后缀中按照和 T 的字典序关系进行二分查找,就可以找到是否出现。
  • 如果子串出现多次,如果多次出现可以再次二分查找,两次二分查找可以分别找出在 SA 中的最左位置和最右位置,并且可以依次得到出现位置。

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

  • 优化暴力,每次从正尾取是看正串和反串谁小,那么我们就把字符串拼成正串+反串,跑一次 SA。
  • 记录首尾位置,比较对应的“后缀”字典序即可!

例题-[USACO007DEC]Best Cow Line G


Height 数组性质应用

求最长公共子串

  • s,t 两串最长公共子串,先将两串用分割符拼接起来。

  • 遍历 tsa[] 中的所有位置,找到第一个小于 i 和第一个大于 i 的 s 的两个后缀,对应和 t[i] 取最长 LCP 即可,即用数据结构来查最值即可$。

  • 求本质不同公共子串个数

    • 类似上面的求法,只不过在每次计算的时候需要减去 T 的后缀 i 与上一个 T 的后缀的 LCP,然后取与 0 取max

比较字符串两个子串大小关系

  • 若比较 A=S[a,...,b],B=S[c...d] 大小关系
  • 如果 LCP(a,c)min(|A|,|B|),则A<B <-> |A| < |B|
    • 其中一个是另一个的前缀,长度小的字典序一定不会大于长度大的
  • 否则 A < B <-> rk[a] < rk[c]
    • 从两串下标为 [LCP(a,c)+1] 的位置开始看,谁小谁字典序更小

求本质不同子串数目

  • 按字典序从小到大枚举所有后缀,统计有多少个新出现的前缀即可。
  • 对于排名第 i 的后缀 S[sa[i],n],共有 nsa[i]+1 个前缀,其中有 Height[i]
    个前缀同时出现在前一个排名的后缀 S[sa[i1],n] 中,因此减掉即可。
  • 上述证明是不完整的,还需要证明所有在 S[sa[i], n] 中出现,但没有在
    S[sa[i1],n] 中出现的前缀,他们在所有更小排名的后缀串也都没有出
    现。
  • 证明就是,如果出现过会破坏我们求 lcp 时的性质。
  • 求本质不同同构子串数目(字符集大小为 3
    • 枚举所有字符集转换,共有 3! 种。将形成的 6 种字符串通过不同的分隔符分割,大串跑 SA
    • 统计大串 S 的本质不同子串数目,观察发现子串中有 2 种不同字符时会在 S 中出现 6 次总次数为 cntA,如果是只有一种字符只会出现 3 次总次数为 cntB,因此计算时需要将 cntB 补到 6 次进行计算,统计全 a/b/c 串只需要一个 for 循环记录最长连续相同子串即可记为 single,因此答案为 (cntA+cntB+single3)/6
    • 计算答案前还需要减去包含分隔符的子串,类似双指针的思想 ans -= (n + 1)*(len - i + 1)

查找出现 K 次的子串的最大长度。

  • 一个字符串出现 K 次表明在相邻的 k1 个 height 数组中均出现。
  • 那么只需要查找相邻 k1 个 height 数组的最小值的最大值即可,单调队列能 O(n), 也可以用区间最值查询。

洛谷P2852 [USACO06DEC]Milk Patterns G


是否有某字符串在文本串中至少不重叠地出现了两次

可以二分目标串的长度 |s|,将 height 数组划分成若干个连续 LCP 大于等于 |s| 的段,利用 RMQ 对每个段求其中出现的数中最大和最小的下标,若这两个下标的距离满足条件,则一定有长度为 |s| 的字符串不重叠地出现了两次。


连续的若干个相同子串

  • 询问 S 多少个子串满足优秀拆分,即拆分为 AABB 形式的串。
  • 按贡献考虑,观察 AA 和 BB 交界,记录 a[i],b[i] 分别代表 i 为 AA 串结尾, i 为 BB(与AA同理)串开头。
  • 那么最终答案是 Σi=1n1a[i]b[i+1]
  • O(n2) 加哈希可以拿 95 分,SA 正解参见 AcFunction's blog
  • 第一次做感觉边界没有搞得太明白。。同时注意下多组数据清空的问题。

洛谷 P1117 [NOI2016] 优秀的拆分


所有后缀之间 lcp 的加和。

  • 即求 height[] 数组所有区间的最小值加和
  • 定义状态 f[i] 表示前缀 i 的所有后缀区间的最小值之和。
  • 考虑 height[i]height[i-1] 的关系:
    • 如果 height[i] >= height[i - 1],f[i] 能取到 f[i - 1] 的所有值
    • 反之,height[i] 不能取到 f[i - 1] 的值,要往前继续找 小于等于 height[i] 的下标。
    • 上点可以用单调栈来维护左边第一个小于 height[i] 的下标。

洛谷P4248 [AHOI2013]差异


询问不超过 k 次修改单个字符的连续子串匹配个数

  • 给定两个字符串 S,T,询问对于 S 中所有子串,有多少个长度为 |T| 的联系子串满足不超过 K 次修改,能变成 T
  • 将 T 接在 S 的后面,跑一次 SA,然后用最多进行 k 次求 LCP 模拟匹配。

参考资料

OIWIKI-计数排序

OIWIKI-基数排序

OIWIKI-捅排序

牛客竞赛字符串-后缀数组


__EOF__

本文作者Roshin
本文链接https://www.cnblogs.com/Roshin/p/SA_notes.html
关于博主:评论和私信会在第一时间回复。或者直接私信我。
版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!
声援博主:如果您觉得文章对您有帮助,可以点击文章右下角推荐一下。您的鼓励是博主的最大动力!
posted @   Roshin  阅读(224)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 震惊!C++程序真的从main开始吗?99%的程序员都答错了
· winform 绘制太阳,地球,月球 运作规律
· 【硬核科普】Trae如何「偷看」你的代码?零基础破解AI编程运行原理
· 上周热点回顾(3.3-3.9)
· 超详细:普通电脑也行Windows部署deepseek R1训练数据并当服务器共享给他人
-->
点击右上角即可分享
微信分享提示