后缀数组 SA

定义 \(sa_i\) 为字典序第 \(i\) 小的后缀的开头位置。

定义 \(rk_i\)\(i\) 开头的后缀在所有后缀中的相对大小。

\(sa_{rk_i}=i,rk_{sa_i}=i\)

后缀排序

快速求 \(sa_i,rk_i\)。这里给 \(O(n\log n)\) 的做法。

考虑倍增维护,记当前倍增长度为 \(len\)。那么对于两个长度为 \(len\) 的字符串 \(s_1,s_2\),有:

  1. \(s_{1,1\dots \frac{len}{2}} < s_{2,1\dots \frac{len}{2}}\)。则 \(s_1 < s_2\)
  2. \(s_{1,1\dots \frac{len}{2}} > s_{2,1\dots \frac{len}{2}}\)。则 \(s_1 > s_2\)
  3. \(s_{1,1\dots \frac{len}{2}} = s_{2,1\dots \frac{len}{2}}\)
    1. \(s_{1,\frac{len}{2}+1\dots len} < s_{2,\frac{len}{2}+1\dots len}\)。则 \(s_1 < s_2\)
    2. \(s_{1,\frac{len}{2}+1\dots len} > s_{2,\frac{len}{2}+1\dots len}\)。则 \(s_1 > s_2\)
    3. \(s_{1,\frac{len}{2}+1\dots len} = s_{2,\frac{len}{2}+1\dots len}\)。则 \(s_1 = s_2\)

因为我们是倍增,所以已经求出 \(len' = \frac{len}{2}\) 时任意两个串的相对大小了,所以只需要将两个 \(\frac{len}{2}\) 的连续字符串的 \(rk\) 看成两个关键字,然后排序。

直接基数排序可以做到 \(O(n\log n)\)。优化常数可以考虑按照第二关键字排序的时候实际上 \(\frac{len}{2}+1 > n\) 的位置都会排在前面,且剩余的可以直接枚举 \(sa_i\) 插入。形如:

U(i, n - w + 1, n) id[++ idx] = i;
U(i, 1, n) if(sa[i] > w) id[++ idx] = sa[i] - w;

这里 \(id_x\) 为第二关键字排序后第 \(i\) 小的长度为 \(2w\) 的开头。那么后缀排序可以写成:

int m = 128, idx = 0;
U(i, 1, n) ++ cnt[rk[i] = s[i]];
U(i, 1, m) cnt[i] += cnt[i - 1];
D(i, n, 1) sa[cnt[rk[i]] --] = i;
for(re int w = 1; ; w *= 2){
	idx = 0;
	U(i, n - w + 1, n) id[++ idx] = i;
	U(i, 1, n) if(sa[i] > w) id[++ idx] = sa[i] - w;
	U(i, 0, m) cnt[i] = 0;
	U(i, 1, n) ++ cnt[rk[i]];
	U(i, 1, m) cnt[i] += cnt[i - 1];
	D(i, n, 1) sa[cnt[rk[id[i]]] --] = id[i];
	U(i, 1, n) rk_[i] = rk[i];
	m = 0;
	U(i, 1, n){
		if(rk_[sa[i]] == rk_[sa[i - 1]] && rk_[sa[i] + w] == rk_[sa[i - 1] + w]) rk[sa[i]] = m;
		else rk[sa[i]] = ++ m;
	}
	if(n == m) break;
}

\(\operatorname{height}\) 数组

定义 \(height_i = \operatorname{LCP}(sa_{i-1},sa_i)\)。也就是相对大小相邻的两个后缀的最长公共前缀。

有引理:\(height_{rk_i}\ge height_{rk_{i-1}}-1\)。证明是简单的,那么可以 \(O(n)\) 求出 \(height\) 数组了。

U(i, 1, n){
	ht[rk[i]] = max(0, ht[rk[i - 1]] - 1);
	while(s[i + ht[rk[i]]] == s[sa[rk[i] - 1] + ht[rk[i]]]) ++ ht[rk[i]];
}

一些题目

CF316G3 Good Substrings

把所有字符串放一起跑个 SA。

对于第 \(i\) 个后缀,找到前面第一个也是 \(s\) 后缀的位置 \(j\)。那么所有 \(s_{sa_i,sa_{i}+1,\dots, sa_i + k}\) 满足 \(k \ge \operatorname{lcp}(j,i)\) 的子串都是之前没被算过的。

考虑找到最小的一个 \(k_0\),使得 \(k =k_0\) 满足对于所有都 \(\ge l_i\)。同理找到最大的 \(k_1\)。那么答案就是 \(\max(0,k_1 - \max(\operatorname{lcp}(j,i),k_0))\)

对于第 \(i\) 个限制,因为 \(s_{l\dots r}\)\(p_i\) 的子串,所以排序后位置应该相邻。找到最小的 \(j_0\) 和最大的 \(j_1\) 使得 \(\operatorname{lcp}(j_0,i) \ge k,\operatorname{lcp}(j_1,i)\ge k\) 就行了。然后看和 \(l\) 的关系。

时间复杂度 \(O((\sum |s_i|) \log( \sum |s_i|) + |s_1|\log |s_1|\sum (\log |s_i|))\)

P6793 [SNOI2020] 字符串

考虑将 \(A\)\(B\) 找出来,那么最优的策略就是将 \(A\) 按照字典序,\(B\) 按照字典序排序。

答案相当于是 \(a\)\(n-k+1\) 个后缀,在 SA 中编号 \(p_1,p_2,\dots,p_{n-k+1}\)\(b\) 的为 \(q_1,\dots, q_{n-k+1}\)。要给它们一一配对,使得 \(\sum \max(0,k - \operatorname{lcp(p_i,q_i)})\) 最小。

因为 \(\min(a,b) + \min(b,c) \le \min(a,b,c) +b\),所以直接就是每次匹配 \(\operatorname{lcp}\) 最大的两个不劣。那么 set 维护即可。

时间复杂度 \(O(n\log n)\)

P4094 [HEOI2016/TJOI2016] 字符串

显然 \(d\) 没有用。维护出来最大的 \(\operatorname{lcp}\) 之后和 \(d-c+1\)\(\min\) 就行了。

跑一遍 SA,问题变成 \(\max\limits_{i=a}^{b} \min(b-i + 1,\operatorname{lcp}(rk_i,rk_c))\)

考虑二分,那么只需要看是否存在一个 \(i\),满足 \(\min(b-i + 1,\operatorname{lcp}(rk_i,rk_c)) \ge x\)。这个 \(\min\) 可以拆开,变成:

  1. \(a \le i \le b\)
  2. \(b-x + 1 \ge i\)
  3. \(\operatorname{lcp}(rk_i,rk_c) \ge x\)

那么就是看区间 \([l,r]\) 内是否存在一个点,使得这个点和 \(rk_c\)\(\operatorname{lcp}\) 不小于 \(x\)。而我们可以在 \(sa\) 上找到 \([L,R]\),使得 \(i \in [L,R]\) 都有 \(\operatorname{lcp}(i,rk_c) \ge x\)。那么只需要看是否存在 \(i \in [l,r] \land rk_i \in [L,R]\) 就行了。

可持久化维护一下就行了。时间复杂度 \(O(n\log^2 n)\)

P4770 [NOI2018] 你的名字

给定 \(T, l, r\)。求有多少个 \(T\) 的子串不是 \(S_{l,\dots, r}\) 的子串。

\(T\) 跑一遍 SA。枚举 \(T\) 某些子串的开头,那么相当于查询 \(T_{sa_i ,\dots |T|}\) 中有多少个前缀满足条件。同时为了不算重,这个前缀长度 \(k\) 要满足 $ k \ge\operatorname{lcp}(i-1,i)+1$。

显然越长越容易满足条件,二分找到最小的满足条件的就行了。那么就是对 \(T_{sa_i, \dots, sa_i + k - 1}\) 进行 check。

可以把询问离线,将所有的 \(T_i, S\) 放一起跑个 SA。同时由于 \(\operatorname{lcp} \ge k\),所以可以往前找到最小的 \(L\) 和往后找到最大的 \(R\),使得区间 \(\operatorname{lcp} \le k\)。那么就是看区间内是否存在一个后缀的 \(sa\) 值在 \([l, r - k + 1]\)。可持久化线段树维护和就行了。

时间复杂度 \(O(n\log^2 n)\)

P3975 [TJOI2015] 弦论

对于后缀数组里 \(rk_i > rk_j\)\((i,j)\),显然任意一个后缀 \(j\) 的前缀字典序都不大于任意一个后缀 \(i\) 的前缀,这里前缀关系不管。因为如果大于了,且不是前缀关系,说明 \(i\) 应该在 \(j\) 前面。

对于 \(t = 0\)。直接从前往后找,找到第 \(k\) 个就行了。

对于 \(t=1\)。依旧从前往后找,只是每个子串产生 \(\operatorname{lcp}(i,j) \ge k\)\(i\) 数量个贡献。但是直接做复杂度会退化到 \(O(n^2)\)。考虑枚举答案是哪个后缀的前缀。首先 \(i\) 会产生 \(\operatorname{lcp}(i-1,i)\) 个基础贡献,也就是之前被计算过但不是答案的。假设到 \(i\) 这里就有答案了,且答案为 \(s_{sa_i,\dots, sa_{i}+ k -1}\)。那么 \([i + 1,n]\) 会产生的基础贡献是 \(\sum\limits_{j=i+1}^{n}\min(k,\operatorname{lcp}(i,j))\)。也就是说,我们要找到最小的 \(k\),使得 \(\delta + \sum\limits_{j=i+1}^{n}\min(k,\operatorname{lcp}(i,j)) \ge k_0\),其中 \(\delta\) 是前面的基础贡献,\(k_0\) 是查询的 \(K\)。这个很不好做。哦烫不烫,可以这么二分。先二分找到答案是哪个后缀的前缀,再枚举具体是哪个字符串。

  1. 对于第一个二分。如果答案在 \(x\)。则 \([1,x]\) 的所有子串都会算进基础贡献。对于 \([x+1,n]\) 的,会产生 \(\operatorname{lcp}(x,i)\) 的基础贡献。需要满足 \(\delta \ge k_0\)
  2. 对于第二个操作。先把 \([1,x-1]\) 的基础贡献算了,\([x,n]\) 的会贡献 \(\operatorname{lcp}(x-1,i)\)。枚举具体前缀的长度 \(y\),会产生 \(\sum\limits_{j=x}^{n}[\operatorname{lcp}(x,j) \ge y]\) 的基础贡献。只要找到第一次不小于 \(k_0\)\(y\) 就行了。对每个 \(y\) 可以二分找到最大的 \(j\)

时间复杂度 \(O(n\log n)\)

「BZOJ4310」跳蚤

我们去二分答案。假设答案字典序第 \(x\) 小。那么问题变成,能否对 \(S\) 进行划分,使得划分后每个区间最大子串的相对字典序不超过 \(x\)

对每个 \(i\) 找到最大的 \(r_i\),使得 \(s_{i,\dots,r_i}\) 的字典序比二分的字典序小。这个东西先按照弦论里 \(t=0\) 的做法找到 \((x,y)\),使得 \(s_{sa_x,\dots,sa_{x}+y-1}\) 是第 \(x\) 小字符串。那么有:

  1. \(rk_i < x\)\(r_i= n\)
  2. \(rk_i=x\)\(r_i = i+y-1\)
  3. \(rk_i > x\)\(r_i = i+\min(y,\operatorname{lcp}(x,rk_i))-1\)

这部分单次时间复杂度 \(O(n)\)

然后贪心分段就行了。可以 \(O(n)\) check。

时间复杂度 \(O(n\log n)\)

P5840 [COCI 2014/2015 #5] Divljak

很显然的想法是离线,把最终集合中所有字符串与 \(S\) 放一起跑一遍 SA。

那么对于字符串 \(S_x\),就可以找到 SA 中的一个区间 \([l,r]\),使得该区间内任意一个后缀都包含了 \(S_x\)。这部分的时间复杂度 \(O(|S| \log |S|)\)

那么对于一个询问,就是查询区间 \([l_x,r_x]\) 中出现了多少种数,且这个数的出现时间不超过 \(t\)。数颜色是简单的套路,维护 \(pre\) 表示上一个该颜色的下标,则问题变成查询满足:

  1. \(l_x \le i \le r_x\)
  2. \(pre_i < l_x\)
  3. \(tim_i \le t\)

这玩意直接 CDQ 分治就行了。时间复杂度两个 log。

4 秒能过吧,不知道。

很显然,这玩意可以不用 SA。对 \(S\) 建 ACAM。那么新加进来一个 \(P\),可以知道 \(P_i\) 在 fail 树上的位置。将这些点的集合加入一个 \(tim\)。那么对于查询,就是看 \(S_x\) 结尾对应 fail 树上的点 \(c\) 子树中有集合并的大小。

这个很容易做,把这 \(|P|\) 个位置按照 dfs 序排序,然后看相邻两个变化量就行了,只是一个路径加值的问题。最后查询就是单点查询。

又发现路径加可以变成点到根加,那么再弄成单点加的话查询就是子树查,也就是区间查了。时间复杂度变成一个 log。

P4112 [HEOI2015] 最短不公共子串

第一个问题。

\(a,b\) 放一起跑 SA。然后枚举所有 \(a\) 的后缀,找到它在 SA 中前一个和后一个是 \(b\) 的后缀的位置。然后求 \(\operatorname{lcp}\) 就行了。

第二个问题。

\(b\) 建一个 DAG,维护出 \(b\) 的所有子序列。具体地,\(f_{i,j}\) 表示 \(i\) 后面第一个 \(b_k = j\) 的位置 \(j\),然后 \(i\)\(f_{i,*}\) 连边。这样枚举 \(a\) 的任意一个子串,都可以在这个 DAG 上走。相当于是一个子序列自动机,只要最后走不动了就表明这个子串可行。

第三个问题。

考虑到一个是 \(a\) 的子序列且不是 \(b\) 的子串的字符串 \(s\),如果 \(s\) 是最短的,说明 \(s\) 去掉最后一个字符后一定是 \(b\) 的子串。那么就相当于第二个问题反过来,枚举 \(b\) 的子串在 \(a\) 的子序列自动机上面走。如果最后 \(s\) 是当前 \(b\) 的这个子串加上某个字符,那么只需要枚举这个字符,看是否满足子序列自动机上有这条边且 \(b\) 不存在这个子串。其实遍历 Trie 就行了。

第四个问题。

维护 \(g_{i,j}\) 表示子序列自动机上 \(a\) 的走到 \(i\)\(b\) 的走到 \(j\) 时最小长度。那么枚举一个字符 \(c\),如果 \(a\) 有边且 \(b\) 每边就表明可行。\(g\) 的转移也比较简单。

时间复杂度 \(O(n\log n + n^2|\sum|)\)

P13772 [CERC 2021] Repetitions

枚举 \(|t| = len\)。每 \(len\) 个点看做一个关键点,记某相邻两个关键点为 \((i,j)\)

维护 \(i,j\) 开始的 LCP,\(i,j\) 结尾的 LCS。记前者长度为 \(x\),后者长度为 \(y\),且均与 \(len\)\(\min\)

那么显然地,如果 \(i + x - 1 \ge j - y + 1 - 1\),任意 \(id \in [j - y , i + x - 1]\) 都满足 \(s_{id - len + 1, \dots, id ,id+1,\dots,id+len}\) 是一个 \(tt\) 串。

我们将这些 \(id\) 标记为 \(1\)

那么对于一个询问 \((l,r)\),只要有一个 \(id\) 满足它被标记且 \(l+len-1 \le id \le r-len\) 就说明区间 \([l,r]\) 中存在一个长度为 \(len\)\(tt\) 串。

那么对每个 \(i\) 找到最小的 \(id\),使得 \(id \ge i\)\(id\) 被标记就行了。

时间复杂度 \(O(n\log n + qn)\)

posted @ 2026-03-05 19:27  harmis_yz  阅读(18)  评论(0)    收藏  举报