简单的后缀数组
Powered by Codex
后缀数组:倍增算法
后缀数组(Suffix Array,简称 SA)解决的是这样一个问题:给定字符串 \(s\),将它的所有后缀按字典序排序。
本文使用 1 下标。设字符串长度为 \(n\),从位置 \(i\) 开始的后缀记作
我们维护两个数组:
- \(sa[i]\):字典序第 \(i\) 小的后缀的起点;
- \(rk[i]\):从位置 \(i\) 开始的后缀,其字典序排名。
二者互为逆排列,即
例如字符串 banana 的后缀为:
| 起点 | 后缀 |
|---|---|
| 1 | banana |
| 2 | anana |
| 3 | nana |
| 4 | ana |
| 5 | na |
| 6 | a |
排序后依次为 a, ana, anana, banana, na, nana,所以
倍增的基本思想
假设我们已经知道每个长度为 \(w\) 的子串的排名。要比较从 \(i\) 开始、长度为 \(2w\) 的子串,可以将其拆成两半:
于是,每个位置 \(i\) 都对应一个二元组
先比较第一关键字,再比较第二关键字,就等价于比较长度为 \(2w\) 的子串。排序后重新编号,便能得到新一轮排名。依次令
直到所有后缀排名互不相同,后缀数组就构造完成了。
初始时 \(w=1\),单个字符的字符码就是它的第一轮排名。
如果 \(i+w>n\),第二段已经越过字符串末尾。我们约定空串的排名为 \(0\);所有非空串的排名都从 \(1\) 开始,因此空串一定比任何非空串小。
原代码没有显式判断 \(i+w\le n\),而是直接访问 \(rk[i+w]\)。这是有意为之:\(rk\) 是全局数组,未赋值的位置会被初始化为 \(0\),正好可以作为空串的排名。这里访问的下标虽然可能超出 \([1,n]\),但只要仍在数组分配的范围内,就不属于 C++ 意义下的数组越界。后文会精确分析需要预留多少空间。
为什么使用计数排序
如果每一轮都直接对二元组排序,一轮需要 \(O(n\log n)\),共进行 \(O(\log n)\) 轮,总复杂度会变成 \(O(n\log^2 n)\)。
注意两个关键字都是不超过 \(n\) 的整数,可以使用计数排序。经典实现先利用上一轮的 \(sa\) 顺序生成按第二关键字有序的 \(id\),再对第一关键字做一次计数排序。
生成第二关键字有序的 \(id\)
对于本轮长度 \(w\):
- 起点 \(n-w+1\ldots n\) 的第二段越界,第二关键字均为 \(0\),它们应放在最前面;
- 如果 \(sa[i]>w\),那么起点 \(sa[i]-w\) 的第二关键字是 \(rk[sa[i]]\)。由于 \(sa\) 已按 \(rk\) 排好,按 \(sa\) 的顺序加入这些起点,第二关键字自然有序。
随后,只需按第一关键字 \(rk[id[i]]\) 稳定计数排序,即可得到按二元组有序的新 \(sa\)。
每轮复杂度为 \(O(n)\),轮数为 \(O(\log n)\),所以:
- 时间复杂度:\(O(n\log n)\);
- 空间复杂度:\(O(n)\)。
重新编号
排序后,相邻两个位置 \(x=sa[i-1]\)、\(y=sa[i]\) 属于同一类,当且仅当它们的两个关键字都相等:
且
其中 \(ork\) 保存上一轮排名;当 \(p>n\) 时,全局数组中对应的 \(ork[p]\) 尚未被赋值,值为 \(0\)。
必须使用旧排名 \(ork\) 比较,不能一边写新 \(rk\) 一边拿 \(rk\) 比较,否则本轮尚未完成就会混入新排名。
代码实现
下面的程序读入一个不含空白字符的字符串,输出 \(sa[1\ldots n]\) 和 \(ht[1\ldots n]\)。代码保持原模板的写法:全局数组、1 下标、\(rk/ork/buc/id\) 命名,并直接利用全局数组的零初始化处理不存在的第二关键字。
# include <bits/stdc++.h>
const int N=1000010;
char s[N];
int n;
namespace SA{
int sa[N],rk[N],ork[N],buc[N],id[N],ht[N];
inline void init(void){
int mx=128;
// 初始时按单个字符排序;题目字符集应保证字符码不超过 mx。
std::fill(buc+1,buc+1+mx,0);
for(int i=1;i<=n;++i) ++buc[rk[i]=s[i]];
for(int i=1;i<=mx;++i) buc[i]+=buc[i-1];
for(int i=1;i<=n;++i) sa[buc[rk[i]]--]=i;
for(int w=1,cc=0;w<=n;w<<=1,mx=cc,cc=0){
// 第二关键字为 0 的后缀排在最前面。
for(int i=n-w+1;i<=n;++i) id[++cc]=i;
// 按上一轮 sa 的顺序加入,其第二关键字已经有序。
for(int i=1;i<=n;++i) if(sa[i]>w) id[++cc]=sa[i]-w;
memset(buc,0,(mx+1)*sizeof(int));
memcpy(ork,rk,sizeof(ork));
cc=0;
for(int i=1;i<=n;++i) ++buc[ork[i]];
for(int i=1;i<=mx;++i) buc[i]+=buc[i-1];
for(int i=n;i;--i) sa[buc[ork[id[i]]]--]=id[i];
// && 短路保证第二关键字只在第一关键字相等时比较,此时最多访问到 ork[n+1]。
for(int i=1;i<=n;++i)
rk[sa[i]]=(ork[sa[i]]==ork[sa[i-1]]&&ork[sa[i]+w]==ork[sa[i-1]+w])?cc:++cc;
if(cc==n) break;
}
// Kasai:按后缀起点顺序求相邻后缀的 LCP。
for(int i=1,k=0;i<=n;++i){
if(k) --k;
while(s[i+k]==s[sa[rk[i]-1]+k]) ++k;
ht[rk[i]]=k;
}
return;
}
} // namespace SA
int main(void){
scanf("%s",s+1);
n=strlen(s+1);
SA::init();
for(int i=1;i<=n;++i) printf("%d%c",SA::sa[i]," \n"[i==n]);
for(int i=1;i<=n;++i) printf("%d%c",SA::ht[i]," \n"[i==n]);
return 0;
}
越界分析
原代码使用 && 连接两次比较。C++ 会对 && 短路求值:只有第一关键字相等,即
成立时,才会计算第二关键字的比较。
上一轮的排名描述长度为 \(w\) 的子串。如果两个第一关键字相等,而其中某个后缀的长度小于 \(w\),那么这个后缀已经在串尾结束,另一个后缀要么更长,要么长度不同,两者在第一关键字中就已经能够分出大小,不可能排名相等。因此,进入第二次比较时,两个后缀的长度都至少为 \(w\)。对于其中任意一个起点 \(p\),都有
所以
因此,没有被短路的第二关键字比较最多只会访问到 \(ork[n+1]\),恰好比有效排名区间 \([1,n]\) 多一位。全局数组中的 \(ork[n+1]\) 为 \(0\),正好表示空串。为保证这次访问仍在数组内,只需满足
这里也可以从排序结果理解:如果第一关键字已经不同,表达式立即短路,代码根本不会计算可能到达 \(2n\) 的下标。若忽略短路机制,只根据 \(sa[i]\le n\) 和 \(w\le n\) 作估计,虽然会得到 \(sa[i]+w\le 2n\),但这个上界实际上不可达。
当 \(i=1\) 时,表达式还会访问 \(sa[0]\) 和 \(ork[sa[0]]\)。全局数组满足 \(sa[0]=0\)、\(ork[0]=0\),因此第一个后缀一定会建立新的排名。这里向左只访问到下标 \(0\),没有出现负下标。
生成 \(id\) 时,只在 \(sa[i]>w\) 的情况下计算 \(sa[i]-w\),故结果至少为 \(1\)。另一方面,由于 \(w\le n\),区间左端点 \(n-w+1\) 也至少为 \(1\)。所以这一部分不会访问下标 \(0\) 或负下标。
还要注意:代码中的输入语句本身不限制输入长度。若题目不能保证 \(n\le 10^6\),应根据题目上限调整 \(N\) 或给输入增加长度限制;这与倍增时利用 \(ork\) 的零区域是两件不同的事。
Height 数组
后缀数组给出了所有后缀的顺序,但很多应用还需要知道相邻后缀有多相似。定义
其中 \(\operatorname{lcp}(x,y)\) 表示后缀 \(s[x\ldots n]\) 与 \(s[y\ldots n]\) 的最长公共前缀长度,并规定 \(ht[1]=0\)。
例如 banana 的后缀数组为
相邻后缀依次比较,可以得到
为什么只需比较相邻后缀
对于任意两个排名 \(x<y\),有
如果两个后缀的最长公共前缀长度为 \(L\),那么所有排在它们之间的后缀也具有这个长度为 \(L\) 的前缀,因此中间每一对相邻后缀的 LCP 都至少为 \(L\);而区间中一定存在一对相邻后缀,其 LCP 恰好为 \(L\),否则两端后缀的公共前缀还可以继续延长。
这个性质说明,\(ht\) 已经包含任意两个后缀的 LCP 信息。若以后需要多次询问,可以在 \(ht\) 上建立 RMQ;本文只讨论 \(ht\) 本身的线性求法。
Kasai 算法
记
即后缀 \(i\) 与它在后缀数组中的前驱的 LCP。Kasai 算法利用下面的性质:
设后缀 \(i\) 在后缀数组中的前驱为后缀 \(j\),且 \(h[i]=k\)。删除两个后缀的首字符后,后缀 \(i+1\) 与后缀 \(j+1\) 至少仍有 \(k-1\) 个相同字符。后缀 \(j+1\) 不一定恰好是后缀 \(i+1\) 的前驱,但它排在后缀 \(i+1\) 之前;根据字典序的连续性,后缀 \(i+1\) 与其真正前驱的 LCP 不会小于 \(k-1\)。因此上式成立。
于是按 \(i=1,2,\ldots,n\) 的顺序计算时,可以保留上一次的答案 \(k\)。每次先令
再从第 \(k+1\) 个字符开始继续比较。每次字符相等会使 \(k\) 增加,而每轮至多使 \(k\) 减少一次,所以所有 while 循环合计只会执行 \(O(n)\) 次,求 \(ht\) 的时间复杂度为 \(O(n)\)。
哨兵与边界
原模板没有单独判断 \(rk[i]=1\)。当后缀 \(i\) 的排名为 \(1\) 时,它没有前驱,代码会取到
由上面的不等式,循环开始时保留的 \(k\) 始终不超过真实的 \(h[i]\)。此时 \(h[i]=0\),所以 \(k\) 必然已经减到 \(0\)。又因为全局数组满足 \(s[0]=0\),而串内字符 \(s[i]\ne 0\),while 会立即停止,最终正确得到 \(ht[1]=0\)。
当 \(rk[i]>1\) 时,被比较的是两个不同后缀。若它们一直相等到其中一个结束,较短后缀会先访问到 \(s[n+1]='\0'\),而另一个后缀仍指向串内的非零字符,比较立即停止。两个不同后缀不可能同时在同一轮到达末尾,因此不会在两个 \0 之间继续比较。这里最多向右多访问一位,即最多访问 \(s[n+1]\),仍只需满足
需要注意,这个论证要求输入串中不含 \0,这对由 scanf("%s",s+1) 读入的普通字符串自然成立。如果处理的是任意整数序列,则应自行添加一个不与序列元素相同的哨兵,或者在 while 中显式判断边界。
典型应用例题
下面整理 OI Wiki 后缀数组页面中适合本阶段的经典问题。它们不要求新的后缀数组构造方法,重点是理解如何使用 \(sa\)、\(rk\) 与 \(ht\)。
例 1:最小循环表示
给定长度为 \(n\) 的字符串 \(s\),在它的所有循环移位中求字典序最小者。
令
每个循环移位都对应 \(t\) 中一个起点位于 \([1,n]\)、长度为 \(n\) 的子串。构造 \(t\) 的后缀数组后,从小到大枚举 \(sa[i]\),第一个满足 \(sa[i]\le n\) 的位置就是答案起点。
这里直接比较整个后缀仍然正确。两个循环移位若在前 \(n\) 个字符内已经不同,后缀顺序就是循环移位的顺序;若前 \(n\) 个字符完全相同,则两个循环移位本来就相同,任选其一即可。
这个问题只用到 \(sa\),可以作为后缀排序模板之后的第一道应用题。
例 2:在文本中查找模式串
给定文本串 \(s\) 和模式串 \(t\),求 \(t\) 是否在 \(s\) 中出现,或者求出所有出现位置。
所有以 \(t\) 为前缀的后缀在后缀数组中构成一个连续区间。可以在 \(sa\) 上二分:
- 第一次二分求第一个字典序不小于 \(t\) 的后缀;
- 第二次二分求第一个不以 \(t\) 为前缀、且字典序严格大于该前缀区间的后缀。
得到的排名区间内,每个 \(sa[i]\) 都是一次匹配的起点。一次朴素比较需要 \(O(|t|)\),所以单次询问复杂度为
其中 \(\mathrm{occ}\) 是输出的出现次数。若询问很多,还可以利用相邻后缀 LCP 避免二分过程中重复比较,但这不属于本文的基础范围。
例 3:比较两个子串
给定两个子串 \(s[l_1\ldots r_1]\) 与 \(s[l_2\ldots r_2]\),比较它们的字典序。
设两个后缀的最长公共前缀为
两个子串长度分别为
若 \(L\ge\min(len_1,len_2)\),则较短的子串字典序更小;若长度也相同,则两者相等。否则,差异出现在公共前缀后的第一个字符,直接比较 \(s[l_1+L]\) 与 \(s[l_2+L]\) 即可。
由前文结论,假设 \(rk[l_1]<rk[l_2]\),则
因此在 \(ht\) 上建立 ST 表后,可以在 \(O(1)\) 时间求 LCP,并在 \(O(1)\) 时间比较任意两个子串。
例 4:不同子串个数
后缀 \(sa[i]\) 一共有
个非空前缀。按后缀数组顺序加入这个后缀时,其中长度不超过 \(ht[i]\) 的前缀已经由前面的后缀贡献过,所以它新贡献
个不同子串。答案为
注意答案可能达到 \(\Theta(n^2)\),应使用 64 位整数。
例 5:出现至少两次的最长子串
任意出现至少两次的子串,必然是某两个后缀的公共前缀。若两个后缀的 LCP 为 \(L\),那么它们之间相邻后缀的某个 LCP 至少为 \(L\)。因此答案就是
若还要输出这个子串,可以记录最大值所在的下标 \(p\),输出 \(s[sa[p]\ldots sa[p]+ht[p]-1]\)。
例 6:出现至少两次且不重叠的最长子串
在例 5 的基础上,要求两次出现不能重叠。二分答案长度 \(L\)。所有 \(ht[i]\ge L\) 的连续段,对应一组拥有相同长度为 \(L\) 前缀的后缀。
对每一组维护后缀起点的最小值与最大值。如果存在一组满足
就能选出两次间距至少为 \(L\) 的出现,它们互不重叠。一次检查为 \(O(n)\),总复杂度为 \(O(n\log n)\)。
例 7:出现至少 \(K\) 次的最长子串
二分答案长度 \(L\)。若某个连续区间中有至少 \(K-1\) 个 \(ht\) 值均不小于 \(L\),那么对应的 \(K\) 个后缀具有一个长度至少为 \(L\) 的公共前缀。
等价地,枚举长度为 \(K-1\) 的 Height 区间,检查是否存在
基础实现可以二分 \(L\) 并线性扫描连续段,复杂度为 \(O(n\log n)\)。洛谷 P2852 就是这一模型在整数序列上的版本。
例 8:两个字符串的最长公共子串
给定 \(s\) 与 \(t\),取一个在两串中均未出现的分隔符 #,拼成
构造 \(u\) 的后缀数组。答案一定来自后缀数组中相邻、且分别起始于 \(s\) 和 \(t\) 的两个后缀。因此枚举 \(i=2,3,\ldots,|u|\),若 \(sa[i-1]\) 与 \(sa[i]\) 属于不同原串,就用 \(ht[i]\) 更新答案。
为什么只需看相邻后缀?假设某个来自 \(s\) 的后缀与某个来自 \(t\) 的后缀具有长度为 \(L\) 的公共前缀,那么所有具有这个前缀的后缀在 \(sa\) 中形成连续段。只要该段同时包含两种来源,其中必然存在一对来源不同的相邻后缀。
分隔符会在第一次跨过两串边界时终止匹配,因此答案不会错误地延伸到另一段字符串中。
进阶例题
下面三道题比前面的典型应用更综合,但核心仍然是把字符串关系转化为后缀之间的 LCP,再利用 Height 数组的结构进行统计。
例 9:洛谷 P1117 [NOI2016] 优秀的拆分
题目链接:https://www.luogu.com.cn/problem/P1117
如果一个字符串可以写成 \(AA\),其中 \(A\) 非空,就称它为一个平方串。题目要求统计字符串中形如
的子串个数,其中 \(AA\) 与 \(BB\) 都是平方串。
设
- \(f[i]\) 表示以位置 \(i\) 结尾的平方串个数;
- \(g[i]\) 表示以位置 \(i\) 开始的平方串个数。
若一个优秀拆分在位置 \(i\) 与 \(i+1\) 之间断开,那么左侧平方串有 \(f[i]\) 种选择,右侧平方串有 \(g[i+1]\) 种选择。因此答案为
问题变为:如何统计所有平方串。
枚举平方串的一半长度
枚举 \(|A|=L\)。再枚举一组相距 \(L\) 的对齐位置 \(q\) 与 \(q+L\),其中
令
并令 \(y\) 表示从 \(q-1\) 与 \(q+L-1\) 开始向左最多能匹配多少个字符,也就是这两个前缀的最长公共后缀。\(y\) 可以在反串上转化为一次 LCP 查询。
在区间
中,都有
因此,起点为 \(p\)、一半长度为 \(L\) 的平方串合法,当且仅当区间 \([p,p+L-1]\) 完全落在上述匹配区间中,即
同一个平方串可能被相邻的两个 \(q\) 重复发现。为了使每个起点只归属于一个对齐位置,再限制
所以本次枚举贡献的平方串起点区间为
若左端点不大于右端点,就对 \(g\) 的这个区间整体加 \(1\);对应的结束位置为 \(p+2L-1\),所以同时对 \(f\) 的平移区间整体加 \(1\)。使用差分数组即可在 \(O(1)\) 时间完成一次区间加。
对于固定的 \(L\),需要枚举 \(O(n/L)\) 个对齐位置,所以总枚举次数为
在原串和反串上分别预处理后缀数组与 LCP 的 RMQ 后,每次求 \(x,y\) 都是 \(O(1)\),总复杂度为 \(O(n\log n)\)。
实现时要特别注意,\(x\) 不能跨过字符串右端点,\(y\) 不能跨过左端点;用后缀 LCP 转换后,这些限制通常由后缀长度自然保证。区间端点仍应与合法起点范围 \([1,n-2L+1]\) 取交集,避免差分数组写到无效位置。
例 10:洛谷 P2178 [NOI2015] 品酒大会
题目链接:https://www.luogu.com.cn/problem/P2178
每个后缀起点 \(i\) 有一个权值 \(a_i\)。对于每个长度 \(L\),题目要求:
- 有多少对不同后缀的 LCP 不小于 \(L\);
- 在这些后缀对中,\(a_i a_j\) 的最大值是多少。
把后缀数组中的每个排名看成一个点。对于 \(i=2,3,\ldots,n\),在排名 \(i-1\) 与 \(i\) 之间连一条权值为 \(ht[i]\) 的边。
由
可知,对于固定的 \(L\),两个排名对应的后缀 LCP 不小于 \(L\),当且仅当它们在只保留边权不小于 \(L\) 的图中连通。
因此可以令 \(L\) 从 \(n-1\) 递减到 \(0\),逐步加入所有满足 \(ht[i]=L\) 的边,并用并查集维护连通块。
设本次合并的两个连通块大小分别为 \(siz_x\) 与 \(siz_y\)。合并前不连通、合并后连通的新后缀对数为
把它累加到当前后缀对总数中,就得到 LCP 不小于当前 \(L\) 的后缀对数。
为了求最大权值乘积,对每个连通块维护其中 \(a_i\) 的最小值 \(mn\) 和最大值 \(mx\)。乘法在两个区间端点处取得极值,因此两个块之间可能产生的最大乘积是下面四项的最大值:
四项都要计算:当一个连通块内全是负数、另一个块内全是正数时,最优答案可能是绝对值最小的负数,不能只比较 \(mn_xmn_y\) 与 \(mx_xmx_y\)。用这四项更新全局最大值,然后合并两个块的 \(mn,mx\) 即可。
若某个 \(L\) 没有新边加入,它的答案与 \(L+1\) 相同。若当前没有合法后缀对,题目规定最大乘积输出 \(0\)。排序 Height 边需要 \(O(n\log n)\);也可以按 \(ht\) 的整数值开桶,将这一部分优化为 \(O(n)\)。并查集合并的复杂度为 \(O(n\alpha(n))\)。
例 11:洛谷 P4248 [AHOI2013] 差异
题目链接:https://www.luogu.com.cn/problem/P4248
题目要求计算所有无序后缀对 \((i,j)\) 的差异之和:
其中 \(S_i=s[i\ldots n]\),所以 \(|S_i|=n-i+1\)。
先计算长度部分。每个后缀长度会与其余 \(n-1\) 个后缀各配对一次,因此
接下来只需求所有后缀对的 LCP 之和。按后缀数组中的排名枚举 \(x<y\),有
令
那么所有后缀对的 LCP 之和,恰好等于数组 \(b\) 的所有非空子数组最小值之和。
这个量可以用单调栈在线性时间内计算。对于每个位置 \(i\),找出:
- 左侧最近的、严格小于 \(b[i]\) 的位置 \(L_i\);
- 右侧最近的、小于等于 \(b[i]\) 的位置 \(R_i\)。
采用一侧严格、一侧非严格,是为了让含有相同最小值的子数组只归属于其中一个位置。以 \(b[i]\) 作为所归属最小值的子数组共有
个,因此
最终答案为
构造后缀数组需要 \(O(n\log n)\),计算 Height 数组和单调栈均为 \(O(n)\)。所有乘法与答案都必须使用 64 位整数;长度部分的数量级达到 \(O(n^3)\)。
基础练习
下面的题目均未出现在开头给出的题目选讲链接中,并按所需技巧大致从简单到复杂排列。
1. 洛谷 P3809【模板】后缀排序
题目链接:https://www.luogu.com.cn/problem/P3809
直接构造并输出后缀数组。适合检查倍增、计数排序、重新编号和下标处理是否正确。建议先独立写出只求 \(sa\) 的版本,再加入 \(ht\)。
2. 洛谷 P2408 不同子串个数
题目链接:https://www.luogu.com.cn/problem/P2408
一个长度为 \(n\) 的字符串共有
个带位置的非空子串。按后缀数组顺序考虑后缀 \(sa[i]\):它提供 \(n-sa[i]+1\) 个前缀,其中前 \(ht[i]\) 个已经在前面的后缀中出现。因此不同子串个数为
这是 \(ht\) 最直接的应用。
3. POJ 2774 Long Long Message
题目链接:https://vjudge.net/problem/POJ-2774
求两个字符串的最长公共子串。将它们用一个从未出现过的分隔符拼接,构造后缀数组。答案一定来自后缀数组中相邻、且分别属于两个原串的后缀,因此枚举 \(ht[i]\) 取最大值即可。
分隔符必须与原字符集不同,并且不能把跨过分隔符的部分算入答案。
4. UVA 11512 GATTACA
题目链接:https://onlinejudge.org/external/115/11512.html
求出现至少两次的最长子串,并统计其出现次数。最长长度就是
当 \(L>0\) 时,连续满足 \(ht[i]\ge L\) 的一段对应一组拥有相同长度为 \(L\) 的前缀的后缀;若这一段含有 \(c\) 个 \(ht\) 值,则对应 \(c+1\) 个后缀。该题可以练习如何从 Height 数组中的连续区间恢复重复次数。
5. 洛谷 P2852 [USACO06DEC] Milk Patterns G
题目链接:https://www.luogu.com.cn/problem/P2852
给定整数序列,求至少出现 \(K\) 次的最长连续子序列。先对数值离散化,再构造后缀数组。二分答案长度 \(L\),若存在连续 \(K-1\) 个 \(ht\) 值均不小于 \(L\),就说明有连续 \(K\) 个后缀共享长度至少为 \(L\) 的前缀。
这题比前四题多出“整数离散化”和“二分答案”两步,但核心仍然只是观察 Height 数组中的连续段。
小结
倍增法的核心是:用长度为 \(w\) 的排名组成二元组,从而得到长度为 \(2w\) 的排名。再借助计数排序将每轮降至 \(O(n)\),最终在 \(O(n\log n)\) 时间、\(O(n)\) 空间内构造后缀数组。原模板不对第二关键字显式判界,而是结合 && 的短路求值与全局数组的零初始化表示空串;实际最多只会访问到下标 \(n+1\)。

浙公网安备 33010602011771号