后缀数组SA

~ 2026-5-16 9:59:37

一些约定

字符串下标从 11 开始.

字符串 ss 的长度为 nn

" 后缀 ii" 代指以第 ii 个字符开头的后缀,存储时用 ii 代表字符串 ss 的后缀 s[in]s[i\dots n]

后缀数组是什么?

后缀数组(Suffix Array)主要关系到两个数组:sasarkrk

其中,sa[i]sa[i] 表示将所有后缀排序后第 ii 小的后缀的编号,也是所说的后缀数组,后文也称编号数组 sasa

rk[i]rk[i] 表示后缀 ii 的排名,是重要的辅助数组,后文也称排名数组 rkrk

这两个数组满足性质:sa[rk[i]]=rk[sa[i]]=isa[rk[i]]=rk[sa[i]]=i

解释

后缀数组示例:

后缀数组怎么求?

O(n^2logn) 做法

相信这个做法大家还是能自己想到的:将盛有全部后缀字符串的数组进行 sort 排序,由于排序进行 O(nlogn)O(n\log n) 次字符串比较,每次字符串比较要 O(n)O(n) 次字符比较,所以这个排序是 O(n2logn)O(n^2\log n) 的时间复杂度.

O(nlog^2n) 做法

这个做法要用到倍增的思想.

首先对字符串 ss 的所有长度为 11 的子串,即每个字符进行排序,得到排序后的编号数组 sa1sa_1 和排名数组 rk1rk_1

倍增过程:

  1. 用两个长度为 11 的子串的排名,即 rk1[i]rk_1[i]rk1[i+1]rk_1[i+1],作为排序的第一第二关键字,就可以对字符串 ss 的每个长度为 22 的子串:{s[imin(i+1,n)]  i[1, n]}\{s[i\dots \min(i+1, n)]\ |\ i \in [1,\ n]\} 进行排序,得到 sa2sa_2rk2rk_2

  2. 之后用两个长度为 22 的子串的排名,即 rk2[i]rk_2[i]rk2[i+2]rk_2[i+2],作为排序的第一第二关键字,就可以对字符串 ss 的每个长度为 44 的子串:{s[imin(i+3,n)]  i[1, n]}\{s[i\dots \min(i+3, n)]\ |\ i \in [1,\ n]\} 进行排序,得到 sa4sa_4rk4rk_4

  3. 以此倍增,用长度为 w/2w/2 的子串的排名,即 rkw/2[i]rk_{w/2}[i]rkw/2[i+w/2]rk_{w/2}[i+w/2],作为排序的第一第二关键字,就可以对字符串 ss 的每个长度为 ww 的子串 s[imin(i+w1, n)]s[i\dots \min(i+w-1,\ n)] 进行排序,得到 sawsa_wrkwrk_w.其中,类似字母序排序规则,当 i+w>ni+w>n 时,rkw[i+w]rk_w[i+w] 视为无穷小;

  4. rkw[i]rk_w[i] 即是子串 s[ii+w1]s[i\dots i + w - 1] 的排名,这样当 wnw \geqslant n 时,得到的编号数组 sawsa_w,也就是我们需要的后缀数组.

过程

倍增排序示意图:

显然倍增的过程是 O(logn)O(\log n),而每次倍增用 sort 对子串进行排序是 O(nlogn)O(n\log n),而每次子串的比较花费 22 次字符比较;

除此之外,每次倍增在 sort 排序完后,还有额外的 O(n)O(n) 时间复杂度的,更新 rkrk 的操作,但是相对于 O(nlogn)O(n\log n) 被忽略不计;

所以这个算法的时间复杂度就是 O(nlog2n)O(n\log^2n). ```cpp #include #include #include #include

using namespace std;

constexpr int N = 1000010;

char s[N];
int n, w, sa[N], rk[N << 1], oldrk[N << 1];

// 为了防止访问 rk[i+w] 导致数组越界,开两倍数组.
// 当然也可以在访问前判断是否越界,但直接开两倍数组方便一些.

int main() {
  int i, p;

  scanf("%s", s + 1);
  n = strlen(s + 1);
  for (i = 1; i <= n; ++i) sa[i] = i, rk[i] = s[i];

  for (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 会被覆盖,要先复制一份
    // 若两个子串相同,它们对应的 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;
      }
    }
  }

  for (i = 1; i <= n; ++i) printf("%d ", sa[i]);

  return 0;
}
```

O(nlogn) 做法

在刚刚的 O(nlog2n)O(n\log^2n) 做法中,单次排序是 O(nlogn)O(n\log n) 的,如果能 O(n)O(n) 排序,就能 O(nlogn)O(n\log n) 计算后缀数组了.

前置知识:[计数排序][基数排序]

由于计算后缀数组的过程中排序的关键字是排名,值域为 O(n)O(n),并且是一个双关键字的排序,可以使用基数排序优化至 O(n)O(n)

```cpp
#include <algorithm>
#include <cstdio>
#include <cstring>
#include <iostream>

using namespace std;

constexpr 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 = 127;
  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;
  memcpy(oldrk + 1, rk + 1, n * sizeof(int));
  for (p = 0, i = 1; i <= n; ++i) {
    if (oldrk[sa[i]] == oldrk[sa[i - 1]]) {
      rk[sa[i]] = p;
    } else {
      rk[sa[i]] = ++p;
    }
  }

  for (w = 1; w < n; w <<= 1, m = n) {
    // 对第二关键字:id[i] + w进行计数排序
    memset(cnt, 0, sizeof(cnt));
    memcpy(id + 1, sa + 1,
           n * sizeof(int));  // id保存一份儿sa的拷贝,实质上就相当于oldsa
    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];

    // 对第一关键字:id[i]进行计数排序
    memset(cnt, 0, sizeof(cnt));
    memcpy(id + 1, sa + 1, n * sizeof(int));
    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 + 1, rk + 1, n * sizeof(int));
    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;
      }
    }
  }

  for (i = 1; i <= n; ++i) printf("%d ", sa[i]);

  return 0;
}
```

一些常数优化

如果你把上面那份代码交到 LOJ #111: 后缀排序 上:

这是因为,上面那份代码的常数的确很大.

第二关键字无需计数排序

思考一下第二关键字排序的实质,其实就是把超出字符串范围(即 sa[i]+w>nsa[i] + w > n)的 sa[i]sa[i] 放到 sasa 数组头部,然后把剩下的依原顺序放入:

int cur = 0;
for (int i = n - w + 1; i <= n; i++) id[++cur] = i;
for (int i = 1; i <= n; i++)
  if (sa[i] > w) id[++cur] = sa[i] - w;

优化计数排序的值域

每次对 rkrk 进行更新之后,我们都计算了一个 pp,这个 pp 即是 rkrk 的值域,将值域改成它即可.

若排名都不相同可直接生成后缀数组

考虑新的 rkrk 数组,若其值域为 [1,n][1,n] 那么每个排名都不同,此时无需再排序.

```cpp
#include <algorithm>
#include <cstdio>
#include <cstring>
#include <iostream>

using namespace std;

constexpr int N = 1000010;

char s[N];
int n;
int m, p, rk[N * 2], oldrk[N], sa[N * 2], id[N], cnt[N];

int main() {
  scanf("%s", s + 1);
  n = strlen(s + 1);
  m = 128;

  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 <<= 1, m = p) {  // m = p 即为值域优化
    int cur = 0;
    for (int i = n - w + 1; i <= n; i++) id[++cur] = i;
    for (int i = 1; i <= n; i++)
      if (sa[i] > w) id[++cur] = sa[i] - w;

    memset(cnt, 0, sizeof(cnt));
    for (int i = 1; i <= n; i++) cnt[rk[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];

    p = 0;
    memcpy(oldrk, rk, sizeof(oldrk));
    for (int 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;
    }

    if (p == n) break;  // p = n 时无需再排序
  }

  for (int i = 1; i <= n; i++) printf("%d ", sa[i]);

  return 0;
}
```


我们会审查剪贴板内容,并对发布不合适内容的同学进行相应的处理