你说得对,但是我怎么没学过后缀科技啊???

后缀数组 (SA)

后缀数组(SA,Suffix Array)最基础的应用是,可以将给定串 \(S\) 的所有后缀串排序。

一点定义:\(sa_i\) 表示第 \(i\) 小的后缀的编号,\(rk_i\) 表示后缀 \([i,n]\) 的排名。显然 \(sa_{rk[i]}=rk_{sa[i]}=i\)

后缀数组就是 \(sa\) 数组,我们可以利用后缀排序(SS,Suffix Sort)求出后缀数组。

\(\text{ }\)

后缀排序

首先 \(O(N^2\log N)\) 的做法是平凡的。

我们考虑优化暴力。我们先比较第一位,肯定能把所有后缀分成若干组。这个时候,暴力做法就是直接去比较第二位,但是\(2\) 位刚才已经比较过了!我们用某种方法按前两位分好组,发现\(3,4\) 位也应当比较过了。以此类推,我们发现过程类似一个倍增。这也就是后缀排序的大致思想了——利用倍增,只需要进行 \(O(\log N)\) 次分组操作。

稍微整合一下流程:

  • 依照前 \(1\) 位排名。直接把字符对应成编号即可。
  • 依照前 \(2\) 位排名。将 \((rk_i,rk_{i+1})\) 进行双关键字排序,更新 \(sa\) 数组,并重标号 \(rk\) 数组。此时 \(rk_i\) 记录 \([i,i+1]\)
  • 依照前 \(4\) 位排名。将 \((rk_i,rk_{i+2})\) 进行双关键字排序,恰好对应了 \([i,i+1]\cup[i+2,i+3]=[i,i+3]\)。更新 \(sa,rk\) 数组。此时 \(rk_i\) 记录 \([i,i+3]\)
  • 同样道理,不断倍增 \(k\),每次依照前 \(k\) 位排名。将 \((rk_i,rk_{i+k/2})\) 进行双关键字排序,恰好对应了 \([i,i+k/2-1]\cup[i+k/2,i+k-1]=[i,i+k-1]\)。更新 \(sa,rk\) 数组。此时 \(rk_i\) 记录 \([i,i+k-1]\)
  • \(k\ge n\) 时,所得到的 \(sa\) 就是我们的后缀数组。

下面是从洛谷题解盗过来的图。

模拟上述过程做到 \(O(N\log^2 N)\)注意双倍空间。代码相当好写:

void SuffixSort() {
	for (int i = 1; i <= n; i++) sa[i] = i, rk[i] = s[i - 1];
	for (int k = 1; k <= n; k <<= 1) {
		sort(sa + 1, sa + n + 1, [&](int x, int y) {return rk[x] != rk[y] ? rk[x] < rk[y] : rk[x + k] < rk[y + k];});
		for (int i = 1, idx = 0; i <= n; i++) _rk[sa[i]] = (rk[sa[i - 1]] == rk[sa[i]] && rk[sa[i - 1] + k] == rk[sa[i] + k] ? idx : ++idx);
		for (int i = 1; i <= n; i++) rk[i] = _rk[i];
	}
}

注意到值域 \(O(N)\),将 std::sort 改为计数排序做到 \(O(N\log N)\)

下面代码还加入了点常数优化(参考 OI-Wiki 对应页面):

  • 第二关键字无需计数排序,因为你上一轮排好了啊。把 \(sa_i+k>n\) 的放前面,其他顺序放后面即可。
  • 提前退出。如果发现 \(idx=n\) 说明已经排好了,直接跳车即可。
void srt() {
	for (int i = 1; i <= idx; i++) cnt[i] = 0;
	for (int i = 1; i <= n; i++) cnt[rk[i]]++;
	for (int i = 1; i <= idx; i++) cnt[i] += cnt[i - 1];
	for (int i = n; i; i--) _sa[cnt[rk[sa[i]]]--] = sa[i];
	for (int i = 1; i <= n; i++) sa[i] = _sa[i];
}
void SuffixSort() {
	for (int i = 1; i <= n; i++) sa[i] = i, rk[i] = s[i - 1];
	idx = 128, srt();
	for (int k = 1; k <= n; k <<= 1) {
		int cur = 0; for (int i = n - k + 1; i <= n; i++) _sa[++cur] = i;
		for (int i = 1; i <= n; i++) if (sa[i] > k) _sa[++cur] = sa[i] - k;
		for (int i = 1; i <= n; i++) sa[i] = _sa[i];
		srt();

		idx = 0; for (int i = 1; i <= n; i++) _rk[sa[i]] = (rk[sa[i - 1]] == rk[sa[i]] && rk[sa[i - 1] + k] == rk[sa[i] + k] ? idx : ++idx);
		for (int i = 1; i <= n; i++) rk[i] = _rk[i]; if (idx == n) break;
	}
}

前者洛谷跑 3.67s,后者跑 560ms,一目了然。

线性科技我不会。

\(\text{ }\)

⭐ LCP 与 height 数组

定义后缀数组上的函数 \(\operatorname{LCP}(i,j)=\operatorname{lcp}(sa_i,sa_j)\),即两后缀的最长公共前缀。

下面是它的一些显然的性质:

  1. \(\operatorname{LCP}(i,j)=\operatorname{LCP}(j,i)\)
  2. \(\operatorname{LCP}(i,i)=n-sa_i+1\)
  3. 对于 \(\forall i\le k\le j,\operatorname{LCP}(i,j)=\min(\operatorname{LCP}(i,k),\operatorname{LCP}(k,j))\)
  4. 推论:\(\operatorname{LCP}(i,j)=\min\limits_{k=i+1}^{j}\operatorname{LCP}(k-1,k) \triangleq\min\limits_{k=i+1}^{j}h_k\)

依据性质 \(4\),于是只需要求出 \(\color{red}h_i=\operatorname{LCP}(i-1,i)\)。这个就是 height 数组了。

我们只需要利用一条关键性质,就能完成线性求解 height 数组!

\(\textbf{Lemma. }\)

\[\forall i\ge2, h[rk_i]\ge h[rk_{i-1}]-1 \]

\(\textbf{Proof.}\)
\(h[rk_{i-1}]=0\) 显然成立,下文讨论 \(\operatorname{LCP}\) 至少为 \(1\) 的情况。
由定义,\(h[rk_{i-1}]=\operatorname{LCP}(i-1,sa[rk_{i-1}-1])\triangleq\operatorname{LCP}(i-1,p)\),把两后缀的第一个字符抹去,得到 \(h[rk_{i-1}]-1=\operatorname{LCP}(i,p+1)\)。于是只需证明 \(h_i\ge\operatorname{LCP}(i,p+1)\),而这是显然的呀,因为 "\([i,n]\) 和它前一位的 LCP" 肯定比 "\([i,n]\) 和它前 \(114514\) 位的 LCP" 要大。\(\square\)

于是直接暴力,均摊复杂度线性。可以参考代码:

for (int i = 1, p = 0; i <= n; i++) {
	if (p) p--; while (s[sa[rk[i]] + p] == s[sa[rk[i] - 1] + p]) p++;
	h[rk[i]] = p;
}

\(\text{ }\)

任意两后缀 LCP

依据上文,求出 height 数组后转化为 RMQ 问题,单次询问做到 \(O(1)\)

\(\text{ }\)

循环串排序

将原串 \(S\) 复制一遍在后面,对 \(T=S+S\) 后缀排序,稍微处理下输出即可。\(O(\text{SA}+n)\)

\(\text{ }\)

字符串子串比大小

子串是诈骗的,可以直接比后缀,求出 height 数组,只需要特判 \(\operatorname{LCP}(L_1,L_2)\ge\min(R_1-L_1+1,R_2-L_2+1)\) 时比较两子串长度。否则直接比 \(rk\) 数组即可。单次询问做到 \(O(1)\),比二分哈希强。

小应用,这个直接 \(S+(\neg S)\) 后缀排序,只比 \(rk\) 就够了,不用写 height。

\(\text{ }\)

不同子串个数

考虑 \(\dfrac{n(n+1)}2\) 减重复部分。注意到子串是后缀的前缀,后缀排序后,每个串与前一个串的公共前缀都是多算的(注意这实质是将每个重复子串在左端点去除贡献)。于是答案即 \(\dfrac{n(n+1)}2-\sum h_i\)\(O(\text{SA}+n)\)

\(\text{ }\)

最长公共子串

子串是后缀的前缀,对 \(S+\textbf{#}+T\) 后缀排序,根据 LCP 理论,只需要在相邻两后缀中找到最大的 LCP,同时满足两后缀恰好一个在 \(S\) 一个在 \(T\)。求出 height 数组后不难实现。\(O(\text{SA}+n)\)

\(\text{ }\)

多个串的最长公共子串

类似上题,对 \((S_1+\textbf{#}+S_2+\textbf{#}+\cdots+\textbf{#}+S_k)\) 后缀排序,并对对应后缀标记所处串,相当于要找到一个区间 \([l,r]\) 满足出现过所有串,且 \(\min\limits_{l<i\le r} h_i\) 最大。双指针 + ST 表做到 \(O(\text{SA}+n\log n)\)

\(\text{ }\)

出现至少 k 次的子串的最大长度

子串是后缀的前缀,相当于原串后缀排序后有至少 \(k\)连续后缀都包含这个子串作为前缀。所以求个 height 数组,每相邻 \(k-1\) 个 height 的最小值再全部取 max,滑动窗口解决。\(O(\text{SA}+n)\)

弱化:BZOJ5673,\(k=2\),输出 height 数组的最大值即可。

\(\text{ }\)

\(\text{ }\)

Reference

SA 部分:

posted @ 2025-02-04 19:15  liangbowen  阅读(28)  评论(0)    收藏  举报