字符串一家子

NOIP 前的基本所有字符串内容

2025 11月 06 周四
2005 字 · 10 分钟

s[l,r]s _ {[l,r]}ss 中区间 [l,r][l,r] 构成的子串。

0. 字符串哈希

原理不再赘述,尽量选几个不常见的模数与底数,实在不行多哈希几次。

还有和哈希不要用,用一个卡一个。

顺手提一个基本不会被卡的哈希,splitmix64,实现如下:

inline uint64_t splitmix64(uint64_t x)
{
x += 0x9e3779b97f4a7c15;
x = (x ^ (x >> 30)) * 0xbf58476d1ce4e5b9;
x = (x ^ (x >> 27)) * 0x94d049bb133111eb;
return x ^ (x >> 31);
}

这几个 magic number 太难记了怎么办?随机数也可以,改了之后就是这样:

mt19937_64 rnd(random_device{}());
inline uint64_t splitmix64(uint64_t x)
{
const static uint64_t r1 = rnd(),r2 = rnd(),r3 = rnd();
x += r1;
x = (x ^ (x >> 30)) * r2;
x = (x ^ (x >> 27)) * r3;
return x ^ (x >> 31);
}

1. KMP

主要是用来算前缀的最长 Border,Border 指相同的前后缀,即 s[1,i]=ss _ {[1,i]} = s _ {}

主要思想就是如果发生失配,我们可以把前缀移到最长的相等后缀处,减少匹配次数。记 kmpikmp _ i 表示 s[1,i]s _ {[1,i]} 的最长 Border 长度,则我们考虑用类似 DP 的方式来做:

对于一个 ii,我们考虑扩展 kmpi1kmp _ {i - 1},如果 si=skmpi1+1s _ i = s _ {kmp _ {i - 1} + 1},那我们就可以直接扩展 kmpi1kmp _ {i - 1},即 kmpikmpi1+1kmp _ i \gets kmp _ {i - 1} + 1

如果失配,我们考虑求 kmpkmp 是为了干什么,就是为了解决失配问题,所以我们可以直接利用这个 kmpkmp 来跳,每次,直到跳到一个 jj,使得 sj+1=sis _ {j + 1} = s _ i,或者 jj 变为 00。然后,这一段的前缀和后缀就匹配了,就可以向上面那样做了。

写成代码是这样的:

int kmp[];
inline void initKMP(string T)
{
int n = T.length();
T = ' ' + T;
for(int i = 2,j = 0;i <= n;i++)
{
while(j && T[j + 1] != T[i])
j = kmp[j];
if(s[j + 1] == s[i])
++j;
kmp[i] = j;
}
}

匹配应该不怎么常考,但还是和上面类似,区别就是匹配长度为 T|T| 的时候记录一下匹配,代码在下面。

int kmp[];
inline void initKMP(string& T);
inline void query(string &S)
{
int n = T.length(),m = S.length();
for(int i = 1,j = 0;i <= m;i++)
{
while(j && T[j + 1] != S[i])
j = kmp[j];
if(T[j + 1] == S[i])
++j;
if(j == n)
// Matched
}
}

求最小循环周期

对于一个字符串,其最小循环周期长度为 nkmpnn - kmp _ {n},如果可以整除,则 SS 可以被长为 nkmpnn - kmp _ n 的字符串 TT 循环表示,否则 SSTT 拼接若干次的一个子串。

KMP 自动机

可以认为是单串的 ACAM。

和 ACAM 一样,有个 Fail 指针,但是我们没必要像 ACAM 那样建。我们定义 δ(i,j)\delta(i,j) 为第 ii 位,字符为 jj 的转移,则有 δ(i,j)={ij=si+1δ(faili,j)jsi+1\delta(i,j) = \begin{cases} i&j = s _ {i + 1} \\ \delta(fail _ {i},j)&j \neq s _ {i + 1} \end{cases}

发现每一次的更新都是复制一个数组再进行单点修改,所以对于较大的字符集可以用可持久化区间树来建。对于每个点,Fail 指向的点的深度就是这个前缀的最长 Border 长度。

exKMP / Z 函数

我们设一个字符串的 Z 函数 ziz _ iSSS[i,n]S _ {[i,n]} 的 LCP 长度。

对于 z1z _ 1,显然为 nn。否则,上面的等价于 SSS[i,n]S _ {[i,n]} 的最长公共子串长度,我们可以维护一个 l,rl,r 表示 s[l,r]s _ {[l,r]} 是一个匹配的最长公共子串,对于一个 ii,若 iri \le r,则 zi=min{zil+1,ri+1}z _ i = \min \{ z _ {i - l + 1},r - i + 1 \}

然后我们暴力扩展,如果最后 ii 超过了 rr,更新 l,rl,r

类似于 KMP,我们也可以求出 TTSS 的每一个后缀的 LCP 长度,记为 pp,则和上面类似,若 iri \le r,有 pi=min{zil+1,ri+1}p _ i = \min \{ z _ {i - l + 1},r - i + 1 \},随后暴力扩展,更新边界。

代码如下:

string S,T;
int n,m,z[MAXN],p[MAXN];
inline void initZFunc()
{
z[1] = n;
for(int i = 2,l = 0,r = 0;i <= n;i++)
{
if(i <= r)
z[i] = min(z[i - l + 1],r - i + 1);
while(i + z[i] <= n && T[i + z[i]] == T[z[i] + 1])
++z[i];
if(i + z[i] - 1 > r)
l = i,r = i + z[i] - 1;
}
}
inline void exKMP()
{
initZFunc();
for(int i = 1,l = 0,r = 0;i <= m;i++)
{
if(i <= r)
p[i] = min(z[i - l + 1],r - i + 1);
while(i + p[i] <= m && S[i + p[i]] == T[p[i] + 1])
++p[i];
if(i + p[i] - 1 > r)
l = i,r = i + p[i] - 1;
}
}

2. AC 自动机

用来做多串匹配的东西。

我们考虑用 Trie 来维护多字符串的结构。由于 Trie 的结构我们已经维护了前缀信息,我们只要考虑怎么维护后缀信息就能维护子串信息了。所以我们定义 Fail 指针,failufail _ u 指向与从根结点到 uu 形成的字符串中有最长公共后缀的且深度低于 uu 的结点。然后将 Trie 的 son(u,c)son(u,c) 转换成 δ(u,c)\delta(u,c),就是原来有的不动他,否则 δ(u,c)=δ(failu,c)\delta(u,c) = \delta(fail _ u,c),同时可以维护 Fail,若有 son(u,c)=vson(u,c) = v,则有 failv=δ(failu,c)fail _ v = \delta(fail _ u,c)

然后字符串匹配的话,我们就在将模式串插入 ACAM 的时候打标记,然后匹配的时候将文本串放到 ACAM 上走,走到一个点,我们跳 Fail,因为 Fail 是最长的后缀,可能有不是最长的后缀匹配上了。

我们发现 Fail 形成一棵树,而匹配的过程就是对于一个点 uu 求出到根结点路径上的某些信息,这启发我们直接对 Fail 建树,然后跑树剖或者 DFS 序之类的算法。

ACAM + DS

主要就是从 Fail 树上看。

匹配的实质是 Trie 图的一条路径上所有点的 Fail 树上的祖先权值和,所以对于每一个点在建 Fail 的时候加上父亲的权值。

如果是动态删增字符串的话可以处理出 DFS 序之后用树状数组或线段树维护,如果强制在线可以考虑二进制分组后重构。

ACAM + DP

这种 DP 比较套路,设 dpi,udp _ {i,u} 表示考虑长为 ii 的字符串,在 ACAM 上的点 uu 的某种值,转移就是枚举转移到哪个点。

然后这个可以套上各种通用优化。

3. Manacher

首先给两个相邻字符间加上一个特殊字符,首尾也要加,这样可以减去奇偶性的讨论。

我们考虑对每个位置求出一个回文半径 pp,表示 s[ipi+1,i+pi1]s _ {[i - p _ i + 1,i + p _ i - 1]} 是一个回文串,像 exKMP 那样,我们维护 mid,rmid,r 表示目前维护的回文区间为 [l,r][l,r],中点为 midmid

对于一个 ii,若有 iri \le r,则将 pip _ i 设为对称点的 pp,即 pi=min{p2midi,ri+1}p _ i = \min \{ p _ {2mid - i},r - i + 1 \},然后暴力扩展,随后若超出边界则重新维护 mid,rmid,r

4. 子序列自动机

顾名思义就是解决子序列有关问题的自动机。

转移函数 δ(u,c)\delta(u,c) 表示从 uu 开始往后的第一个字符 cc 的位置,没有则为一个虚点,可以维护为 n+1n + 1。我们可以从后往前建自动机,初始先将每个 δ(n+1,c)\delta(n + 1,c) 设为 n+1n + 1,然后从 nn11,复制 δ(i+1)\delta(i + 1) 后将 δ(i,si)\delta(i,s _ i) 设为 ii。因为只有复制和单点修改所以字符集较大时同样可以用可持久化区间树来优化。

匹配子序列就可以将文本串放到子序列自动机上,每次走下一个,最后不在 n+1n + 1 就是一个子序列。


Thanks for reading!

字符串一家子

2025 11月 06 周四
2005 字 · 10 分钟