后缀数组(SA)学习笔记

后缀数组(SA)学习笔记

后缀数组(Suffix Array, SA),指对于一个字符串的所有后缀按字典序排序后得到的数组,以长度为第二关键字。

倍增求后缀数组

我们将用倍增法计算出两个数组:\(sa_i\) 表示字典序排名为 \(i\) 的后缀左端点,\(rk_i\) 表示左端点为 \(i\) 的后缀字典序排名。

从动态规划的角度考虑这个算法,令 \(sa_{i, j}\) 表示从 \(i\) 开始长度为 \(2^j\) 的子串在所有长度为 \(2^j\) 的状态中字典序排名。

如果能够转移,把第二维滚掉就可以得到想要的 \(sa\) 数组。

可以如此转移:对于位置 \(i\),定义一个二元组 \((sa_{i, j - 1}, sa_{i + 2^{j - 1}, j - 1})\),第二个元素超了就放 0,然后对于所有的二元组排序后就是 \(2^j\) 这个阶段的排名。

用快排是 \(O(n\log^2 n)\) 的,相当好写。

基数排序优化

用基数排序(基排,鸡排)可以做到 \(O(n\log n)\),即先对第二关键字桶排,然后对第一关键字桶排。

注意到对第二关键字排序的时候可以分为两部分,一部分是后面超了填 0 的部分,这部分直接放在最前面,另一部分就是上一次循环留下的 \(sa\),而对于所有 \(i, sa_i > j\)\(i\) 它一定会被 \(sa_i - j\) 作为第二关键字,通过从小到大地枚举 \(i\),直接在排序数组里面放 \(sa_i - j\) 就可以了,这样就完成了第二关键字的排序,注意这个方法是 不稳定的

第一关键字没办法老老实实写基排吧。

以下给出基数排序的实现。

代码实现

// Problem: P3809 【模板】后缀排序
// Copyright (c) 2023 Moyou All rights reserved.
// Date: 2024-01-17

#include <algorithm>
#include <cstring>
#include <iostream>
#include <queue>
//#define int long long
using namespace std;
typedef long long ll;
const int N = 2e6 + 10;

int n, sig, sa[N], sa2[N], buc[N], rk[N], x[N];
string s;
void SA() { // 18 行的巨压后缀数组
    for(int i = 1; i <= n; i ++) sig = max(sig, rk[i]), buc[rk[i]] ++; 
    for(int i = 1; i <= sig; i ++) buc[i] += buc[i - 1]; 
    for(int i = n; i; i --) sa[buc[rk[i]] --] = i; 
    for(int j = 1, idx = 0; idx < n; j *= 2, sig = idx) {
        idx = 0; for(int i = n - j + 1; i <= n; i ++) sa2[++ idx] = i;
        for(int i = 1; i <= n; i ++) 
            if(sa[i] > j) sa2[++ idx] = sa[i] - j;
        for(int i = 1; i <= n; i ++) x[i] = rk[sa2[i]];
        for(int i = 1; i <= sig; i ++) buc[i] = 0;
        for(int i = 1; i <= n; i ++) buc[x[i]] ++;
        for(int i = 1; i <= sig; i ++) buc[i] += buc[i - 1];
        for(int i = n; i; i --) sa[buc[x[i]] --] = sa2[i];
        swap(rk, sa2), idx = 1, rk[sa[1]] = 1;
        for(int i = 2; i <= n; i ++)
            if(sa2[sa[i]] == sa2[sa[i - 1]] && sa2[sa[i] + j] == sa2[sa[i - 1] + j]) rk[sa[i]] = idx;
            else rk[sa[i]] = ++ idx;
    }
} 

signed main() {
    ios::sync_with_stdio(0), cin.tie(0), cout.tie(0);
    cin >> s;
    n = s.size(), s = " " + s;
    for(int i = 1; i <= n; i ++) rk[i] = s[i];
    SA();
    for(int i = 1; i <= n; i ++) cout << sa[i] << ' ';
    
    return 0;
}

height 数组

光有 \(sa, rk\) 其实没大用,后缀数组主要靠 \(height\) 数组发挥作用。

\(height_i\)\(\text{LCP}(i - 1, i)\),注意这里的 \(\text{LCP}(a, b)\) 有特殊约定,表示 \(sa_a\)\(sa_b\) 的最长公共前缀。

这里有两个强力的引理。

Lemma 1

\[height_{rk_i}\ge height_{rk_{i - 1}} -1 \]

证明:其实比较显然

若令 \(j - 1 = sa_{rk_{i - 1} - 1}\),就是 \(sa\) 数组中与后缀 \(i\) 相邻的后缀,则 \(s_{j - 1}\sim s_{j - 1 + height_{rk_{i - 1}} - 1} = s_{i - 1}\sim s_{i - 1 + height_{rk_{i - 1}} - 1}\),这给出了 \(s_{j}\sim s_{j - 1 + height_{rk_{i - 1}} - 1} = s_{i}\sim s_{i - 1 + height_{rk_{i - 1}} - 1}\),这段匹配的长度是 \(height_{rk_{i - 1}} - 1\),证毕。(可以自己画图看看,这一坨我都不想看

Lemma 2

如果 \(sa_{rk_i} < sa_{rk_j}\)

\[\text{LCP}(i, j) = \min_{i < k\le j} \min (\text{LCP}(i, k), \text{LCP}(k, j)) = \min_{i < k\le j} \text{LCP}(k- 1, k) \]

第二个式子到第三个式子是显然的,证明第一个等式:

\(l = \min_{i < k\le j} \min (\text{LCP}(i, k), \text{LCP}(k, j))\),则 \(i, k\) 有至少 \(l\) 个前缀字符相同,\(j, k\) 有至少 \(l\) 个前缀字符相同,故 \(i, j\) 有至少 \(l\) 个前缀字符相同,所以 \(\text{LCP}(i, j)\ge l\)

反证法,假设 \(\text{LCP}(i, j) > l\),这说明 \(\text{LCP}(i, k) = \text{LCP}(k, j) = l\),也就是说 \(s_{i + l}\ne s_{k + l} \vee s_{j + l}\ne s_{k + l}\) 成立,且我们知道 \(s_{i + l} = s_{j + l}\),根据这两个式子可以推出矛盾,因为如果满足这两个式子,根据 \(sa\) 的性质,\(i, j\) 应该是相邻的,但是中间有一个 \(k\),所以矛盾,假设不成立。

综上 \(\text{LCP}(i, j) = l\)

根据引理 1,我们可以线性求出 \(height\) 数组,因为长度最多减少 \(1\)

根据引理 2,我们可以通过预处理 ST 表做到快速查询后缀 LCP。

代码实现

for(int i = 1, l = 0; i <= n; i ++) {
    if(l) l --;
    while(s[i + l] == s[sa[rk[i] - 1] + l]) l ++;
    ht[rk[i]] = l;
} // 超短

经典技巧

  1. 对字串长度有不等号限制的题目可以考虑二分答案然后进行对于满足条件的后缀分组。
  2. 对于多个字符串的问题,可以考虑拼接在一起考虑
  3. 对于回文串相关的问题,可以考虑把串的翻转拼接在串的后面考虑
posted @ 2024-01-19 21:45  MoyouSayuki  阅读(1)  评论(0)    收藏  举报
:name :name