字符串基础

推荐在 cnblogs 阅读。

前言

几乎所有字符串算法都存在一个共性:基于所求信息的特殊性质与已经求出的信息,使用增量法与势能分析求得所有信息。这体现了动态规划思想。

Manacher 很好地证明了这一点:它维护所求得的最右回文子串的回文中心 d 与回文半径 r,利用回文性质通过均摊右端点移动距离在线性时间内求出以每个位置为中心的最长回文半径。

SA,KMP,Z 与 SAM 等常见字符串算法无不遵循这一规律。读者在阅读时注意体会这种思想,笔者认为其对于提升解决问题的能力有很大帮助。

定义与记号

约定

  • 文章使用打字机字体 texttt 描述一个字符串的具体内容,例如 s=alexwei
  • 无歧义时,n 表示当前描述的字符串的长度。在字符串两侧加上 | 表示长度。
  • 当下标 i 不在 [1,n] 范围内时,对应位置的字符 si 视为空字符。

基本定义

  • 字符集:字符集 可以是任何具有全序关系的集合 Σ,即 Σ 中任意两个不同元素 x,yΣxy)可比较大小。除非特殊规定,一般按字母表顺序或数码大小比较元素。
  • 字符:字符集 Σ 中的元素称为 字符sis[i] 表示 s 的第 i 个字符。
  • 空串:不含任何字符的字符串称为 空串,记作 ϵ|ϵ|=0。空串之于字符串类似空集之于集合。
  • 子串:由 s 在开头或末尾删去若干字符得到的字符串称为 s子串s 本身和空串均为 s 的子串。s[l,r]sl,r 表示 s 位置 lr 上的字符连接而成的子串,当 l>r 时为空串。
  • 反串:翻转 s 得到 s反串,记作 sR
  • 回文串:s=sR 的串称为 回文串。特别地,空串是回文串。
  • 拼接:s+t 表示将 t 拼接在 s 后。

前后缀相关

  • 前缀:在 s 末尾删去若干字符得到的字符串称为 s前缀,形如 s[1,i]0in),记作 preis 本身和空串均为 s 的前缀。
  • 后缀:在 s 开头删去若干字符得到的字符串称为 s后缀,形如 s[i,n]1in+1),记作 sufis 本身和空串均为 s 的后缀。
  • 真前 / 后缀:真前缀 表示非原串前缀,真后缀 同理。
  • 最长公共前缀:lcp(s,t) 表示 st最长公共前缀(Longest Common Prefix),即最长的 u 使得 u 同时为 st 的前缀。最长公共后缀(Longest Common Suffix)同理。
  • 字典序:定义空字符小于任何字符。称 s字典序 小于 t 当且仅当去掉 lcp(s,t) 后,s 的第一个字符小于 t 的第一个字符。等价于以第 i 个字符作为第 i 关键字比较。

匹配相关

  • 出现位置:若 s[p|t|+1,p]=t,则称 ts 中以位置 p 出现ts 中的 出现位置 等于 t 的最后一个字符在 s 中的对应位置。例如,若 s=abcbabct=abc,则 ts 中的所有出现位置为 {3,7}

  • 匹配:称字 t 匹配 s 当且仅当 ts 中出现。

  • 模式串(单词):用于匹配的字符串称为 模式串,相当于题目给定的 单词

  • 字典:题目给定的所有模式串的集合称为 字典

  • 文本串:被匹配的字符串称为 文本串

  • 分清模式串和文本串的定义:用 t 匹配 s 即求 ts 中的所有出现位置,t用于匹配的串,称为模式串(模式串是我们要寻找的模式,是子串);s被匹配的串,称为文本串(文本串相当于给出的文本,是主串)。

1. Manacher 算法

Manacher 在所有字符串算法中理解起来相对容易,学习它有助于理解 Z 算法。

Manacher 在 NOI 大纲里是 8 级算法。

1.1 相关定义

根据回文串的定义,我们发现奇回文串和偶回文串本质不同。当 n 为奇数时,其 回文中心 为它最中间的位置 n+12;当 n 为偶数时,其回文中心为位置 midmid+1 之间的空隙,其中 mid=n2

此外,定义回文串 s回文半径 为其开头到回文中心形成的字符串长度。当 |s| 是奇数时,s 的回文半径为 n+12;当 n 是偶数时,s 的回文半径为 n2

容易发现对于 s 的某个回文子串 s[l,r],若 l<r,则 s[l+1,r1] 回文。因此,若 xi 的回文半径,则任何小于等于 x 的正整数均为 i 的回文半径。因此,考虑一个回文中心:

  • 当它是下标 i 时,存在阈值 R 满足当 0j<Rs[ij,i+j] 回文,R 称为以位置 i 为回文中心的 最长回文半径 Ri。它可以理解为以 i 为回文中心的回文子串数。
  • 同理,当它是 ii+1 之间的空隙时,存在阈值 R 满足当 0j<R 时,s[ij,i+1+j] 回文。

1.2 统一奇偶回文子串

为避免分类讨论,我们尝试将所有偶回文子串转化为奇回文子串。

容易发现,若将所有空隙视作一种独立的 分隔字符,则偶回文子串可视为以对应分隔符为回文中心的奇回文子串。例如 abbbba,若将空隙视为字符 c,则原串变为新串 cacbcbcbcbcac

考虑原串偶回文子串在新串上的形态。偶回文子串 bb 对应新串 cbcbcabbbba 对应新串 cacbcbcbcbcac。手动模拟可知若某分隔符在新串上的最长回文半径为 Ri(一定是奇数),则它对应的原串最长回文子串长度为 Ri1

考虑原串奇回文子串在新串上的形态。奇回文子串 bbb 对应新串 cbcbcbc。手动模拟可知若某原串下标在新串上的最长回文半径为 Ri(一定是偶数),则它对应的原串最长回文半径为 Ri2,最长回文子串长度同样为 Ri1

这样,我们统一了奇回文串和偶回文串从新串转回原串的过程,有效减少了根据最长回文半径 Ri 求解问题的细节,并得到结论:新串的 Ri 表示原串以对应下标或空隙为中心的最长回文子串长度加 1

1.3 算法介绍

Manacher 算法可以求出以每个位置 i 为回文中心的最长回文半径 Ri。当然,也可以求出以每个空隙为回文中心的最长回文半径。这种情况已经转化为前者。

首先将 s 的所有字符之间插入相同分隔符,包括头尾,然后在整个字符串头尾插入另外两种不同的分隔符,以防止越界。

朴素想法是对每个回文中心 i,从 1 开始从小到大暴力枚举 Ri。很遗憾,全 a 串就可以将复杂度卡成 O(n2)。具体复杂度是 n 加上 s 的回文子串数量,出现在不同位置的相等子串 算多个

只需加上一些优化即可将复杂度变为 O(n)

回顾朴素暴力,在从 Ri=1 开始枚举的过程中,我们浪费了很多由回文性质带来的有用信息。考虑回文中心 c 及其右端点 r=c+Rc1,若当前希望求出 Ric<i) 且满足 ir,则我们完全可以利用 2ci 处已经求得的 R2ci 的信息。

具体地,在 c 的最长回文半径范围内,因为 c<ir,所以 2cii 在该范围内对称。也就是说,在 [cRc+1,c+Rc1] 范围内,以 2ci 为对称中心的回文串也是以 i 为对称中心的回文串。因此,我们得出 Rimin(ri+1,R2ci)

如上图,因为黄色部分回文,所以对于以 2ci 为回文中心且落在黄色部分的回文串(橙色部分),一定也在 i 处出现。

因此,考虑维护已经计算过的所有回文中心对应的最长回文子串的右端点的最大值 r=maxp=1i1p+Rp1 以及对应回文中心 c

对于当前位置 i,若 r<i,则从 0 开始枚举求得 Ri。此时每次扩展均使得 r 向右移动 1

否则 ir,利用回文性质和已经求出的信息,先将 Ri 赋值为 min(ri+1,R2ci),再逐位扩展:

  • R2ci<ri+1,则 Ri 就等于 R2ci。否则根据对称性 R2ci 可以更大,与其最大性矛盾。
  • 否则 Ri 被初始化为 ri+1,使得每次扩展都会将 r 向右移动 1

综上,时间复杂度线性。

模板题 P3805 代码如下:

#include <bits/stdc++.h>
using namespace std;
const int N = 2.2e7 + 5;
int n, m, ans, R[N];
char s[N], t[N];
int main() {
  scanf("%s", s + 1), n = strlen(s + 1);
  t[0] = '!', t[m = 1] = '@';
  for(int i = 1; i <= n; i++) t[++m] = s[i], t[++m] = '@'; // 间隔插入字符.
  t[++m] = '#';
  for(int i = 1, c = 0, r = 0; i < m; i++) { // r 是最右回文边界, c 是对应回文中心.
    R[i] = r < i ? 1 : min(R[c * 2 - i], r - i + 1); // 若 i <= r, 则根据对称性继承信息.
    while(t[i - R[i]] == t[i + R[i]]) R[i]++;
    ans = max(ans, R[i] - 1);
    if(i + R[i] - 1 > r) c = i, r = i + R[i] - 1; // 更新 r 和 c.
  }
  cout << ans << endl;
  return 0;
}

1.4 结论与应用

Manacher 本身证明了一个关于回文子串的结论:一个字符串的本质不同回文子串个数不大于 n。因为所有以 i 为回文中心的长度小于等于 min(ri+1,R2ci) 的回文子串均与以 2ci 为回文中心的对应长度的回文子串相等。故以 i 为回文中心,每找到一个在此之前没有出现过的回文子串,均会使 r 增加 2(找到回文子串后,因为分隔符回文再扩展一步)。注意,使 r 增加并不一定代表找到新的回文子串。

当然,我们也可以不借助 Manacher 直接证明这一结论。考虑在每个回文子串第一次出现处计入贡献。若存在两个本质不同回文子串 p,q 在同一右端点处贡献答案,不妨设 |p|<|q|,则根据回文性 q[1,|p|]=p,与 p 第一次出现在 q[|q||p|+1,|q|] 处矛盾。因此一个右端点至多贡献一个本质不同回文子串。

利用 Manacher,我们可以求出以每个字符开头或结尾的最长回文子串:考虑位置 i 及其最长回文半径 Ri,若 i+Ri1>r,根据算法,用 i+Ri1 更新 r。更新前枚举 j[r+1,i+Ri1],若 tj 对应原串字符 sk 而非分隔符,则原串中以 sk 结尾的最长回文子串长度为 ji+1。通过分奇偶性讨论和模拟可证其正确性。代码实现见例题 P4555。

1.5 例题

UVA11475 Extend to Palindrome

找到最小的使得 s[l,r] 为回文串的 l,则答案即 s 加上 s[1,l1] 翻转后的结果。代码

P3501 [POI2010] ANT-Antisymmetry

借鉴 Manacher 的思路,我们对每个位置求出最长 Anti-symmetry 半径。同样的,记录最右边的回文区间,快速继承和扩展做到均摊线性。代码

P4555 [国家集训队] 最长双回文串

对每个位置求出以该字符结尾和开头的最长回文子串 lftirti,则 maxi=1n1lfti+rti+1 即为所求。

时间复杂度线性。

#include <bits/stdc++.h>
using namespace std;
const int N = 2e5 + 5;
int n, m, ans, R[N], lft[N], rt[N];
char s[N], t[N];
int main() {
  scanf("%s", s + 1), n = strlen(s + 1);
  t[0] = '!', t[m = 1] = '@';
  for(int i = 1; i <= n; i++) t[++m] = s[i], t[++m] = '@';
  t[++m] = '#';
  for(int i = 1, c = 0, r = 0; i < m; i++) {
    R[i] = i > r ? 1 : min(R[2 * c - i], r - i + 1);
    while(t[i - R[i]] == t[i + R[i]]) R[i]++;
    if(i + R[i] - 1 > r) {
      for(int j = r + 1; j < i + R[i]; j++) if(j & 1 ^ 1) lft[j >> 1] = j - i + 1; // 新串的偶数位置对应一个原串字符.
      c = i, r = i + R[i] - 1;
    }
  }
  for(int i = m - 1, c = m, r = m; i; i--) { // 倒过来做一遍 Manacher.
    R[i] = i < r ? 1 : min(R[2 * c - i], i - r + 1);
    while(t[i - R[i]] == t[i + R[i]]) R[i]++;
    if(i - R[i] + 1 < r) {
      for(int j = i - R[i] + 1; j < r; j++) if(j & 1 ^ 1) rt[j >> 1] = i - j + 1;
      c = i, r = i - R[i] + 1;
    }
  }
  for(int i = 1; i < n; i++) ans = max(ans, lft[i] + rt[i + 1]);
  cout << ans << endl;
  return 0;
}

P1659 [国家集训队] 拉拉队排练

因为题目要求奇回文串,所以只考虑以下标 i 为回文中心。若新串位置 k 对应原串下标 i,则此时所有长度 j[1,Rk2] 的回文半径均存在,相当于对 1,3,,Rk1 进行单点加,最后全局查询。差分维护即可。

时间复杂度是快速幂的 O(nlogn)代码

P5446 [THUPC2018] 绿绿和串串

如果 prei 能生成 s,则以 i 为回文中心时,要么存在顶到结尾的回文串,要么存在顶到开头的回文串且 pre2i1 能生成 s

Manacher 求出每个位置的最长回文半径后倒过来 dp 一遍即可。

时间复杂度 O(n)代码

2. Z 算法 / 扩展 KMP

扩展 KMP 在 NOI 大纲里是 9 级算法。它和 KMP 没有关系。

2.1 算法简介

定义字符串 sZ 函数 zi 表示 si 后缀与 s 本身的最长公共前缀长度,即 zi=|lcp(sufi,s)|z1 无用,一般令 z1=n

每次暴力匹配,时间复杂度 O(n2)。它没有利用任何已经求出的 z1,z2,,zi1 的信息,很不优美。

Z 算法利用已经求得的信息的性质,通过增量法求出 Z 函数。

[i,i+zi1]i 的匹配段,也称 Z-box。据定义,s[i,i+zi1]=s[1,zi]

类似 Manacher,实时维护最靠右侧的匹配段 [l,r]。匹配到位置 i 时,分两种情况讨论:

  • i>r,直接暴力匹配。
  • ir,因为 s[1,rl+1]=s[l,r],所以 s[i,r]=s[il+1,rl+1]。因此 sufiri+1 前缀和 sufil+1ri+1 前缀相等。故首先令 zimin(ri+1,zil+1),然后暴力匹配。

读者可以发现 Z 算法和 Manacher 的核心思想几乎一模一样。

时间复杂度:当 zil+1<ri+1 时,zi 不可能继续向下匹配,否则与 zil+1 的最大性矛盾。因此,每次成功的匹配都会使 r 增加 1。时间复杂度是优秀的线性。

2.2 应用

Z 函数可以用于做特定类型的字符串匹配:求字符串 t 的每个后缀 is 的最长公共前缀长度 pi

  • 解法 1:令 s=s+c+t,其中 c 是任意不属于 s,t 字符集的分隔符。对 s 求 Z 函数。
  • 解法 2:先求出 s 的 Z 函数。然后类似求 Z 函数的方法,维护最右匹配段 [l,r] 表示 t[l,r]=s[1,rl+1],若 i>r 则暴力匹配,否则令 pi=min(zil+1,ri+1)

两种解法本质相同,因为 Z 算法本身相当于用 s 匹配自己,类似 KMP 用自己匹配自己的方式求出 nxt 数组。

2.3 例题

P5410 【模板】扩展 KMP(Z 函数)

#include <bits/stdc++.h>
using namespace std;
const int N = 2e7 + 5;
char s[N], t[N];
int n, m, z[N], p[N];
int main() {
  scanf("%s%s", t + 1, s + 1);
  m = strlen(t + 1), n = strlen(s + 1);
  z[1] = n;
  for(int i = 2, l = 0, r = 0; i <= n; i++) {
    z[i] = i > r ? 0 : min(z[i - l + 1], r - i + 1);
    while(s[1 + z[i]] == s[i + z[i]]) z[i]++; // 因为 i 不等于 1, 所以 i + z[i] 超出下标时空字符和 s[1 + z[i]] 必然不等, 判断句不成立.
    if(i + z[i] - 1 > r) l = i, r = i + z[i] - 1;
  }
  for(int i = 1, l = 0, r = 0; i <= m; i++) {
    p[i] = i > r ? 0 : min(z[i - l + 1], r - i + 1);
    while(p[i] < n && s[1 + p[i]] == t[i + p[i]]) p[i]++; // 应判断 p[i] 小于模式串长度而非匹配串长度.
    if(i + p[i] - 1 > r) l = i, r = i + p[i] - 1;
  }
  long long ans = 0;
  for(int i = 1; i <= n; i++) ans ^= 1ll * i * (z[i] + 1);
  cout << ans << endl;
  ans = 0;
  for(int i = 1; i <= m; i++) ans ^= 1ll * i * (p[i] + 1);
  cout << ans << endl;
  return 0;
}

CF432D Prefixes and Suffixes

KMP 找到完美子串,Z 算法 + 差分求出现次数。

时间复杂度线性。代码

CF526D Om Nom and Necklace

重新表述题意:若 S 能被表示成 AAAAB,其中 A 出现了 k 次且 BA 的前缀,则 S 符合要求。

枚举所有 ki。根据 border 的性质,若 S 有长为 |S|p 的 border,则 S 有周期 p。因此 KMP 求出 Snxt 数组。若 inxti|ik 说明 S[1,i]k 个相同字符串拼接而成,即 |A|=ik

此时考虑可能的 B 的最长长度 r,即 min(|A|,|lcp(S[i+1,|S|],S)|),后者用 Z 算法求。这说明 S[1,ii+r] 均可成为答案,差分维护即可。

时间复杂度线性。代码

3. 后缀数组

前置知识计数排序基数排序,倍增。

后缀数组(Suffix Array, SA)的思想与实现非常简单,基础的倍增思想加上排序,但其扩展得到的 ht 数组功能强大且适用性广,使得它在 OI 界有广泛应用。

后缀数组在 NOI 大纲里是 8 级算法。

3.1 相关定义

  • 定义 rki 表示 sufi 在所有后缀中的字典序排名。由于任意后缀长度不同,故不存在两个后缀排名相同。例如 aabab,有 aabab<ab<abab<b<bab,所以 rk={1,3,5,2,4}
  • 定义 sai 表示排名为 i 的后缀的开始位置。它与 rk 互逆:rk(sai)=i,这个 i 表示排名;sa(rki)=i,这个 i 表示位置。这就是 后缀数组

rk 是从位置到排名的映射,sa 则将排名映射回位置。若令 P(Position)表示位置集合,R(Rank)表示排名集合(均为 1n 的整数集,但含义不同),则 rk 可视作 PR 的函数,sa 可视作 RP 的函数。它们按任意顺序复合均得恒等函数rksa=sark=I,其中对于任意 x[1,n] 均有 I(x)=x

也可以说,rksa 互为反函数,即 rk=sa1sa=rk1

充分熟悉 rksa 的定义有助于接下来的学习。

  • 简记 sufisufj 的最长公共前缀为 lcp(i,j)
  • ht 数组的定义将在 3.3 小节给出,它是 SA 算法的核心。
  • 下文区分下标和位置两个概念,前者指数组的某个位置,而后者指某个后缀的开始位置。

3.2 后缀排序

后缀排序算法通过一系列排序操作得到一个字符串的后缀数组。

我们的目标是将字符串 s 的所有后缀按字典序从小到大排序。

3.2.1 算法介绍

对于两个长度相等的字符串 s,t,将其分成长度对应相等的两部分 s=s1+s2t=t1+t2|si|=|ti|)。如果想比较 st 的字典序大小,可以先比较 s1t1 的字典序大小,若 s1t1,则 s1t1 的字典序大小决定了 st 的字典序大小,否则比较 s2t2 的字典序大小。

对于多个字符串的比较,我们也可以这样做。分成长度对应相等的两部分,以第一部分为第一关键字,第二部分为第二关键字比较。

基于这个思想,我们考虑倍增。

假设已知所有 w1 级子串,即所有 s[i,i+2w11](若 i+2w11>n,则超出的部分定义为空字符)的排名 rk

因为 s[i,i+2w1] 等于 s[i,i+2w11] 加上(拼接)s[i+2w1,(i+2w1)+2w11],所以对于位置 ij,若 (rki,rki+2w1)<(rkj,rkj+2w1),则从新的 rki<rkj,即从 i 开始的 w 级子串的排名小于从 j 开始的 w 级子串的排名。也就是说,(rki,rki+2w1) 在所有这样的二元组中的排名反映了 s[i,i+2w1] 在所有 w 级子串中的排名。

进行 O(logn) 层倍增后,所有 w2wn)级子串互不相同,排序结束。因为两个关键字值域大小均为 n,所以基数排序配合计数排序即可做到单次倍增 O(n),总复杂度 O(nlogn)

3.2.2 常数优化与注意点

朴素地实现倍增后缀排序常数太大,以下是常数优化技巧。

  • 对第二关键字的排序是不必要的:设当前子串长度为 2w

    i+w>n,则 rk(i+w)=0i 在第二关键字排序中被排到最前面;

    对于 1nw 的所有位置,我们希望按 rk(i+w) 递增的顺序排列它们,则 i+w 在原 sa 中按下标递增的顺序出现。考虑倒推,按下标 i 从小到大枚举 sai,若 sai>w,则加入 saiw。即 1nw 在第二关键字排序后的排列,等于所有大于 wsai 减去 w 后按下标从小到大排列。

  • 计数排序的桶大小:

    修改 rki 的定义为在所有 w 级子串当中小于 s[i,i+w1]不同的 子串数量加 1。这样有两个好处,一是计数排序的过程中对桶做前缀和时只需枚举到 rk 的最大值,即上一轮的 rk(san)(注意,尽管最终的 rksa 互逆,但后缀排序进行的过程中由于相同排名的存在,并不一定满足该性质),减小常数;二是若 rk(san) 已经等于 n,则所有后缀分化完毕,可以直接退出算法而无需等到 wn

在实现后缀排序时,还有一些注意点:

  • 初始令 rki=si,并以 si 为关键字计数排序得到初始 sa

  • 每次桶排后根据新的 sa 反推出 rk:从小到大枚举 i,若 (rk(sai1),rk(sai1+w))=(rk(sai),rk(sai+w)),则 rk(sai)=rk(sai1),否则 rk(sai)=rk(sai1)+1。在此之前需要将原 rk 数组拷贝一份到 ork,否则更新 rk 时会出错。

给出一份实现良好的后缀排序 模板题 代码,附有部分注释:

#include <bits/stdc++.h>
using namespace std;
constexpr int N = 1e6 + 5;
char s[N];
int n, sa[N], rk[N], ork[N], buc[N], id[N];
void build() {
  int m = 1 << 7, p = 0;
  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; i--) sa[buc[rk[i]]--] = i;
  for(int w = 1; ; m = p, p = 0, w <<= 1) { // m 表示桶的大小, 等于上一轮的 rk 最大值.
    for(int i = n - w + 1; i <= n; i++) id[++p] = i; // 循环顺序无关, 顺序倒序都可以, 不影响最终结果.
    for(int i = 1; i <= n; i++) if(sa[i] > w) id[++p] = sa[i] - w;
    memset(buc, 0, m + 1 << 2); // 注意清空桶.
    memcpy(ork, rk, n + 1 << 2); // 注意拷贝 rk -> ork.
    p = 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; i--) sa[buc[rk[id[i]]]--] = id[i]; // 注意, 倒序枚举保证计数排序的稳定性. 基数排序的正确性基于内层计数排序的稳定性.
    for(int i = 1; i <= n; i++) rk[sa[i]] = ork[sa[i - 1]] == ork[sa[i]] && ork[sa[i - 1] + w] == ork[sa[i] + w] ? p : ++p; // 原排名二元组相同则新排名相同, 否则排名 +1.
    if(p == n) break; // n 个排名互不相同, 排序完成.
  }
}
int main() {
  scanf("%s", s + 1);
  n = strlen(s + 1);
  build();
  for(int i = 1; i <= n; i++) printf("%d ", sa[i]);
  return 0;
}
  • 尽管上述代码中 sa[i - 1] + wsa[i] + w 可能达到 2n,但 ork 的大小只需要开到 n+1:若 ork[sa[i - 1]] != ork[sa[i]],则程序不会执行第二条判断,直接返回否。否则 ork[sa[i - 1]] = ork[sa[i]],说明 s[sai1,sai1+w1]=s[sai,sai+w1],据此可知 sai1+w1sai+w1n,因此 sai1+wsai+wn+1

3.3 Height 数组

  • 定义 hti 表示 suf(sai1)suf(sai) 的最长公共前缀长度 |lcp(sai1,sai)|,即排名为 i1i 的后缀的 LCP 长度。ht1 未定义,一般为 0

绝大多数 SA 的应用都需要 ht 数组的信息,很少见只用 sark 就可以解决的问题。不过分地说,后缀排序求 sark 只是为了求 ht

ht 用哈希 + 二分 LCP 太 low 了不是吗?完全没用上 sark 的优秀性质。

结论 1:若 rki<rkj<rkk,则 |lcp(i,j)||lcp(j,k)| 均不小于 |lcp(i,k)|

证明:设 t=|lcp(i,k)|,因为 sufj 的字典序在 sufisufk 之间,所以 sufj 的前 t 个字符必然和 sufisufk 相等。

结论 1 非常容易理解,因为字典序距离越近,LCP 越长。例如,按字典序排序后,两个 abcd 开头的字符串之间不会出现以 abccabce 开头的字符串。

若希望求出 ht 数组,自然考察其性质。

假设 hti 已知,则 |lcp(sai,sai1)|=hti。考察 suf(sai1+1)suf(sai+1),即排名分别为 i1i 的后缀位置加上 1 后对应的后缀。当 hti>0 时,显然有 |lcp(sai1+1,sai+1)|=hti1,且根据 rk(sai1)<rk(sai) 容易证明 rk(sai1+1)<rk(sai+1)

pq 分别表示排名为 ii1 的后缀位置加 1,即 sai+1sai1+1,我们尝试求出 rkp 对应的 ht。先梳理一下已有的信息:

  • |lcp(q,p)|=hti1
  • rkq<rkp

它们合起来表达了:存在位置 q,满足其排名小于 p 的排名,且它们对应后缀的 LCP 长度为 hti1

相信部分读者此时已经想到一些了不起的性质了。

排名为 rkp1 的后缀 sufr 的排名要么等于 rkq,此时 q=r,要么夹在 rkqrkp 之间,因为 rkr 是小于 rkp 的最大正整数 rkp1,而 rkq 小于 rkp。因此,根据结论 1,其与 p 之间的 LCP 长度必然不小于 hti1,即 ht(rkp)hti1

进一步地,注意到 rkp 表示排名为 i 的后缀位置加 1 的排名,i 表示排名为 i 的后缀位置的排名,即 ht(rk(sai+1))ht(rk(sai))1。换元,令 u=sai+1,我们得到了 ht 数组的核心性质(u>1):

ht(rku)ht(rku1)1

  • 没有讨论 hti=0 的情况,不过因为 ht 非负所以同样满足上式。
  • 因为 s0 为空字符,所以求出的 ht(1)=0

X2eJYj.png

如上图,p=sai+1q=sai1+1hti1=|lcp(p,q)||lcp(p,r)|=ht(rkp)

根据该性质,我们可以按 i 递增的顺序递推求解 ht(rki)。实际上就是按 i 从小到大的顺序计算位置为 i 的后缀与排名为其排名减 1 的后缀的 lcp。想象一个指针从 s1 扫到 sn,指针右移时,将右移前排名减 1 的后缀对应位置也向右移动一位,就可以理解 ht 数组的性质了。

下方代码中,k 指针向右移动的总次数为线性,因为每次 i 增加只会使得 k 减少 1,而 kn

for(int i = 1, k = 0; i <= n; i++) {
  if(k) k--;
  while(s[i + k] == s[sa[rk[i] - 1] + k]) k++; // sa[rk[i]] = i, 需要保证 s[0] 和 s[n + 1] 为空字符 (多测清空), 否则可能出错.
  ht[rk[i]] = k;
}

3.4 应用

3.4.1 任意两个后缀的 LCP

有了 ht 数组,我们可以快速求一个字符串 si 后缀与 j 后缀的最长公共前缀 lcp(i,j)

结论 2:设 rki<rkj,则 |lcp(i,j)|=minp=rki+1rkjhtp

证明:设 t=|lcp(i,j)|

根据字典序的定义,所有排名在 rkirkj 之间的后缀 sufk(rkirkkrkj) 的前 t 个字符均与 sufisufj 相等。又因为 sufi[t+1]sufj[t+1],所以存在两个相邻的排名 p,p+1,使得 suf(sap)[t+1]suf(sap+1)[t+1]。这样,对于所有 p[rki+1,rkj]htpt,且存在 htp=t

简单地说,i 后缀与 j 后缀的最长公共前缀长度就是夹在这两个后缀排名之间的 ht 数组的最小值。可以感性理解为字典序相差越大,前缀差别越大。

Tt6Les.png

上图为对 aabaaaab 后缀排序的结果及其 ht 数组,由矩形框起来的两个字符串相等。形象地,两个后缀之间的 lcp 就是它们排名之间所有矩形宽度的最小值,即 ht 的最小值。

如果将整张图逆时针旋转 90 度,将得到一张矩形柱状图。ht 恰好表示了每个矩形的高度,这可能也是 height 这一名称的来源。正因如此,SA 可以和单调栈相结合:众所周知,单调栈可以求出柱状图中面积最大的矩形。

查询区间最值,使用 ST 表维护即可做到 O(nlogn) 预处理,O(1) 在线回答询问。

注意点:

  • 查询范围是 rki,rkj 而非 i,j
  • rki>rkj 时,需要 swap(i, j)
  • 左边界要加 1
  • 需特判 i=j 的情况。

3.4.2 本质不同子串数

可以用 s 的所有后缀的所有前缀表示其所有子串。考虑每次添加一个后缀,并删去这个后缀与已经添加的后缀的所有重复前缀,即 maxjS|lcp(si,sj)|

因为 maxj<i|lcp(sai,saj)|=|lcp(sai,sai1)|=hti,所以按照 sa1,sa2,,san 的顺序添加后缀,sai 对答案的贡献即 (nsai+1)hti。化简得

(n+12)i=2nhti

上述做法求出了 s 的所有后缀的本质不同前缀数量。我们可以将其扩展到求 s 的某个后缀集合 S 的所有本质不同前缀数量。设 S 的所有元素(位置)按排名从小到大排序后分别为 p1,p2,,p|S|,类似地,答案为

(i=1|S|npi+1)(i=1|S|1|lcp(pi,pi+1)|)

后者对 ht 预处理 ST 表后 O(|S|) 求出。

3.4.3 结合单调栈

ht 数组可以被形象地认为是一张矩形柱状图,这是用单调栈解决问题的基础。

例如求所有后缀两两 LCP 长度之和,考虑按排名顺序加入所有后缀并实时维护 F(i)=p=1i1|lcp(sap,sai)|,即 p=1i1minq=p+1ihtq,可以视为往单调栈内加入高 hti,宽 1 的矩形后,单调栈内矩形面积之和。这容易维护的。

对所有 F(i) 求和即为所求。

例题:P4248,P7409,CF1073G。

3.4.4 多个串的最长公共子串

给定 nn>1)个字符串 s1,s2,,sn,求它们的最长公共子串。

t=s1+c1+s2+c2+cn1+snL=|t|,表示将所有字符串拼接起来,并用不属于字符集的分隔符隔开。对 t 建出 SA 数组,问题相当于求 max1lrLminp=l+1r|lcp(p,p1)|,其中 l,r 需满足每个字符串 si 均有一个后缀落在排名区间 [l,r] 内,因为子串需要同时出现在每个字符串当中。

容易发现随着 l 增大,r 单调不降,即若 [l,r] 满足限制,则 [l,r]llrr) 同样合法。因此用双指针维护整个过程。此外,我们需要实时维护区间最小值,根据端点单调性,使用单调队列即可。

双指针部分复杂度线性。

例题:P2463。

3.4.5 结合并查集

注意到两个后缀之间的 LCP 长度以它们排名之间的 ht 的最小值表示,所以当给定长度阈值 L 时,去掉所有 <Lht 将排名区间划分成若干子区间,同一子区间内任意两个后缀的 LCP 长度均 L

从大到小考虑所有 hti,每次在 sai1sai 之间连边并使用数据结构如 Kruskal 重构树或并查集 + 启发式 / 线段树合并维护这一过程,可以得到与每个后缀的 LCP 长度 L 的所有后缀的信息。

hti 建笛卡尔树效果相同。

例题:P2178,P7361。

3.5 例题

P3763 [TJOI2017] DNA

枚举开始位置并使用 SA 加速匹配。时间复杂度线性对数。代码

P2852 [USACO06DEC] Milk Patterns G

从大到小添加每个 hti,等价于在 saisai1 连边,使得出现大小为 k 的连通块的 hti 即为所求。

P2463 [SDOI2008] Sandy 的卡片

差分后求所有 n 个数组的最长公共子串,双指针加单调队列实现,2.4.4 有讲解。时间复杂度线性对数。代码

SP220 PHRASES - Relevant Phrases of Annihilation

建出 SA 数组,二分答案,检查每个 htmid 的极长排名区间是否合法。时间复杂度 O(SlogS),其中 S=|si|

P4248 [AHOI2013] 差异

F(i) 表示排名为 i 的后缀与排名小于它的后缀之间的 LCP 长度之和。

hti 看成宽 1hti 的矩形,则 F(i)=p=1i1minj=p+1ihtj,即 ht1i 组成的高度递增的单调栈内矩形面积和。用单调栈维护 F,依次求出 F(2n),答案即

(n+12)(n1)2i=2nF(i)

时间复杂度线性对数,代码

P7409 SvT

双倍经验。

CF1073G Yet Another LCP Problem

三倍经验。

求出 rk(ai)<rk(bj)|lcp(ai,bj)| 以及反过来的结果,再加上 ai=bjnai+1

aibj 按照 rk 排序,若 bj=ai 则认为 bj 在前,因为限制 rk(ai)<rk(bj)。若当前是 ai,则先将单调栈内高于 |lcp(ai1,ai)| 的部分削去,再加入高 nai+1 的矩形,前者可以视为加入宽 0,高 |lcp(ai1,ai)| 的矩形。若当前是 bj,则将单调栈内高于 |lcp(ai,bj)| 的部分削去,并将面积累和入答案,其中 ai 是上一个被考虑到的 a

时间复杂度 O((n+k+l)logn)代码

P4081 [USACO17DEC] Standing Out from the Herd P

将所有字符串用 不同 分隔符连接,后缀排序,对于颜色相同的一段排名区间 [l,r],先求出在考虑之前排名的后缀时,每个后缀的贡献 Lihti,其中 Li 表示排名为 i 的后缀长度。

这样,这段排名区间对答案的贡献还要减去 sufsarsufsar+1 之间的 LCP 长度 htr+1,再加上多减的部分,即 sufsal1sufsar+1 之间的 LCP 长度。

时间复杂度 O(nlogn)代码

P6640 [BJOI2020] 封印

求出 s 的每个后缀最长的作为 t 的子串的前缀长度,设为 fi。问题相当于求 maxi=lrmin(ri+1,fi)

直接二分答案 x,检查是否有 maxi=lrx+1fix 即可。时间复杂度线性对数。代码

*P2178 [NOI2015] 品酒大会

由于 r 相似也是 r0r<r) 相似,所以如果仅考虑 Lhti,将 sai1sai 之间连边,若 p,q 在同一连通块,说明 lcp(sufp,sufq)L,即 p,q 是 “L 相似” 的。

这启发我们求出 ht 后从大到小依次处理,并使用并查集和启发式合并实时维护每个连通块的大小以及所有权值。只需用最大值乘以次大值,以及最小值乘以次小值(因为有负数)更新 L 的答案,时间复杂度 O(nlog2n)

进一步地,只记录四个极值就不需要启发式合并 set,时间复杂度 O(nlogn)

*CF822E Liar

使用贪心的思想可知在一轮匹配中,我们能匹配尽量匹配,即若从 sitj 开启新的一段,那么我们一定会匹配直到第一个 k 使得 si+ktj+k。因为匹配到一半就断掉没有匹配到不能继续为止更优。若前者存在符合题意的分配方案,则后者必然存在,调整法易证。

注意到 x30 的限制,说明总状态数不会很多,考虑动态规划。

fi,j 表示 s[1,j] 选出 i 个子串时最多能匹配到 t 的哪个位置。对于每个 fi,j,首先可以转移到 fi,j+1 表示不开启一段匹配。若开启一段匹配,则需找到 s[j+1,n]t[fi1,j+1,m] 的最长公共前缀长度 L,并令 fi,j+Lmax(fi,j+L,fi1,j+L)

求一个字符串某两个后缀的 LCP 是后缀数组的拿手好戏,时间复杂度 O(n(x+logn))代码

本题同时用到了贪心,DP 和 SA 这三个跨领域的算法,是好题。

P5028 Annihilate

设排名 j 对应字符串 belj

枚举每个字符串 si,每个排名 jans(i,belj) 的贡献为 max(|lcp(j,pre)|,|lcp(j,suf)|),其中 pre 表示 j 的属于字符串 i 的排名前驱,suf 则为后继。

因此,从小到大枚举排名 j 并实时维护 mn=|lcp(j,pre)|,相当于 ht 的一段区间 min。若 belj=i,则 pre 变为 j,将 mn 设为 +

否则 mnmin(mn,htj),然后令 ans(i,belj)max(ans(i,belj),mn)。反过来再做一遍即可。

这样避免空间复杂度带 log。时间复杂度 O(L(n+logL))代码

P7769 丑国传说 · 大师选徒(Selecting Apprentices)

考虑 al+k+ab+k=s 的充要条件:设 di=ai+1aidn 未定义,需满足 al+ab=sdlr1dbb+(rl)1 互为相反数。

这启发我们把 d 以及 d 的相反数 d 拼接在一起得到序列 D。求出其后缀数组,问题转化为检查是否存在 D 的后缀 D[i,2n1] 满足 i>nain=sal|lcp(D[i,2n1],D[l,2n1])|rl

容易处理第三条限制:符合条件的后缀的排名是一段包含 rkl 的区间 [x,y]xrkly)。预处理 ht 的倍增数组,二分 + RMQ 求出。

对于前两条限制,对每个值 c 开桶 bucc 记录 i>nain=c 的所有 i 后缀的排名。询问即检查 bucsal 中是否存在 [x,y] 之间的数,排序后二分查找即可。注意特判 l=r 的情况。

时空复杂度均为线性对数。代码

P2603 [ZJOI2008] 无序运动

这道题的关键在于如何处理两个粒子片段相似。设两粒子片段分别为 (a1,,ak)(b1,,bk),设 Ai=aiai+1,记 f(a)=ABi 同理,得到两个向量序列 A,B

容易得知,不考虑翻转时a 相似于 b 当且仅当对于任意 i[1,k2],有序对 (Ai,Ai+1)(Bi,Bi+1) 相似,体现为它们的长度比相等 |Ai||Ai+1|=|Bi||Bi+1|,且 有向 夹角相等 Ai,Ai+1=Bi,Bi+1

用浮点数记录上述信息丢失精度,考虑记录向量长度的平方比,以及向量叉积与点积的比。一个细节,就是叉积比点积得到夹角正切值,但 tan 的周期是 π,无法唯一对应一个 2π 范围内的角度。此时我们需要保留叉积的符号,即约分时最大公约数取绝对值。

考虑翻转只需将 ai 关于 xy 轴对称,再做一遍上述判定即可。

因此,我们得到如下算法:用相邻向量之间的信息描述所有片段和粒子运动轨迹。类似字符串匹配,将信息离散化后容易使用 SA 或 AC 自动机求出每个片段在粒子运动轨迹和粒子运动轨迹关于 y 轴对称得到的轨迹中出现次数之和。

注意点:

  • 特判 k=2
  • 当某个片段翻转后与它本身不考虑翻转相似时,它在原粒子运动轨迹中每出现一次均会在翻转后的粒子运动轨迹的对应位置出现,被重复计算。因此要除以 2

时间复杂度 O((N+L)log(n+L))代码

P6095 [JSOI2015] 串分割

因为字符不含 0,所以贪心让最大位数最小。答案串长度 L=nk

答案满足可二分性。我们二分答案在后缀数组中的排名。破环成链,枚举 L 个起始点并判断是否可行。假设当前匹配到 i,若 s[i,i+L1] 的排名不大于二分的答案,那么就匹配 L 位,否则匹配 L1 位。若进行 k 次匹配后总匹配位数不小于 n 则可行。

若可匹配 L 位时匹配 L1 位,则下一次最多匹配 L 位,这与首先匹配 L 位时下一次匹配的最劣情况,即匹配 L1 位,效果相同。因此贪心正确。

进一步地,比较两个长度为 L 的字符串时,我们不需要求 LCP 并比较下一个字符。可直接比较它们对应的后缀。问题在于也许 s[i,i+L1]s[j,j+L1] 相等,其中 j 是排名为当前二分值的后缀开始位置,但 sufi>sufj,这使得我们认为只能匹配 L1 位而非 L 位。

但其实没有关系,因为若 s[j,j+L1] 作为答案串可行,则二分排名最大的以 s[j,j+L1] 作为前缀的后缀时必然可行。

时间复杂度 O(nlogn)代码

*P6793 [SNOI2020] 字符串

a+b 求后缀数组。类似品酒大会的套路,按 ht 大到小合并相邻两个排名的后缀,然后贪心消灭掉当前连通块尽可能多的 a,b 后缀对。

时间复杂度 O(nlogn)代码

P2336 [SCOI2012] 喵星球上的点名

将姓和名用分隔符连接,问题相当于给定 n 个文本串和 m 个模式串,对每个文本串求出作为其子串的模式串数量,对每个模式串求出包含它为子串的文本串数量。这是 AC 自动机经典应用,但因为字符集太大(也可以做),考虑其它做法。

将所有文本串用分隔符连接,建出后缀数组,对每个模式串求出以其为前缀的排名区间。第一问相当于区间不同颜色数,离线扫描线 BIT。第二问相当于对每种颜色查询与其有交的区间数。对每个区间和每个颜色在第一个位置统计答案,则每个位置对其对应的颜色的贡献为左端点落在一段区间,右端点落在另一段区间的区间数量,二维数点,离线扫描线 BIT。

时间复杂度线性对数。代码

P4143 采集矿石

字典序排名从大到小使得对于固定的 l,随着 r 增大,[l,r] 的排名 严格递减,重要度之和 非严格递增。只需二分出 [l,r] 的排名与重要度之和的交点,再检查是否符合要求。

考虑如何求某个子串 [l,r] 在所有本质不同子串中的排名。对于子串 [l,r],其字典序 非严格大于 [l,r] 当且仅当后者是前者的前缀,或者去掉 LCP 后前者的第一个字符大于后者。

满足第一种条件的子串对应后缀 [l,n] 的排名是一段排名区间 [L,R]Lrank(l)R)。满足第二种条件的子串对应后缀的排名是排名区间 [R+1,n]

因此,求出排名为 [L,n] 的后缀的本质不同前缀数量,减去 rl[l,r] 的真前缀被多算了)即为所求。前者根据后缀数组经典结论,为 (i=Lnnsai+1)(i=L+1nhti)

求出 L 只需二分找到不大于 rkl 的最小排名,使得排名为 Lrkl 之间所有后缀的 LCP 不小于 rl+1。对 ht 预处理 ST 表求 RMQ + 二分即可。

时间复杂度 O(nlog2n)代码

*CF1654F Minimal String Xoration

非常好题目,爱来自瓷器。

注意到一个重要性质,位运算在每一位独立。

f(i,d) 表示 sisi1si2si(2d1),即 s 的下标异或 i 得到字符串的前 2d 位。可知 f(i,d+1)=f(i,d)+f(i2d,d)

因此,类似后缀排序,设 p(i,d) 表示 f(i,d) 在所有 f(j,d)0j<2n)当中的排名,则 p(i,d+1) 即为二元组 (p(i,d),p(i2d,d)) 在所有二元组 (p(j,d),p(j2d,d)) 中的排名。

倍增并排序,时间复杂度 O(2nn2)。使用基数排序可以去掉一个 n,但并不必要。代码

*P7361 「JZOI-1」拜神

不错的题目。

建出 s 的后缀数组,考虑一次询问的本质。对于长度 L,它合法当且仅当存在两个位置 p,q[l,rL+1]pq),使得 lcp(sufp,sufq)L。根据套路,p,q 满足该条件当且仅当若将所有 Lhti 值对应的两个位置 sai1sai 之间连边,则 p,q 在同一连通块。

显然答案满足可二分性,因此着眼于判断一个长度 L 是否合法。借鉴品酒大会的技巧,我们求出 ht 数组后从大到小加入并查集,相当于每次合并两个位置 sai1,sai。对于每个长度 L,在线段树 TL 上记录每个位置 p 的后继 sucp,表示 sucp 是大于 p 且和 p 在相同连通块的最小位置。判断合法只需查询 TL[l,rL] 的区间最小值是否 rL+1

考虑如何维护 sucp:启发式合并。为并查集的每个代表元维护一个 set Si,每次往 Si 中插入一个数 ylower_bound 查询 y 的后继 su 与前驱 pr,在线段树上更新 sucprysucysu。由于要储存每个长度的线段树,所以可持久化。

时空复杂度均为线性对数平方。代码

*P5161 WD 与数列

据定义,差分数组相同的两个串相等。转化为求差分数组不相交且不相邻的相等子串对数量,补集转化得相等子串对数量减去相交或相邻的相等子串对数量。

对差分数组求后缀数组,按 ht 从大到小合并相邻两个排名的后缀。用线段树与启发式合并求出两个后缀所在连通块 X,Y 之间的两两贡献,即 xXyYmax(0,w+1|xy|),其中 w 为当前 ht。遍历较小集合 Y 的每个位置 y,在线段树 TX 上查询 [yw,y) 以及 (y,y+w] 两个区间位置个数与下标之和。最后线段树合并。

时间复杂度 O(nlog2n),空间复杂度 O(nlogn)代码

P5115 Check, Check, Check one two!

看到题目,我首先想到建出正反串 SA 及其 ht 的笛卡尔树,并在一棵树上启发式合并,另一棵树上用 P4211 LCA 套路做,掐指一算发现时间复杂度是 O(nlog3n),虽然离线(枚举 LCA,考虑较小子树对较大子树的贡献,将询问离线扫描线)后三个 log 分别是启发式合并,BIT 和树剖,显然卡不满,但是依然非常难写。

稍微观察一下 lcp(i,j)lcs(i,j),它们拼接起来形成长为 lcp(i,j)+lcs(i,j)1 的相等子串,联想到优秀的拆分,这启发我们在 (ilcs(i,j)+1,jlcs(i,j)+1) 处统计贡献。因为相等子串的要求 极长,否则 lcp(i,j)lcs(i,j) 可以更大,所以枚举 i,j,若 si1sj1,则 s[i,i+lcp(i,j)1] 产生贡献。进一步地,我们发现贡献和 i,j 具体无关,仅和 L=lcp(i,j) 相关,为 f(L)=p=1Lp(Lp+1)[pk1][Lp+1k2]f 可以 O(n) 预处理。

对于 si1sj1 的要求,直接容斥。问题转化为求与任意两个 (i,j)i<j)的 lcp(i,j) 相关的式子。经典套路,直接对 ht 做扫描单调栈,对于当前 i 实时维护 1j<if(minp=j+1ihtp) 即可。时间复杂度 O(n(logn+|Σ|))代码

理论可以做到关于长度加字符集线性(线性 SA,线性区间 RMQ),但不实用。

听说官方题解是 log2n 的边分树,应该是对题解一开始的 log3n 思路应用更多套路。对比两种做法,直接硬做没有用到拼接成相等子串的性质,而扫描单调栈巧妙运用了该性质。对于前者,可以扩展至无法拼接的问题而后者不能,如给定排列 p,将原问题 lcp(i,j) 换成 lcp(pi,pj)。对于后者,可以扩展至任意容易快速计算 f 的情形,如 (lcp(i,j)lcs(i,j))k

*P1117 [NOI2016] 优秀的拆分

本题巧妙的地方有两点,一是通过乘法原理将 AABB 转化为以每个位置开头和结尾的 AA 数量 gi/fi,二是枚举 A 的长度 L设置关键点fg

对于固定的 L,若每间隔 L 放置一个关键点,则 AA 必然恰好经过两个关键点。不妨设为 pq=p+L。我们在 q 处统计 fi,在 p 处统计 gi,即可能的开始位置,且 q 管辖的右端点(结尾位置)范围为 [q,min(n,q+L1)]p 管辖的左端点(开始位置)范围为 [max(1,pL+1),p]

求出 sufpsufq 的最长公共前缀长度 r,以及 preppreq 的最长公共后缀长度 l

l 限制了 AA 开头和末尾的最小值。preppreq 的 LCS 只有 l,所以对于任意 i[pl+1,p] 均有 si=si+L,但 splsql。因此,AA 的开头位置不能在 pl+1 左边,对应末尾位置不能在 (pl+1)+2L1q+Ll 左边。

同理,AA 末尾的最大值不能超过 q+r1,对应开头位置不能在 (q+r1)2L+1pL+r 右边。

综上,我们需要对 f 进行 [q+Ll,q+r1] 区间加 1,对 g 进行 [pl+1,pL+r] 区间加 1。差分维护即可。

求任意两个前缀的 LCS 或任意两个后缀的 LCP 可借助 SA 实现。

时间复杂度线性对数,包括建出 SA,建出 ht 的 ST 表以及枚举 L 的调和级数。代码

从这道题开始,设置关键点变成了经典套路。

*SP687 REPEATS - Repeats

借用优秀的拆分的套路,直接枚举循环节长度 L,每相邻 L 个位置放置一个关键点。容易发现,若某个长度为 L 的子串连续出现 k 次,则它恰好跨过 k 个关键点。

Sol 1:显然答案满足可二分性。二分 k,考虑所有连续 k 个关键点 p,p+L,,p+(k1)L,若这些位置对应的所有后缀的 LCP(长度 tor)加上它们对应的所有前缀的 LCS(长度 tol)减去同时覆盖的关键点后覆盖的长度不小于 L,即 tor+tol1L,说明出现 (k,L) - repeat。从小到大扫过所有连续的 k 个关键点,用单调队列维护当前窗口内关键点排名的最大值和最小值方便求 LCP & LCS。因为关键点总数为调和级数的 O(nlnn),所以时间复杂度 O(nlog2n)

Sol 2:Sol 1 太不优美了。L 固定时,称关键点区间 [l,r] 表示关键点为 pl,pl+1,,pr(pi=pi1+L) 是优秀的,当且仅当它对应的 tor+tol1 不大于 L,出现 (rl+1,L) - repeat。显然若 [l,r] 优秀则其任何子区间优秀,推得 r 增加时最左合法左端点单调不降,满足 two-pointers 的条件。双指针替代二分,时间复杂度 O(nlogn)

Sol 3:Sol 2 仍不够优美。根据 border 论最经典结论,s 有长 L 的 period 当且仅当 s 有长 nL 的 border,即 s[1,nL]=s[L+1,n]。考虑相邻关键点 pq=p+L,求出 tor=|lcp(sufp,sufq)|tol=|lcs(prep,preq)|,说明 sufptol+1sufqtol+1 有长为 tol+tor1 的 LCP。为使得覆盖 p,q(k,L) - repeat 最长,它会从 ptol+1 开始,满足有 border s[ptol+1,p+tor1]=s[qtol+1,q+tor1]。容易发现对应出现次数 k 等于 (q+tor1)(ptol+1)+1Ltol+tor1L 下取整后加 1。所有相邻两个 p,q 的该式最大值即为所求。时间复杂度仍为线性对数,但代码好写很多。代码

CF1608G Alphabetic Tree

毒瘤细节码农题。

首先,对于一次询问 Q(u,v,l,r),由于信息具有可减性,所以转化为 Q(u,v,1,r)Q(u,v,1,l1),记为 Q(u,v,r)Q(u,v,l1)。相当于做一个扫描线。

对于后缀数组,对特定 t 求解 t 在文本串 s 中出现次数的方法是二分找到第一个 t 的后缀排名 L,以及最后一个 t 的后缀排名 R。任何排名在 [L,R] 之间的后缀均以 t 为前缀,代表 ts 中的一次出现。

对于本题也一样。我们先对 si 进行后缀排序,设当前扫描线到位置 p,则管用的只有 s1p 的后缀。对于一次询问 Q(u,v,p),我们只需要对 uv 形成的字符串 t(uv) 进行上述操作即可。

具体地,二分排名 m,问题转化为比较 t(uv) 和排名为 m 的后缀 s 的大小关系。一般的比较方法是二分 LCP 然后判下一个字符的大小关系。对于本题,如果再二分 LCP len,那么需要求出 s[1,len] 的哈希值,以及 uv 长度为 len 的前缀的哈希值,后者需要树上倍增求解,时间复杂度 O(qlog3n),不可接受。但我们注意到倍增的过程本质上也是一种二分,因此将倍增和二分结合起来即可做到 O(qlog2n)

求得 t(uv) 对应的排名区间 [L,R] 后,只需求出当中有多少个管用的后缀,扫描线时 BIT 维护即可。代码。以下是一些注意点:

  • 哈希值的每一位不能直接减去 'a',否则 aabab 会被视作相等。
  • 哈希 base 应大于多串 SA 插入分隔符的最大数值。
  • 二分 L 的下界为 1,上界为 SA 总长加 1R 的上下界要减去 1
  • 注意分清排名和下标。

*GYM102803E Everybody Lost Somebody

一道考察对 SA 的 sarkht 数组综合理解的题目。

对于 hti1,枚举 j[0,hti),则 s[sai+j]=s[sai+1+j],且 s[sai+hti]<s[sai+1+hti]。若 hti 不存在等于 1 的情况,则直接并查集合并相同的位置,然后对于小于的限制连边跑拓扑排序即可。

接下来考虑 hti=1。此时对于 saisai+1 的 LCP 没有限制,唯一的限制就是 susai<susai+1。这对 s[sai]s[sai+1] 提出了一些要求。

使用倍增桶排求解 SA 的思想,我们知道 su(sai)su(sai+1) 的大小关系取决于 s[sai]s[sai+1] 以及 su(sai+1)su(sai+1+1)。当 rk(sai+1)<rk(sai+1+1) 时,s[sai] 只需不大于 s[sai+1],否则 s[sai] 需要小于 s[sai+1]

对于所有不大于和小于的限制,通过赋边权 01 结合拓扑排序求解。时间复杂度 O(n2)代码

接下来考虑加强版 n106。复杂度瓶颈在于并查集合并相等字符的限制,但显然我们有很多合并都是浪费的,有用的合并最多只有 n1 次。

注意到 s[i]=s[j] 意味着对于所有 p[rki,rkj](不妨设 rki<rkj)均有 s[i]=s[sap]。这相当于为 rkirkj1 打上标记。位置 p 被打上标记表示 s[sap]=s[sap+1]n2 次区间修改操作,通过差分转化为 n2 次单点修改。

注意到对于一个 sai,所有单点加 1rk(sai+j) 关于下标 sai+j(注意不是关于排名 rk(sai+j))形成一段区间 [sai,sai+hti)。这相当于对给 rkp 打标记的差分序列进行 关于原序列下标 p 的区间修改,二次差分即可。

容易将并查集的复杂度去掉,时间复杂度 O(n)代码

*CF1043G Speckled Band

易知答案不超过 4,因为若出现两个相等的字符,可以用 bacad 的方式分割字符串。因此我们尝试对每种情况分别讨论一下,令 t=slr

显然,答案为 1 当且仅当 t 不包含相同字符。

若答案为 1,则 t 有整除它长度的周期,等价于 t 有长为 |t|d 的 border。枚举因数判一下即可,用哈希或各种后缀字符串结构均可。

若答案是 2,则 t 必然形如 abaaababb:若 t 存在 border 则存在不相交 border,直接把 border 割来即可;当 t 不存在 border 时,它的两端不同,形如 ab。若形如 abab 则存在 border,矛盾,因此形如 aababb,可转化成 aababb

aba 这种情况有些棘手,我们放在最后讨论。aabbaa 本质上是一样的,可以通过 “优秀的拆分” 枚举长度 + 设置关键点的套路求出所有形如 aa 的字符串的出现位置,形成共 nlnn 个区间。维护 lfti 表示从 i 开始最短的形如 aa 的字符串长度,rti 则表示以 i 结尾的。每个区间相当于为 lftrt 区间 checkmin。从小到大枚举长度,并查集维护。

若答案为 3,首先两端必然不等,形如 ab。若 中出现 ab,那么直接 abcb 或者 acab 即可。只需判 slsr 是否在 sl+1r1 当中出现过。否则根据出现次数 2 的限制,只能为 accb,简化为 accb。因为维护了 lft 这个信息,直接判断是否有 mini=lr(i+lfti1)r 即可。

若上述条件均不符合,则答案为 4

最后解决一个遗留问题:求一个子串是否存在 border。当然这可以通过 “border 的四种求法” 的 border 论或 SAM + 树剖解决,但因为只需判断 border 的存在性,所以存在一个优美且巧妙的根号做法。

若 border 相交,则必然形成长度更短的 border。因此我们不妨 钦定 border 不交,得到根号分治做法:若 border 长度小于 n,直接枚举。若 border 长度不小于 n,那么它在整个字符串中的出现次数不超过 n,可在后缀数组上枚举 l 后缀的排名的半径为 n 的排名邻域判断。

时间复杂度 O(nlogn+qn),但是很优美。代码

*牛客多校 2022#6L Striking String Problem

给定字符串 S,T,正整数 k2k 个整数 li,ri,令 U=S[l1,r1]++S[lk,rk]q 次询问给定 x,y,求 TU[x,y] 中的出现次数。

1liri|S|1061k,q5×1051|T|5×1051xiyi|U|,时间限制 8s,空间限制 1G。

模拟赛题加强出到牛客多校了,比赛链接

定义 ST 的最长公共后缀前缀为 X,其中 X 最长且 S[|S||X|+1,|S|]=T[1,|X|],注意顺序。

Li=rili+1Qi=S[li,ri]

将询问差分,变成 TU[1,r] 中的出现次数减去 TU[1,l+T2] 中的出现次数。因此,简化询问形式为求 TU[1,P] 中的出现次数,用数 P 描述。

单模式串整体匹配通常使用 KMP,对 T 求 KMP 数组 nxt。令 Ui=Q1++Qi,考虑 U 不断匹配 T 的过程,不妨设 Ui1T 的最长公共后缀前缀长度为 M,即 Ui[|Ui|M+1,|Ui|]=T[1,M]

朴素算法为暴力匹配 S[li,ri] 并更新 M,时间复杂度 O(|U|),无法通过。

注意到在处理 S[li,ri] 时只需知道 M 的值而不关心 Ui1 具体长什么样,因此,实时维护 M 即可独立各个 S 的子串,对它们分别处理。

将问题分成两部分求解,一为计数,二为维护 M

  • 维护 M

问题相当于求 T[1,M]+QiT 的最长公共后缀前缀长度 M

两种情况,M>LiMLi

对于后者,只需预处理 pi 表示 S[1,i]T 的最长公共后缀前缀长度,pi 可用 TS 做 KMP 匹配求得。注意要在 KMP 树(也称失配树,border 树)上倍增到第一个 Li 的节点。

对于前者,令 D=MLi,则 T[MD+1,M]+Qi=T[1,M]。考虑其充要条件:

  • 第一,T[MD+1,M]=T[1,D],即 T[1,D]T[1,M] 的 border。
  • 第二,Qi=T[D+1,M],即 S[li,|S|]T[D+1,|T|] 的最长公共前缀不小于 Li

考虑条件 1,建出 T 的 border 树,则合法的 D 限制为 M 及其祖先。

考虑条件 2,建出 S+T 的后缀数组,使得 |lcp(S[li,|S|],T[D+1,|T|])|Li 的排名为一段区间 rkli,rkri,可预处理 ht 倍增数组后对每个 Qi 二分求得。

容易发现,只需求得 M 数值最大的祖先 D,使得 T[D,|T|] 在后缀数组的排名在区间 [rkli,rkri] 中。

树上可持久化线段树,BM 维护 M 及其所有祖先 A 对应后缀 T[A,|T|] 的排名。下标表示排名,区间维护排名落在该区间的 T[A,|T|]A 的最大值,求 D 即对 BM 查询区间 [rkli,rkri] 最值。

  • 计数:

为方便计数,限制 M<|T|。即若上一部分求得 M=|T|,则令 MnxtM

处理 Qi 时,我们回答落在 (|Ui1|,|Ui|] 的询问。用变量 alr 表示 TUi1 中的出现次数,并实时维护 alr

仔细思考后容易发现一次询问相当于求 TT[1,M]+S[li,li+P1] 中的出现次数,加上 alr,用 query(M,li,P) 描述。

同样,分成两种情况讨论,T 是否完全包含于 S[li,li+P1]

若完全包含,首先 P 不小于 T,考虑预处理 pi 的过程中一并求出 TS 的所有出现位置 E(结尾对应位置,即 T=S[e|T|+1,e](eE))。问题即求 E 中落在 [li+T1,li+P1] 的数的个数。静态区间和,前缀和即可。

若不完全包含,设出现位置在 S 中对应 X,即 T=T[M(|T|(Xli+1)+1)+1,M]+S[li,X],其中 |T|(Xli+1)+1T 这次出现在 T[1,M] 的覆盖长度,令 D 为该值,则 T[1,D]=T[MD+1,M]

因此,类似维护 M 的情况,对 M 的每个 D 检查是否对应一次出现。考虑其充要条件:

  • D+P|T|
  • |lcp(T[D+1,|T|],S[li,|S|])|TD

不同于维护 M,这次不等式右侧的 TD 仅与 T 有关,但合法的后缀在后缀数组上仍为一段区间。同样,树上可持久化线段树 CM 维护,注意修改形式为区间加法,需标记永久化。

查询时,树上倍增找到最深的使得 A+P<TA(若不存在可视为 0),在 CMCA 上查询 S[li,|S|] 排名处的取值即可。

更新 alr 只需将其加上 query(M,li,Li)

减小空间常数的方法:问题转化为给定一棵树,每个点有区间 [ci,di],查询某个点到根的路径上对应区间包含 ei 的点的数量,可离线扫描 + BIT。

综上,设 n=|S|m=|T|,则时间复杂度 O((k+q+n+m)log(n+m)),空间复杂度 O((n+m)log(n+m))

*CF917E Upside Down

1 为根,将问题分成直链和跨过 d=lca(u,v) 分别求解。设 str(u,v) 表示 uv 的路径所表示字符串。

对于直链,相当于 skstr(d,v) 中的出现次数加上 skRstr(d,u) 中的出现次数。差分转化为从根到某个点的路径上 skskR 的出现次数。分别建出正串和反串的 AC 自动机,则问题形如从根到某点在 AC 自动机上形成的路径上满足以 sk 作为 fail 树上的祖先的状态。dfs 时用树状数组维护单点修改,区间查询。

对于跨过 LCA,比较困难。考虑求出 str(u,d) 最长 真后缀 P 使得它为 sk 的前缀,str(d,v) 最长 真前缀 Q 使得它为 sk 的后缀,则所有 P 的 border 均可与 str(u,d) 对应长度的后缀匹配,Q 的 border 均可与 str(d,v) 对应长度的前缀匹配。因此,若 xP 的 border 长且 |sk|xQ 的 border 长,则产生一次贡献。建出 skskR 的失配树 FFR,则问题形如给定 p,q 求满足 xF 上的 p 到根的路径上且 |sk|xFR 上的 q 到根的路径上的 x 的数量。树上二维数点,对 F 离线 dfs,每遍历到一个点 x 就将 |sk|x 加入当前集合 S,并回答 p=x 的询问 (p,q),即查询 Sq 的祖先的数量。时间戳拍平 FR 之后区间修改,单点查询。离开时再将 |sk|x 删去。

问题转化为求 PQ 的长度。对于形如给定 s,t,求 t 的最长后缀使得它是 s 的前缀的问题,有两个经典解法,Z 算法和后缀数组。Z 算法灵活性较差,本题一个字符串通过树上路径确定,不可行,因此考虑后缀数组。对 t 建出后缀数组,二分得到 s 在后缀数组中的位置,求出使得 lcp(s,t[p,|t|]) 最大的 p 以及对应 LCP L,则 t[p,|t|] 最长的长度 L 的 border 即为所求。

对于本题可以如法炮制。求 P 时,求 str(d,u) 最长真前缀使得它为 skR 的后缀。求 Q 时,求 str(d,v) 最长真前缀使得它为 sk 的后缀。在后缀数组中定位时需要比较链和某个后缀的大小,二分 LCP 并比较下一个字符。但二分 LCP 之后还要求树上 k 级祖先,写长剖太麻烦了,将倍增和二分结合在一起即可做到单次比较 O(logn)

时间复杂度 O(qlog2n)代码

CHANGE LOG

  • 2021.12.12:新增 KMP 算法与 Z 算法。
  • 2021.12.13:修改部分笔误。
  • 2021.12.23:新增前言。
  • 2021.12.24:新增 SA 应用部分。
  • 2022.1.10:新增几个 SA 应用与例题。
  • 2022.6.10:重构文章,修改表述。
  • 2023.1.23:修改表述。
  • 2023.2.3:移除 KMP 和 Border 论。
  • 2023.4.8:更新后缀排序的介绍。

参考资料

定义

第一章

第二章

第三章

posted @   qAlex_Weiq  阅读(6692)  评论(9编辑  收藏  举报
相关博文:
阅读排行:
· winform 绘制太阳,地球,月球 运作规律
· TypeScript + Deepseek 打造卜卦网站:技术与玄学的结合
· AI 智能体引爆开源社区「GitHub 热点速览」
· 写一个简单的SQL生成工具
· Manus的开源复刻OpenManus初探
点击右上角即可分享
微信分享提示