字符串常见算法
1.KMP 算法
引入
border
下文默认字符串从 1 开始。
\(border\) 的定义,若对于字符串 \(s\),长为 \(n\),
若存在 \(s[1,r]=s[n-r+1,n](r\not=n)\),那么称 \(r\) 为 \(s\) 的 \(border\).
\(fail\) 数组的定义为当前位置的最长 \(border\).
我们称 \(fail_i\) 为前缀函数。
\(border\) 有一个性质:\(border\) 的 \(border\) 也是原串的 \(border\)。
即 \(fail_{fail_i}\) 也为 \(i\) 的 \(border\)。

计算 fail
当我们计算到 \(fail_i\) 时,我们保存一个指针 \(j\),指向 \(fail_{i-1}\),
我们已知 \(s[1,j]=s[i-j,i-1]\),我们尝试用 \(i-1\) 的 \(border\) 来计算 \(fail_i\)。
若存在一个最大的 \(border\) 为 \(r\),满足 \(s_{r+1}=s_i\),那么我们就可以知道 \(s[1,r+1]=s[i-r,i]\)
我们不断的令 \(j=fail_j\),直到 \(s_{j+1}=s_i\),或 \(j=0\)。
若 \(s_{j+1}=s_i\),那么 \(fail_i=j+1\),否则 \(fail_i=j\)。
初始的,令 \(fail_1=0\)。
时间复杂度 \(O(n)\)。
可能的实现代码:
code
fail[1]=0;
for(int i=2,j=0; i<=m; i++) {
while(j&&s[j+1]!=s[i]) j=fail[j];
if(s[j+1]==s[i]) j++;
fail[i]=j;
}
用于字符串匹配问题
假设有字符串 \(s\),\(t\),要求你计算 \(s\) 中出现多少次 \(t\)。
把 \(t\) 求一遍 \(fail\) 数组。
若匹配到 \(s\) 第 \(i\) 位,保存一个指针 \(j\),表示当前 \(t\) 匹配到 \(j\) 位。
我们不断的令 \(j=fail_j\),直到 \(t_{j+1}=s_i\),或 \(j=0\)。
若 \(t_{j+1}=s_i\),那么令 \(j=j+1\),否则 \(j=0\)。
直到 \(j\) 把 \(t\) 扫完了,那么就相当于出现多一次,此时令 \(j=fail_j\),继续计算后面。
fail 树
我们发现 \(fail\) 指针可以形成树形结构。
应用
1.P4824 [USACO15FEB] Censoring S
KMP + 栈,若 \(S\) 中出现了 \(T\),那么就弹栈。
此时 \(j\) 指针需要恢复到弹栈前,继续计算。
2.UVA11022 String Factoring
KMP + 区间 Dp。
若 \(k\) 为 \(s[i,j]\) 的循环节,那么 \(f_{i,j}=f_{i,i+k-1}\)
循环节可以用 KMP 求出,为 \(n-fail_n\)。
\(f_{i,j}=\max(f_{i,t}+f_{t+1,j})\)
2.扩展 KMP (Z function)
引入
Z 函数
由于未知的原因,下文默认字符串从 0 开始。
函数 \(z(i)\) 表示 \(s[0,n-1]\) 和 \(s[i,n-1]\) 的最长公共前缀。
即原串和以 \(i\) 为后缀的 LCP。
特别的 \(z(0)=0\)。
在线性时间计算 Z 函数
设我们现在求 \(z(i)\),则 \(z(0)\sim z(i-1)\) 均已求出。
设 \(p=k+z(k)-1\) 的最大值,其中 \(0<k<i\)。
根据定义我们发现有 \(s[0,z(k)-1]=s[k,p]\)
而现在我们要求的是 \(s[i,n-1]\) 与 \(s[0,n-1]\) 的 LCP。
由于 \(s[0,z(k-1)]=s[k,p]\),
那么 \(s[i-k,z(k-1)]=s[i,p]\)

如图:相同颜色的部分相同。
定义 \(l=z(i-k)\)。
那么 \(s[0,l-1]=s[i-k,i-k+l-1]=s[i,i+l-1]\)。
所以形成下图:

那么 \(z(i)=l\)。
但是还有一种情况。

如果 \(i+z(i)-1>p\) 了,那么就需要逐位比较,并更新 \(k=i\) 了。
可能的实现代码:
code
void getZ(char *c) {
int len=strlen(c);
int p=0,k=1,l;
z[0]=len;
while(p+1<len&&c[p]==c[p+1]) p++;
z[1]=p;
for(int i=2; i<len; i++) {
p=k+z[k]-1;
l=z[i-k];
if(i+l<=p) z[i]=l;
else {
int j=max(0,p-i+1);
while(i+j<len&&c[i+j]==c[j]) j++;
z[i]=j;
k=i;
}
}
}
利用 Z 函数解决后缀匹配问题
给出字符串 \(a\),\(b\),求 \(a\) 的每个后缀和 \(b\) 的 LCP。
我们设 \(ext(i)\) 为 \(a\) 的第 \(i\) 个后缀的 LCP。
那么我们设 \(p=k+ext(k)-1\) 的最大值。
我们模仿 Z 函数的求解过程,就可以求出 \(ext(i)\)。

可能的代码:
code
void exkmp(char *a,char *b) {
int la=strlen(a),lb=strlen(b);
int p=0,k=0,l;
while(p<la&&p<lb&&a[p]==b[p]) p++;
ext[0]=p;
for(int i=1; i<la; i++) {
p=k+ext[k]-1;
l=z[i-k];
if(i+l<=p) ext[i]=l;
else {
int j=max(0,p-i+1);
while(i+j<la&&j<lb&&a[i+j]==b[j]) j++;
ext[i]=j;
k=i;
}
}
}
应用
1.CF432D Prefixes and Suffixes
若求有多少个 “完美子串”,只要统计多少 \(i+z(i)=n\) 即可。
若求以前缀 \(j\) 的出现次数,只需统计多少 \(i\),满足 \(z(i)\ge j\)。
3.Manacher 算法
引入
算法
Manacher 算法可以计算出每个点的最大回文半径。
若 \(p(i)\) 表示 \(i\) 最大的回文半径。
那么有 \(s[i-p(i)+1]=s[i+p(i)-1]\)。
因此,Manacher 算法只能计算奇数长度的回文串。
于是我们决定在相邻字符中都插入特殊字符 \(\#\)。
这样若回文中心在 \(\#\) 上,代表原回文串长为偶数。
否则,代表原回文串长为奇数。
我们计算的时候,保存两个值。
\(mr=mid+p(mid)-1\) 的最大值。
若我们计算 \(p(i)\),那么根据回文性 \(i\) 与 \(2\cdot mid-i\) 是对称的,
故 \(p(i)=p(2\cdot mid-i)\)。
但是有一个问题,若 \(i+p(i)-1>mr\) 这样是不对的。
因为这样会把超过回文半径的也加进来,不满足回文性。
所以我们一个一个字符去匹配,并更新 \(mr,mid\).
不难发现 \(p(i)-1\) 对应原回文串的长度。
由于 \(mr\) 递增,所以复杂度线性。
可能的实现代码:
code
n=strlen(c+1);
s[++cnt]='~'; s[++cnt]=',';
for(int i=1; i<=n; i++) s[++cnt]=c[i],s[++cnt]=',';
s[++cnt]='!';
for(int i=2; i<=cnt-1; i++) {
if(i<=mr) p[i]=min(p[mid*2-i],mr-i+1);
else p[i]=1;
while(s[i-p[i]]==s[i+p[i]]) p[i]++;
if(i+p[i]>mr) mr=i+p[i]-1,mid=i;
ans=max(ans,p[i]-1);
}
应用
1.P4555 [国家集训队] 最长双回文串
首先求出每个 \(p(i)\)。
再求 \(l(i)\) 表示左边最大的回文串长,\(r(i)\) 为右边最大的回文串长。
答案是 \(\max(l(i)+r(i))\)。
4.AC 自动机
引入
结构
AC 自动机是以字典树为基础的。
类似 KMP,每个节点有一个失配指针(fail)。
\(fail_u\) 的含义是对于字典树上节点 \(u\),其对应的字符串 \(S\),
若 \(fail_u=v\)。指的是对于字典树上节点 \(v\),对应字符串为 \(T\)。
且 \(T\) 是 \(S\) 最长的,存在于字典树中的后缀。
构建
首先我们先对字典树进行 BFS。从根节点开始。
设我们现在求 \(fail_u\)。设 \(u\) 在树上的父亲为 \(fa\),经过 \(c\) 的一条边到 \(u\)。
即 \(ch(fa,c)=u\).
那么 \(fail_u=ch(fail_{fa},c)\)。
若不存在 \(ch(fail_{fa},c)\) 呢,那就是 \(ch(fail_{fail_{fa}},c)\),直到根。
为了简化这样一直跳,我们在计算 \(ch(fa,c)\) 时,可以枚举 \(a\sim z\) 并先把它们求出来。
于是我们的过程变成这样:
设我们现在求 \(fail_u\),有 \(ch(fa,c)=u\).
\(fail_u=ch(fail_{fa},c)\).
那么枚举 \(a\sim z\),
若不存在 \(ch(u,c)\) 的转移,则令 \(ch(u,c)=ch(fail_u,c)\).
fail 树
是指 fail 指针组成的树,根节点指向字典树的根。
每个点到根节点路径上所有点都是它的后缀。
在计算中 fail 树起到很大作用。

运用于匹配问题
假设有文本串 \(T\),有多个模式串 \(S\),问每个 \(S\) 出现次数。
显然把 \(T\) 放到自动机里匹配,每次都以 \(T\) 的字符为转移。
我们匹配到每个节点时,相当于这个节点以及它的所有后缀都出现了一次。
此时,fail 树中,这个节点到根节点的所有节点都出现次数 +1。
注意:如果我们发现匹配到失配时,不需要跳 fail 指针,我们只需继续匹配,
因为我们已经定了 \(ch(u,c)=ch(fail_u,c)\)。
应用
1.CF710F String Set Queries
二进制分组 + AC自动机
动态的 AC 自动机。
把操作顺序二进制分解,就是把总共的操作拆成若干个 \(2^i\) 大小的 AC 自动机。
如 19 被拆成 16,2,1 大小的几个自动机,答案就是它们加起来。
如果再要加入一个操作,变成第 20 个操作,就变成 16,4 个自动机加起来,
那么我们只需要暴力重构即可。
而每个操作只会被重构 \(\log n\) 次。
而我们又发现加,减字符串贡献是可减的,所以我们对于加入和删除分别二进制分组即可。
2.P3311 [SDOI2014] 数数
数位 dp + AC 自动机。
我们在数位 dp 时,只需要加上一个状态 \(p\),表示当前自动机匹配到 \(p\) 位,
再处理出哪些点是不能被转移即可。
3.P2414 [NOI2011] 阿狸的打字机
fail 树的典型应用。
我们想要查询 \(x\) 在 \(y\) 出现多少次。
假设我们把 \(y\) 扔到 AC 自动机上去,相当于构成了一条字典树上的路径。
假设匹配到 \(p\),若此时 \(y\) 出现了,那么 \(y\) 一定为该点后缀,那么从 \(p\) 点向上跳一定跳到 \(y\)。
所以 \(x\) 一定在 \(y\) 的子树内,所以这就变成一个子树的统计问题。
我们把 \(x\) 匹配到的节点 \(+1\),然后查询 \(y\) 子树内有多少个点即可。
然后我们现在只需快速的求出所有 \(x\) 匹配的点,容易发现我们在原字典树上 dfs 即可。
第一次到一个点,就 +1,回溯的时候就 -1。
子树和用树状数组即可。
4.CF1202E You Are Given Some Strings...
巧妙。
考虑分割点 \(k\),指的是以 \(k\) 结尾有一个串 \(i\),而 \(k+1\) 开头有一个串 \(j\)。
统计以 \(k\) 结尾有 \(c1(k)\) 个串,而 \(k+1\) 开头有 \(c2(k+1)\) 串,
那么答案是 \(\sum c1(k)\cdot c2(k+1)\)。
考虑正反建两个 AC 自动机。
5.P5840 [COCI2015]Divljak
转化成 fail 树就变成子树数颜色问题。
这里介绍一种树论的方法。
例如插入字符串 \(P\) 匹配到 \(p_1,p_2...p_n\) 这些点,每个点 +1.
先按照 fail 树上 dfn 排序,然后相邻的点 Lca -1.
这样子树若有这个颜色,查询一定贡献为 1.