后缀全家桶 | 1. SA

本系列主要讲解:

  • SA(后缀数组)
  • SAM(后缀自动机)
    • 广义 SAM

并结合例题,总结一些经典的套路。

(希望我可以尝试同时用 SA 和 SAM 解决一些例题)

(还好总结了,不然真的就全忘了)


算法讲解

约定

\(rk(i)\) 表示后缀位置 \(i\) 对应的排名。

\(sa(i)\) 表示排名为 \(i\) 的位置。

根据定义,两者互逆。\(sa(rk(i))=rk(sa(i))=i\)

\(H(i) = lcp(sa(i - 1), sa(i))\),其中 lcp 表示两个后缀的最长公共前缀。

目标

使用小常数 \(O(n\log n)\) 倍增算法求解 \(sa,rk\) 数组。

\(O(n)\) 求出 \(h\) 数组。


\(sa,rk\)

\(O(n\log^2n)\) 1

使用 sort 进行排序,cmp 考虑二分最长公共前缀的位置,判断后一个位置大小关系。

实际测试中,优化过后的该算法可以通过大部分题目。

\(O(n\log^2n)\) 2

使用倍增加速排序。每次排序仅以 \(i,i+w\) 为双关键字,使用上一次的 \(sa,rk\) 数组作为 val 进行排序。

总共排序 \(\log n\) 次,每次朴素排序 \(O(\log^2n)\)

其实,该思想出现的本质是因为有:\(S(i,j)=S(i,j-1)+S(i+2^{j-1},j-1)\)

\(S(i,j)\) 的 rk 值也正可以用后两者进行排序得出,所以这样进行 log 次后,这些 rk 对应的 \(S'\) 就是我们要求的后缀。(忽略后面越界的位置)


参考这个思想,我们看一道和后缀数组无关的题:(CF1654F)

  • 给定一个长度为 \(2^n\),只包含小写字母的字符串 \(s\)
  • 你可以将字符串的下标全部异或一个 \([0,2^n)\) 的整数 \(k\),即构造一个与 \(s\) 等长的新字符串 \(t\),使得 \(t_i=s_{i\oplus k}\)
  • 最小化 \(t\) 的字典序,并输出字典序最小的 \(t\)
  • \(n\leq 18\)

分析:设 \(S(i,j)\) 表示考虑前 \(2^i\) 个位置管辖的子串,所有下标异或 \(j\) 形成的新串 \(t'\)

\(S(i,j)=S(i-1,j)+S(i-1,j\bigoplus 2^{i-1})\)。原因是前 \(2^{i-1}\) 个位置异或 \(j\) 的位置一定在异或 \(j\bigoplus 2^{i-1}\) 的位置前面。

双关键字排序也满足。

最后根据 \(rk(1)\) 的结果输出即可。

	for(int i = 0;i < (1 << n); ++i) rk[i] = c[i] - 'a';
	for(int w = 0;w < n; ++w) {
		for(int i = 0;i < (1 << n); ++i) A[i].first.first = rk[i], A[i].first.second = rk[i ^ (1 << w)], A[i].second = i;
		sort(A, A + (1 << n));
		for(int i = 0, p = 0;i < (1 << n); ++i)
			if(i != 0 and A[i].first.first == A[i - 1].first.first and A[i].first.second == A[i - 1].first.second) rk[A[i].second] = p; 
			else rk[A[i].second] = ++p;
	}
	for(int i = 0;i < (1 << n); ++i) if(rk[i] == 1) {
		for(int j = 0;j < (1 << n); ++j) putchar(c[j ^ i]);
		return 0;
	}

\(O(n\log n)\)

倍增不变,考虑将排序变为 \(O(n)\)

这里真正的难点是理解 \(O(n)\) 的基数排序。简单说一下。

此处排序有双关键字,于是采用 LSD 基数排序 先对第二关键字排序。然后给一关键字排序使用的是计数排序。

模板

给出优化后的代码。注意开始要先计数排序一次。

bool cmp(int x, int y, int z) { return tmp[x] == tmp[y] and tmp[x + z] == tmp[y + z]; }
void Suf_Arr(int lenn) {
	int mm = 127;
	for(int i = 1;i <= lenn; ++i) ++cnt[rk[i] = s[i]];
	for(int i = 1;i <= mm; ++i) cnt[i] += cnt[i - 1];
	for(int i = lenn;i >= 1; --i) sa[cnt[rk[i]]--] = i; 
	for(int opt = 1;;opt <<= 1) {
		int p = 0; 
		for(int i = lenn - opt + 1;i <= lenn; ++i) ID[++p] = i;
	    for(int i = 1;i <= lenn; ++i) if(sa[i] > opt) ID[++p] = sa[i] - opt; 
	    for(int i = 1;i <= mm; ++i) cnt[i] = 0;
	    for(int i = 1;i <= lenn; ++i) ++cnt[sb[i] = rk[ID[i]]];
	    for(int i = 1;i <= mm; ++i) cnt[i] += cnt[i - 1];
	    for(int i = lenn;i >= 1; --i) sa[cnt[sb[i]]--] = ID[i];   
	    memcpy(tmp, rk, sizeof tmp);  
	    p = 0;
	    for(int i = 1;i <= lenn; ++i) 
			rk[sa[i]] = cmp(sa[i], sa[i - 1], opt) ? p : ++p; 
		if(p == lenn) break ; mm = p;
	}
	for(int i = 1;i <= lenn; ++i) rk[sa[i]] = i;
}

\(H\)

这才是 sa 解决的大多数问题都需要的东西。有了上面的预处理可以很轻松地完成。

结论:\(H(i)\ge H(i-1)-1\)

据此时间复杂度可以做到 \(O(n)\)

证明考虑表示出这几个后缀,这里不展开。

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

求两后缀 lcp

结论:\(lcp(i,j)=\min {H(k)}\)\(k\in [rk(i)+1,rk(j)]\)

这个我感觉比较显然。

所以我们维护 ST 表,即可 \(O(1)\) 查询。

经典应用

不同子串的数目

考虑一个后缀 \(sa(i)\),他所在的位置可以贡献 \(n-sa(i)+1\) 个前缀,而这些前缀可能重复,重复数就是 \(H(i)\)

又显然 \(\sum n-sa(i)+1=\sum i\),所以就是 \(\sum i-H(i)\)

比较子串大小

假设在比较 \(s1=[a,b],s2=[c,d]\)。另 \(x=lcp(a,c)\)

\(x\ge \min(b-a+1,d-c+1)\),那么前面都一样,比较长度即可。

否则,比较第 \(x+1\) 位。

结合并查集/单调栈

见例题。

例题

其实很多 trick 都是前面留下的,跟 sa 无关。

「AHOI2013」差异

重点是求 \(\sum _{i,j} lcp(T_i,T_j)\)

由于 \(lcp(T_i,T_j)\) 的取值一定是来自范围内的一个 \(H\) 最小值,我们不妨考虑这个最小值可以贡献的区间。

那么这就是单调栈经典问题,求出 \(l_i,r_i\) 然后区间乘一下。

「HAOI2016」找相同字符

给定两个字符串,求出在两个字符串中各取出一个子串使得这两个子串相同的方案数。两个方案不同当且仅当这两个子串中有一个位置不同。

Sol1

考虑容斥,答案为两个串拼在一起一共的不同子串方案减去各自单独成一个串的方案,统计部分类似上一题。(好像求的甚至是一样的)

Sol2

考虑直接做:从前往后扫,如果遇见 \(s1\) 的,就计算已有 \(s2\) 的和,加入;\(s2\) 同理。每次还要对所有元素取 min。

「JSOI2015」串分割

给定环形数字字符串 \(S\)。把 \(S\) 进行 \(K\) 次切割,并分成 \(K\) 个非空的子串。每一个子串将其看成一个十进制数。最小化这 \(K\) 个数中的最大值。

首先最终答案各个数的位数一定是尽量相同的。可以二分后缀排名,然后贪心 check:

将原串复制一份,枚举起始点。现在考虑将额外的 \(1\) 分给哪些位置。其余都是 \(len=\lfloor \frac{n}{k}\rfloor\)

如果 \(rk(i) > x\),则一定只能是长为 \(len\)。否则可以。

证明:如果可以加一,就直接加。最后一定可以找到合法解。

考虑下一次使用 \(len+1\) 而这一次使用 \(len\),那和反过来是一样的;相反可能下一次取不到 \(len+1\)

「BZOJ4310」跳蚤

\(S\) 分成 \(K\) 组,然后对于每一组,取其字典序最大的子串,得到一个集合 \(T\),记 \(T\) 中字典序最大的串为 \(H\),询问 \(H\) 字典序最小可以是什么。

!模型:“二分这个子串。

考虑对于 \(sa(i),sa(i-1)\),两者除了 lcp 的部分,其余显然有:\(sa(i-1)\) 的后缀前缀排名一定小于 \(sa(i)\)

所以每个 \(i\) 的贡献子串个数一共是 \(n-sa(i)+1-h(i)\),可以简单得出 \(L,R\)

得到这个子串的 \(L,R\) 后,我们只需从后向前贪心,如果必须断才断开。

=> 思考:为何要从后向前贪心?

=> 是否记得:之前模拟赛有一道关于最小字典序的题,从后向前 dp 避免了后效性。原因显然,可以到那个题去看。(决策正确性)

Fun Fact:上面两道题非常相似,一道限制了子串长度;另一道需要从后向前保证字典序。

「NOI2015」品酒大会

对每个 \(i\) 求有多少对后缀满足 \(lcp(x,y) >= i\) 以及满足条件的两个后缀的权值乘积的最大值。

因为我打的是单调栈所以讲一下单调栈做法。其实并查集和单调栈维护这一类信息是近似的。(但是并查集看着清晰得多,应用也更广)下题会具体介绍并查集做法。

考虑每个 \(H(i)\) 所管辖的区间 \([l,r]\),我们需要维护这个区间内的最大值和次大值信息。可以这样修改单调栈:

单调栈维护一个结构体,这个结构体记录要维护的信息,然后对 \(H(i)\) 进行单调栈上踢数的时候,将这些结构体的信息\(i\) 的结构体合并即可。

在加一点关于基本功的细节:建议跑一次单调栈,每个 \(i\) 入栈之前跑的是算出其左边的信息,而右边的完整信息是需要在被后面的 \(j\) 第一次踢出(也是最后一次)时更改。

「SNOI2020」字符串

有两个长度为 \(n\) 的由小写字母组成的字符串 \(a,b\),取出他们所有长为 \(k\) 的子串(各有 \(n-k+1\) 个),这些子串分别组成集合 \(A,B\)。现在要修改 \(A\) 中的串,使得 \(A\)\(B\) 完全相同。可以任意次选择修改 \(A\) 中一个串的一段后缀,花费为这段后缀的长度。总花费为每次修改花费之和,求总花费的最小值。

并查集!你过关!

这个题,单调栈似乎真的没了……因为单调栈只能维护连续序列的 max 区间……而并查集可以非常灵活地操作。

待补(((

「NOI2016」优秀的拆分

显然可以前后单独计算。

我们考虑先停一停,周末再写。

repeats

字符串识别

对于每一位,求出经过这一位的在 \(s\) 中仅出现过一次的子串的最短长度。

这个“仅出现过一次”的限制可以通过求 \(D=\min(H(i),H(i+1))\) 得到。

那么当前的 \(sa(i)\) 做出的贡献就要至少是 \(D+1\),并且是 \(sa(i)+D-1\) 这些点。

仅此而已吗?考虑这个后缀大于 \(D+1\) 的长度,对于 \(sa(i)+D\) 及其后面的点,从 \(sa(i)\) 出发到它的子串都是唯一的,我们还要对它们进行更新答案:\(j-sa(i)+1\)

然后线段树区改单查即可。

「BJOI2020」封印

给定 \(s,t\),多次询问 \(s[l \dots r]\)\(t\) 的最长公共子串长度。

考虑预处理出 \(s\) 的每个位置在 \(t\) 中的最长公共子串长度,记为 \(f(i)\)。假设已知。

只需要求 \(\max_{l\le i\le r} \{ \min(f(i), r-i+1) \}\)

!模型:我们可以使用 二分+ST 解决:

二分答案 \(x\),判断是否有 \(\max _{l\le i\le r-i+1} f(i) \ge x\) 即可。(核心在于通过 \(x\) 得到了 \(i\) 的合法区间,直接规避了 min 的计算

现在看一下预处理怎么搞,发现这个东西就是 rk 值与 \(s_i\) 对应的 rk 值最接近的第一个在 \(t\) 中的位置。只需求 lcp。

我们按照 \(H\) 数组单调的顺序,即可预处理出:

    for(int i = 2;i <= len; ++i) {
        tmp = min(tmp, h[i]); // 按照定义算 lcp
        if(sa[i] > len2 + 1) dp[sa[i] - len2 - 1][0] = tmp; // 在 s 中,直接更新答案
        else tmp = h[i + 1]; // 是一个 t,作为 lcp 计算的起点,更新
    }

还有个小问题:实现的时候将 \(t\) 接在了 \(s\) 前面,lhy 说反过来不行,试一下。

「CF316G3」Good Substrings

给出 \(n\) 个限制,每个限制包含 3 个参数 \(T,l,r\),一个字符串满足当前限制当且仅当这个字符串在 \(T\) 中的出现次数在 \([l,r]\) 之间。
现在给你一个字符串 \(S\),问你 \(S\) 的所有本质不同的子串中有多少个满足所有限制。

\(s,t\) 拼在一起。处理每个 \(s_i\) 的满足条件的区间。

注意到这个区间是由限制中的 \(l,r\) 决定的;还要考虑 \(H(i)\) 重复的影响。

于是两个二分确定 \(L,R\) 即可。check 需要做的和下一题类似,二分出这个 lcp 的区间 \([L',R']\)

我们对每个限制做一个前缀和记录每个 \(T\) 的 rk 值,如果当前的这个区间 \([L',R']\) 中的 \(T\) rk 值不满足 \([l,r]\) 的限制,则不满足条件。

回顾 \([L',R']\)\(s[i,i+len-1]\) 为基,找到的 \([L',R']\) 是后缀上所有包含这个 \(s\) 子串的后缀区间,所以可以直接进行判断。

「TJOI / HEOI2016」字符串

多次询问子串 \(s[a..b]\) 的所有子串和 \(s[c..d]\) 的最长公共前缀的长度的最大值。

注意到 \(s[c,d]\) 的前缀,于是 \(d\) 的作用就只是限制长度。

考虑二分这个长度,就是要求:\([a,b-x+1]\) 中是否存在后缀 \(x\),使得 \(lcp(x,c)\ge x\)

!模型:固定 \(c,x\),可知与 \(c\) 的 lcp 长度大于等于 \(x\) 的位置一定是在 rk 上连续的。所以可以二分得到左右 rk 值。

问题进一步:\([a,b-x+1]\) 中是否有 rk 值在 \([L,R]\) 中?

这是个二维数点问题,考虑主席树维护即可。

「NOI2018」你的名字

\(t\) 中不在 \(s[l,r]\) 中出现的本质不同的子串个数。

等价于求出现的个数。

考虑把所有 \(t\) 接在后面跑 sa。

参考上一题的做法我们可以枚举 \(t\) 的子串左端点 \(i\),然后二分长度 \(x\) 上主席树判断。但是双 log 过不了。

性质:当 \(i\to i+1\)\(x\) 最少变为 \(x-1\)。(显然)

于是就不需要二分了,单 log 解决。

(也许以后还会做一些 sa 题,到时候可以补充进来)

总结

上面的例题可以总结为以下几类:

  • 二分后缀排名

周末继续……

posted @ 2024-05-06 21:05  LCat90  阅读(10)  评论(0编辑  收藏  举报