前言
不能完全不管字符串,就硬着头皮学一学。
完全未完工。
后缀数组
后缀数组主要指的是两个数组:
$sa_i$ 表示排名第 $i$ 小的后缀的起始位置;
$rk_i$ 表示从 $i$ 开始的后缀的排名。
这里的后缀大小比较的是字典序。
说白了以上两个数组的意思分别是 kth 和 rank。
很显然 $sa_{rk_i}=rk_{sa_i}=i$
建议好好记住这个定义,否则以后可能绕来绕去就糊涂了。
后缀数组求法
不讲线性求法了(我也不会),我们来说一说常见好写的倍增法。
我们不是要求 $sa$ 吗,显然有 $sa$ 数组中两个下标 $i<j$ 的条件是 $rk_{sa_i}<rk_{sa_j}$。
假设我们对从每个字符开始长度为 $w$ 的串排好序了。
然后我们试图得到长度为 $2w$ 的 $sa$ 数组。
我们只需要把 $sa$ 数组以 $rk_{i}$ 为第一关键字,$rk_{i+w}$ 为第二关键字排序就行了。得出 $sa$ 数组之后再更新一下 $rk$ 数组即可。
听起来很简单啊,算一下复杂度,如果用快速排序,时间复杂度是 $O(n\log^2n)$ 的。
但是!如果我们用基数排序而不是快速排序,时间复杂度会优化到 $O(n\log n)$。
这个基数排序就是万恶之源了。
啥是基数排序呢?就是对于多关键字的排序,先对第二关键字稳定排序,再对第一关键字稳定排序,就能对整个序列稳定排序。
然后如果对每个关键字排序时使用计数排序,复杂度是 $O(n)$,那么在求 SA 的时候排序复杂度也就是 $O(n)$ 了。
然后就是比较绕的地方了,如果头脑很困不清醒(比如我)可能就会被绕进去。
计数排序的原理说白了也很简单:算出比每个值小(或者说优先级更高)的值有几个,然后把这个值放到正确的位置里。
在这里我们需要计数的是 $rk$ 的值。
emmmm好像没什么可以说的了,该看代码理解了。
借用 OI Wiki 上的一份代码:
#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 i, m, p, w;
scanf("%s", s + 1);
n = strlen(s + 1);
m = max(n, 300);
for (i = 1; i <= n; ++i) ++cnt[rk[i] = s[i]];
for (i = 1; i <= m; ++i) cnt[i] += cnt[i - 1];
for (i = n; i >= 1; --i) sa[cnt[rk[i]]--] = i;
for (w = 1; w < n; w <<= 1) {
memset(cnt, 0, sizeof(cnt));
for (i = 1; i <= n; ++i) id[i] = sa[i];
for (i = 1; i <= n; ++i) ++cnt[rk[id[i] + w]];
for (i = 1; i <= m; ++i) cnt[i] += cnt[i - 1];
for (i = n; i >= 1; --i) sa[cnt[rk[id[i] + w]]--] = id[i]; // 对第二关键字进行计数排序
memset(cnt, 0, sizeof(cnt));
for (i = 1; i <= n; ++i) id[i] = sa[i];
for (i = 1; i <= n; ++i) ++cnt[rk[id[i]]];
for (i = 1; i <= m; ++i) cnt[i] += cnt[i - 1];
for (i = n; i >= 1; --i) sa[cnt[rk[id[i]]]--] = id[i]; // 对第一关键字进行计数排序
memcpy(oldrk, rk, sizeof(rk));
for (p = 0, i = 1; i <= n; ++i) { // 更新 rk 数组时需要注意:完全相同的串排名也应该一样。
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 (i = 1; i <= n; ++i) printf("%d ", sa[i]);
return 0;
}
然后上面的代码常数是很大的。
有以下的常数优化:
- 第二关键字无需计数排序,只需把超过范围的放到前面,剩下的按照原先顺序填进去即可。
- 优化值域:每次计数排序的值域可以设为每一次更新 $rk$ 时不同元素的个数。
- 用一个数组存下来 $rk_{id_i}$:减少不连续内存访问(我的盲区)。
- 用函数 cmp 来计算是否重复:减少不连续内存访问(我的盲区)。
- 若排名都不相同可直接生成后缀数组:如果 $rk$ 的值域已经达到了 $[1,n]$,那么已经排好序,无需接着生成数组。
下面是我写的优化过常数的代码:
#include <algorithm>
#include <cstdio>
#include <cstring>
#include <iostream>
using std::cerr;
using std::cin;
using std::cout;
using std::endl;
const int maxn = 1e6 + 10;
char s[maxn];
int n, p, sa[maxn], rk[maxn << 1], tmp[maxn], cnt[maxn], oldrk[maxn << 1], val[maxn];
inline bool equ(int i, int j, int dep) { return oldrk[i] == oldrk[j] && oldrk[i + dep] == oldrk[j + dep]; }
int main()
{
scanf("%s", s + 1);
n = strlen(s + 1);
int m = 300, p;
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 dep = 1; dep <= n; dep <<= 1, m = p)
{
p = 0;
for (int i = n - dep + 1; i <= n; i++) // 把超过去的放到前面
val[++p] = i;
for (int i = 1; i <= n; i++) // 剩下的按照原顺序填进去
if (sa[i] > dep)
val[++p] = sa[i] - dep;
// val 是按照第二关键字排序后的 sa 数组的值
memset(cnt, 0, sizeof(cnt));
for (int i = 1; i <= n; i++)
cnt[tmp[i] = rk[val[i]]]++; // tmp 是常数优化用的
for (int i = 1; i <= m; i++)
cnt[i] += cnt[i - 1]; // 对计数排序用的数组求前缀和来得到元素的位置
for (int i = n; i >= 1; i--)
sa[cnt[tmp[i]]--] = val[i]; // 对于排序前数组的每个值分配一个新位置
std::swap(rk, oldrk);
p = 0;
for (int i = 1; i <= n; i++)
rk[sa[i]] = equ(sa[i], sa[i - 1], dep) ? p : ++p; // 更新 rk 数组
if (p == n) // 最后一个优化
break;
}
for (int i = 1; i <= n; i++)
printf("%d ", sa[i]);
return 0;
}
应用
咕咕咕