后缀数组基础 Suffix Array

将字符串 \(s\) 的所有后缀按字典序排序。

SA 算法主要求以下数组:

  • \(\text{sa}_i\):排名为 \(i\) 的后缀的下标。

  • \(\text{rk}_i\):下标以 \(i\) 开始的排名。

  • \(\text{ht}_i\)\(\text{height}\) 数组。

    • \(\text{ht}_1 = 0,\text{ht}_i=\text{lcp}(\text{sa}[i],\text{sa}[i-1])\)\(\text{lcp}(i,j)\) 表示后缀 \(i\) 和后缀 \(j\) 的最长公共前缀的长度。
    • 即第 \(i\) 小的后缀与第 \(i-1\) 小的后缀的最长公共前缀的长度。

求 sa 和 rk

\(n = |s|\)

根据定义有 \(\text{sa}[\text{rk}[i]]=\text{rk}[\text{sa}[i]] = i\)

考虑倍增求:

\(\text{rk}_w[i]\) 为字符串 \(s[i:i+2^w-1]\) 在所有长度为 \(2^w\)\(s\) 的字串的排名。

可设 \(\text{rk}_0[i] = s[i]\)(此时是相对排名)。

设已经求出所有 \(\text{rk}_w[i]\),考虑求 \(\text{rk}_{w+1}[i]\)

  1. \(\text{rk}_w[i]\) 作为第一关键字,\(\text{rk}_w[i+2^w]\) 作为第二关键字进行排序,存到 \(\text{sa}\) 里。若 \(i+2^w > n\),可视 \(\text{rk}_{w}[i+2^w]\)\(0\),即最小。
  2. 通过 \(\text{sa}\) 反推出 \(\text{rk}_{w+1}[i]\)
  3. 不断重复上述过程,直到 \(2^w > n\)

最多会倍增 \(O(\log n)\) 次,每次排序的时间复杂度为 \(O(n\log n)\),总时间复杂度为 \(O(n\log^2 n)\)

考虑排序用基数排序 + 计数排序,这样每次排序的时间复杂度为 \(O(n)\),总时间复杂度为 \(O(n\log n)\)

/* OI wiki */
#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;
}

但是上面代码常数太大,考虑进行一些常数优化。

  • 第二关键字排序优化

    其实上一轮的 \(\text{sa}\) 已经求出 \(\text{rk}_w[i+2^w]\) 的顺序,直接用就行。省略的对第二关键字的计数排序。

  • 计数排序值域优化

    每轮的排名都没排到 \(n\),记录一个 \(m\) 表示当前最大的排名,可以减少计数排序的常数。

  • 倍增次数优化

    若当前的排名排到了 \(n\),说明所有的后缀只用前 \(2^{w+1}\) 个字符就确定了顺序,再往后比较就没有意义了,此时结束算法即可。

最终代码:空间复杂度 \(O(n)\),时间复杂度\(O(n\log n)\),常数小

const int N = 1e6 + 5;
char s[N];
int n, sa[N], rk[N], tp[N], cnt[N];
void SA() {
	int m = 127;
	rep(i, 1, n) cnt[rk[i] = s[i]]++;
	rep(i, 1, m) cnt[i] += cnt[i - 1];
	per(i, 1, n) sa[cnt[rk[i]]--] = i;
	for(int w = 1, p; ; w <<= 1, m = p /* 计数排序值域优化 */)
	{
        /* 第二关键字排序优化 */
		p = 0;
		rep(i, n - w + 1, n) tp[++p] = i;
		rep(i, 1, n) if(sa[i] > w) tp[++p] = sa[i] - w;

        /* 对第一关键字排序 */
		memset(cnt, 0, sizeof(cnt));
		rep(i, 1, n) cnt[rk[i]]++;
		rep(i, 1, m) cnt[i] += cnt[i - 1];
		per(i, 1, n) sa[cnt[rk[tp[i]]]--] = tp[i];

        /* 用 sa 反推 rk */
		memcpy(tp, rk, sizeof(tp));
		rk[sa[1]] = p = 1;
		rep(i, 2, n) rk[sa[i]] = (tp[sa[i]] == tp[sa[i - 1]] && tp[min(sa[i] + w, n + 1)] == tp[min(sa[i - 1] + w, n + 1)]) ? p : ++p;
		if(p == n) break; /* 倍增次数优化 */
	}
}

求 height 数组

引理:\(\text{ht}[\text{rk}[i]] \ge \text{ht}[\text{rk}[i-1]]-1\)

证明:

\(\text{ht}[\text{rk}[i-1]] \le 1\),原式显然成立。

\(\text{ht}[\text{rk}[i-1]] > 1\)

考虑 \(\text{ht}[\text{rk}[i-1]]\)

height_array_1.png

将这两个字符串的第一个字符去掉:

height_array_2.png

橙色部分是可能多匹配的 \(\text{lcp}\)

因为后缀 \(\text{sa}[\text{rk}[i-1]-1]\) 和后缀 \(i - 1\)\(\text{lcp}\) 长度 \(>1\) ,则去掉一个字符后,后缀 \(\text{sa}[\text{rk}[i-1]+1]\) 还是还是排在后缀 \(i\) 前面。

后缀 \(\text{sa}[\text{rk}[i]-1]\) 是排在后缀 \(i\) 的前一个,又因为这是按字典序排的,所以后缀 \(\text{sa}[\text{rk}[i]-1]\) 和后缀 \(i\)\(\text{ht}[\text{rk}[i-1]]-1\) 个字符必定相同,即 \(\text{ht}[\text{rk}[i]] \ge \text{ht}[\text{rk}[i-1]]-1\)

证毕。

代码实现:时间复杂度 \(O(n)\)

for(int i = 1, k = 0; i <= n; i++)
{
    k -= !!k;
    while(s[i + k] == s[sa[rk[i] - 1] + k]) k++;
    ht[rk[i]] = k;
}

应用:

1. 求两个后缀的最大 \(\text{lcp}\) 长度\(\text{lcp}(\text{sa}[i],\text{sa}[j]) = \min\limits_{i+1\le k\le j}\{\text{ht}[k]\}\),可以结合树状数组。

2. 比较两个字串字典序大小:比较 \(A=s[a:b]\)\(B=s[c:d]\)

  • \(\text{lcp}(a,c) \ge \min(|A|,|B|), A < B \Leftrightarrow |A| < |B|\)\(A\)\(B\) 的前缀或 \(B\)\(A\) 的前缀。

  • \(\text{lcp}(a,c) < \min(|A|,|B|), A < B \Leftrightarrow \text{rk}[a] < \text{rk}[c]\)

posted @ 2025-09-23 08:43  kuailedetongnian  阅读(17)  评论(1)    收藏  举报