后缀数组

创建时间:2026-02-06


后缀数组的概念

对于一个长度为 \(n\) 的字符串 \(s\),取出其所有后缀,按字典序从小到大排序。

定义其后缀数组(Suffix Array, SA)\(sa_1, sa_2, sa_3, \cdots, sa_n\) 表示排名为 \(1, 2, 3, \cdots, n\) 的后缀的起始下标。定义排名数组 \(rk_i\) 为以 \(i\) 开头的后缀的排名。

根据定义显然有 \(rk_{sa_i} = i\)

\(s = \textup{banana}\) 为例,我们有:

索引 后缀 排序后的后缀 \(sa\) \(rk\)
\(1\) \(\text{banana}\) \(\text{a}\) \(6\) \(4\)
\(2\) \(\text{anana}\) \(\text{ana}\) \(4\) \(3\)
\(3\) \(\text{nana}\) \(\text{anana}\) \(2\) \(6\)
\(4\) \(\text{ana}\) \(\text{banana}\) \(1\) \(2\)
\(5\) \(\text{na}\) \(\text{na}\) \(5\) \(5\)
\(6\) \(\text{a}\) \(\text{nana}\) \(3\) \(1\)

后缀数组的求法

倍增求后缀数组

看到P3809 【模板】后缀排序,给定一个字符串 \(s\)\(|s| \le 10^6\)),求其后缀数组。

\(n = |s|\)

最简单的做法是取出所有后缀,暴力排序并比较,复杂度 \(O(n^2 \log n)\),太慢了。

容易发现,暴力做法慢的原因是每次比较比较两个后缀的字典序大小都要 \(O(n)\) 的时间,所以考虑利用已知信息加速比较。

尝试倍增,每次先比较所有后缀的前 \(k\) 个字符,求出其 \(sa\)\(rk\),这样就可以通过以 \(rk_i\) 为第一关键字,\(rk_{i+k}\) 为第二关键字快速比较两个后缀的前 \(2k\) 个字符。

具体的,当且仅当 \(rk_i < rk_j\)\(rk_i = rk_j\)\(rk_{i+k} < rk_{j+k}\),以 \(i\) 开头的后缀的前 \(2k\) 个字符小于以 \(j\) 开头的后缀的前 \(2k\) 个字符(当 \(i+k>n\) 时认为 \(rk_{i+k} = 0\),即空字符串的字典序永远最小,\(j\) 同理)。

代码:

for (int i = 1; i <= n; i++)
    sa[i] = i, rk[i] = s[i];
for (int k = 1; k <= n; k <<= 1) {
    copy(rk + 1, rk + n + 1, oldrk + 1);
    sort(sa + 1, sa + n + 1, [=](int x, int y) {
        return oldrk[x] == oldrk[y] ?
            oldrk[x + k] < rk[y + k] : oldrk[x] < oldrk[y];
    });
    for (int j = 1; j <= n; j++) {
        rk[sa[j]] = rk[sa[j - 1]];
        if (oldrk[sa[j]] != oldrk[sa[j - 1]] ||
            oldrk[sa[j] + k] != oldrk[sa[j - 1] + k])
            rk[sa[j]]++;
    }
}

这样做的时间复杂度为 \(O(n \log^2 n)\),还不够优秀。

基数排序优化

回忆刚才的做法,算法瓶颈在按双关键字比较的快速排序,复杂度 \(O(n \log n)\)。众所周知,快速排序是基于比较的排序中最快的了,所以想优化必须使用非比较类排序算法。

考虑到在该问题中需要按双关键字排序,故自然想到使用基数排序(口胡一下):

先对所有可能的第二关键字排序,并 \(pos_i\) 为记录排名是 \(i\) 的第二关键字对应 \(s\) 中哪个后缀,这通过可以 \(sa\) 数组在 \(O(n)\) 的时间内快速实现。再计算第一关键字小于等于 \(i\) 的后缀数量 \(cnt_i\),即 \(\sum_{j=1}^n [rk_j \le i]\),得到排序后的 \(sa\) 数组。

具体实现见代码。

void suffix_sort(int m) {
    auto radix_sort = [](int m) {
        fill(cnt, cnt + m + 1, 0);
        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[pos[i]]]--] = pos[i];
    };
    copy(s.begin() + 1, s.end(), rk + 1);
    iota(pos + 1, pos + n + 1, 1);
    radix_sort(m);
    for (int k = 1, tot = 0; k < n && tot < n; k *= 2, m = tot) {
        tot = k;
        iota(pos + 1, pos + k + 1, n - k + 1);
        for (int i = 1; i <= n; i++)
            if (sa[i] > k)
                pos[++tot] = sa[i] - k;
        radix_sort(m);
        copy(rk + 1, rk + n + 1, pos + 1);
        rk[sa[1]] = tot = 1;
        for (int i = 2; i <= n; i++)
            rk[sa[i]] = pos[sa[i]] == pos[sa[i - 1]] && pos[sa[i] + k] == pos[sa[i - 1] + k] ? tot : ++tot;
    }
}

熟练后可以写得更加简洁:

void suffix_sort(int m = 128) {
    for (int i = 1; i <= n; i++)
        buc[rk[i] = s[i]]++;
    for (int i = 1; i <= m; i++)
        buc[i] += buc[i - 1];
    for (int i = n; i >= 1; i--)
        sa[buc[rk[i]]--] = i;
	for (int k = 1, tot = 0; k < n && tot < n; k *= 2, m = tot) {
		tot = k;
		iota(pos + 1, pos + k + 1, n - k + 1);
		for (int i = 1; i <= n; i++)
			if (sa[i] > k)
				pos[++tot] = sa[i] - k;
        fill(buc + 1, buc + m + 1, 0);
        for (int i = 1; i <= n; i++)
            buc[rk[i]]++;
        for (int i = 1; i <= m; i++)
            buc[i] += buc[i - 1];
        for (int i = n; i >= 1; i--)
            sa[buc[rk[pos[i]]]--] = pos[i];
		copy(rk + 1, rk + n + 1, pos + 1);
		rk[sa[1]] = tot = 1;
		for (int i = 2; i <= n; i++)
			rk[sa[i]] = pos[sa[i]] == pos[sa[i - 1]] && pos[sa[i] + k] == pos[sa[i - 1] + k] ? tot : ++tot;
	}
}

后缀数组与 LCP 问题

我们把两个字符串的最长公共前缀(Longest Common Prefix)叫做它们的 LCP。

定义 \(height_i\) 为以 \(sa_i\) 开头的后缀和以 \(sa_{i-1}\) 开头的后缀的 LCP 的长度。画图易证,\(height_{rk_i+1} \ge height_{rk_i} - 1\)

故可以在 \(O(n)\) 的时间内求出 \(height\) 数组。

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

同样可以证明,\(\forall 1 \le i < j \le n\)\(sa_i\)\(sa_j\) 的 LCP 长度等于 \(\min_{i < k \le j} \{height_k\}\)

这条性质,使得后缀数组(或 \(height\) 数组)可以解决很多和与 LCP 有关的问题,且常常与以 ST 表、单调队列为代表的和 RMQ 有关的数据结构,又或者是二分、单调栈等与单调性有关的算法结合。

习题

P4051 [JSOI2007] 字符加密(板)
P2463 [SDOI2008] Sandy 的卡片(后缀数组 + 单调队列)
P2336 [SCOI2012] 喵星球上的点名(后缀数组 + ST表 + 二分 + 树状数组/莫队)
P4248 [AHOI2013] 差异(后缀数组 + 单调栈)
P2178 [NOI2015] 品酒大会(后缀数组 + 并查集)
P5341 [TJOI2019] 甲苯先生和大中锋的字符串(后缀数组 + 单调队列)
P4094 [HEOI2016/TJOI2016] 字符串(后缀数组 + 二分答案 + ST表 + 二分 + 主席树)

posted @ 2026-06-02 16:56  xubaichuan  阅读(14)  评论(0)    收藏  举报