前言
开这个坑的目的是巩固一下字符串的基础内容,毕竟自己对这一块的接触还不是很多。
其实字符串算法最大的特点就是 最大化利用已经求出的信息,几乎所有算法都是基于这句话的。
一些定义:
- \(\operatorname{lcp}\left(i,j\right)\) 为以 \(i\) 开头的后缀和以 \(j\) 开头的后缀的最长公共前缀。
- \(\sigma\) 为字符集。
- \(s_{i,j}\) 为 \(s\) 下标在 \([i,j]\) 之间的字串。
- \(|S|\) 为字符串 \(S\) 的长度。
- \(B_{\max}(S_{i,j})\) 为字符串 \(S_{i,j}\) 的最长 border。
Hash
概述
Hash 就是把一个字符串映射成一个数字的过程,常见的构造函数类似于:
\(Base\) 可以取一个小质数,\(p\) 可以用一个大质数。如果觉得不保险还可以用双哈希,即选取两个 \(Base\) 和 \(p\)。
例题 1:「CTSC2014」企鹅QQ
题意:
给定 \(n\) 个长度为 \(L\) 的字符串,问有多少对字符串只有一位不同。
数据范围:\(1\le n\le 30000\),\(1\le L\le 200\)。
对每个前缀和后缀分别哈希,枚举哪一位不同然后统计。
最小表示法
概述
题意:给定一个字符串 \(S\),求一个 \(i\in[1,|S|]\),使得 \(S_{i,|S|}+S_{1,i-1}\) 的字典序最小。
维护两个指针 \(i\),\(j\),表示当前比较的两个循环表示的起点。
暴力求出 \(k=\operatorname{lcp}\left(i,j\right)\),然后比较 \(S_{i+k}\) 和 \(S_{j+k}\),不妨假设 \(S_{i+k}<S_{j+k}\),那么肯定 \([j,j+k]\) 范围内的所有下标都不可能成为最小循环表示的起始位置(因为以 \(j+x\) 起始的循环表示字典序一定大于以 \(i+x\) 起始的循环表示)。令 \(j\leftarrow j+k+1\),继续暴力做这个过程。
注意以上下标都是在 \(\bmod\ n\) 意义下的。
每个指针只会扫一遍字符串,所以时间复杂度为 \(\mathcal{O}(|S|)\)。
代码
int i = 1, j = 0, k = 0;
while (i < n && j < n && k < n)
{
if (a[(i + k) % n] == a[(j + k) % n]) ++k;
else
{
if (a[(i + k) % n] > a[(j + k) % n]) i += k + 1;
else j += k + 1;
if (i == j) ++i;
k = 0;
}
}
// min(i, j) 即为最小循环表示的起始位置
Manacher
概述
Manacher 算法可以求出以每个下标为中心的最长的 长度为奇数 的回文子串。
为了求出长度为偶数的回文子串,我们可以在原串相邻两个字符中间插入一个不属于 \(\sigma\) 的字符。
记 \(p_i\) 为以 \(i\) 为中心的最长回文半径,当前最大的 \(i+p_i-1\)(即已经被某个点为中心的回文串所覆盖到的最右端点) 为 \(mr\),这个最大的 \(i\) 为 \(mid\)。
考虑如何利用上述信息求得一个新的 \(p_i\)。若 \(i>r\),那么暴力扩展求;否则有 \(i\le r\),根据回文的对称性,可以得出 \(p_i=\min\left(mr-i+1,p_{2\times mid-i}\right)\)。
为什么这样是对的?考虑在 \([mid-p_{mid}+1,mid+p_{mid}-1]\) 的范围内,\(2\times mid-i\) 和 \(i\) 对称,所以以 \(2\times mid-i\) 为中心的回文串同时也是以 \(i\) 为中心的回文串。而这个性质只能在当前范围内满足,所以还要和 \(mr-i+1\) 取 \(\min\)。
代码
scanf("%s", s + 1);
t[k = 1] = '$', t[++k] = '#';
n = strlen(s + 1);
rep(i, 1, n) t[++k] = s[i], t[++k] = '#';
t[++k] = '#', t[++k] = '@';
int mid = 0, mr = 0;
rep(i, 1, k)
{
if (i <= mr) p[i] = min(mr - i + 1, p[mid * 2 - i]);
else p[i] = 0;
while (t[i + p[i]] == t[i - p[i]]) ++p[i];
if (i + p[i] - 1 > mr) mr = i + p[i] - 1, mid = i;
}
printf("%d\n", *max_element(p + 1, p + 1 + k) - 1);
例题 2:「THUPC2018」绿绿和串串
长度为 \(n\) 的串肯定满足条件。
对于每一个 \(i\),如果存在以它为中心的回文串包括 \(n\) 这个位置,或者 存在以它为中心且以 \(1\) 开头的回文串且 \(2i-1\) 符合题目条件,那么 \(i\) 就是一个合法答案。
Z 算法 / exKMP
概述
Z-algorithm 可以求出所有的 \(z_i=\operatorname{lcp}\left(1,i\right)\)。
类似 Manacher,每次维护最靠右的 \(r=l+z_l-1\) 和这个最大的 \(l\),可以发现 \(z_i\ge\min(r-i+1,z_{i-l+1})\)。然后和 Manacher 一样暴力扩展并更新 \(l,r\) 即可。
利用 Z-algorithm 解决字符串匹配问题:将两个字符串拼接在一起,中间用一个不属于 \(\sigma\) 的字符隔开。
代码
string s, t;
int z[M];
inline void getz(string s)
{
int l = 0, r = 0, n = s.size();
rep(i, 2, n - 1)
{
if (i > r) z[i] = 0;
else z[i] = min(r - i + 1, z[i - l + 1]);
while (s[i + z[i]] == s[1 + z[i]]) ++z[i];
if (i + z[i] - 1 > r) r = i + z[i] - 1, l = i;
}
}
int main()
{
cin >> s >> t;
int n = s.size(), m = t.size();
t = " " + t + "*" + s;
getz(t);
LL ans = 0;
z[1] = m;
rep(i, 1, m) ans ^= (1ll * i * (min(m - i + 1, z[i]) + 1));
printf("%lld\n", ans);
ans = 0;
rep(i, m + 2, m + n + 1) ans ^= (1ll * (i - m - 1) * (z[i] + 1));
printf("%lld\n", ans);
return !!0;
}
例题 3:「NOIP2020」字符串匹配
题意:
\(T\) 组数据,每次给定一个字符串 \(S\),求 \(S=(AB)^iC\) 的方案数,其中 \(F(A) \le F(C)\),\(F(S)\) 表示字符串 \(S\) 中出现奇数次的字符的数量。
两种方案不同当且仅当拆分出的 \(A\)、\(B\)、\(C\) 中有至少一个字符串不同。
数据范围:\(1\le T\le5\),\(1\le |S|\le 2^{20}\)。
首先可以求出每个后缀 / 前缀出现奇数次的字符数量,枚举 \(AB\) 和 \(AB\) 的出现次数,用哈希暴力判断是否合法,这样就可以知道 \(F(C)\) 了,然后用一个树状数组统计合法的 \(A\) 的个数,时间复杂度 \(\mathcal{O}(T|S|\log|S|\log26)\),可以得到 \(84\) 分。
考虑优化这个过程。先求出 \(S\) 的 \(z\) 函数,枚举 \(AB\) 的长度 \(i\),那么可以扩展的次数就是 \(\lfloor\frac{z_{i+1}}{i}\rfloor+1\)。
如何处理 \(F(A)\le F(C)\) 的限制?我们把扩展的次数按奇偶性讨论。
- 若 \(AB\) 出现了奇数次,即 \(k\) 次,那么我们只看第 \(1\) 个 \(AB\),则 \([i+1,ki]\) 中的字符出现次数的奇偶性并没有被改变(因为 \(k-1\) 为偶数),所以 \(F(C)=F(S_{i+1,|S|})\),可以直接算出。
- 若 \(AB\) 出现了 偶数次,那么 \(F(C)=F(S)\),因为这个前缀出现的字符都出现了偶数次,不会改变奇偶性。
对于以上两种情况分别用树状数组统计合法的 \(A\) 之后加起来即可。
代码:https://paste.ubuntu.com/p/2WSwnkhbVm/。
例题 4:「CF526D」Om Nom and Necklace
同样枚举 \(AB\) 的长度 \(i-1\),那么 \(AB\) 要出现奇数次的充要条件就是 \(z_{i}\ge k(i-1)\),此时能贡献到的区间就是 \([k(i-1),\min(ki,z_i+i-1)]\),差分后前缀和即可。
例题 5:「CF432D」Prefixes and Suffixes
求出 \(S\) 的 \(z\) 函数。枚举 \(S\) 的一个长度不超过 \(|S|\) 的后缀 \([i,|S|]\),如果 \(z_i=|S|-i+1\),说明这个后缀是一个 border。
怎么统计一个 border 的出现次数?可以发现对于每个位置 \(i\),以它开头的字符串对长度为 \([1,z_i]\) 的前缀有一次贡献,差分后做一遍后缀和即可。
代码:https://paste.ubuntu.com/p/xzCPwbhBTF/。
KMP
概述
KMP 一般用来解决字符串匹配问题。
KMP 的核心在于一个 \(nxt\) 数组,\(nxt_i\) 存储的是 \([1,i]\) 的最长 border(即前缀和后缀相等)。
维护两个指针 \(i,j\),假设我们已经知道 \(s_{i-j+1,i}\) 和 \(t_{1,j}\) 匹配,那么肯定就有 \(s_{i-j+nxt_j+1,i}\) 和 \(t_{1,nxt_j}\) 也能匹配。因为 \(s_{i-j+1,i-j+nxt_j}=s_{i-nxt_j+1,i}=t_{1,nxt_j}=t_{j-nxt_j+1,j}\),所以当前串 \(s\) 的前 \(nxt_j\) 个字符和串 \(t\) 的后 \(nxt_j\) 个字符相等,当 \(s_{i+1}\) 和 \(t_{j+1}\) 失配的时候就可以直接 \(j\leftarrow nxt_j\)。
如何求得 \(nxt_i\)?这个可以通过 \(t\) 串自己和自己匹配求得。具体来说,假设我们已经知道了 \(nxt_{1\dots i-1}\),想要求 \(nxt_i\),那么 \(t_{1,i}\) 的最长 border 肯定是由 \(t_{1,i-1}\) 的一个 border(不一定是最长)在后面加上 \(t_i\) 得到。那么我们从 \(j=nxt_{i-1}\) 开始匹配,如果失配就 \(j\leftarrow nxt_j\),直到 \(t_{j+1}=t_i\)。如果 \(j=0\) 就需要判断一下 \(t_1\) 是否等于 \(t_i\)。
注意:如果题目中对于 \(nxt\) 的定义给出了若干限制,那么我们必须要先不考虑限制求出 \(nxt\),然后再去处理限制,否则可能会导致求出的 \(nxt\) 错误。
代码
const int N = 1000003, M = N << 1;
int n, m;
char s[N], t[N];
int nxt[N];
int main()
{
scanf("%s%s", s + 1, t + 1);
n = strlen(s + 1), m = strlen(t + 1);
int p = 0;
rep(i, 2, m)
{
while (p && t[p + 1] != t[i]) p = nxt[p];
if (t[p + 1] == t[i]) ++p;
nxt[i] = p;
}
p = 0;
rep(i, 1, n)
{
while (p && t[p + 1] != s[i]) p = nxt[p];
if (t[p + 1] == s[i]) ++p;
if (p == m) printf("%d\n", i - m + 1), p = nxt[p];
}
rep(i, 1, m) printf("%d ", nxt[i]);
return !!0;
}
例题 6:「NOI2014」动物园
先求出 \(nxt_i\),顺便计算出 \(cnt_i\) 表示 \(i\) 要跳多少次 \(nxt\) 才能变成 \(0\),即 \(i\) 的 border 数量。
在求 \(num_i\) 的时候,先把指针 \(p\) 跳到 \(\le\frac{i}{2}\) 的最大位置上,然后就有 \(num_i=cnt_p+[p>0]\),\([p>0]\) 的意思是 \([1,p]\) 这个 border 没有在 \(cnt_p\) 中统计到。
代码:https://paste.ubuntu.com/p/xw83TsXhbH/。
例题 7:「POI2006」PAL-Palindromes
由于给出的串都是回文串,所以有结论:若 \(a+b\) 是一个回文串,当且仅当它们的最短回文整周期串相同。
充分性显然。必要性考虑反证法,具体留给读者作为练习。
有了结论之后就可以先用 KMP 求出 \(nxt\) 数组,若 \(n-nxt_n|nxt_n\) 说明 \(nxt_n\) 就是这个字符串的最短回文整周期串长度,否则就是它本身。开个哈希表把这些串的最短回文整周期串存下来,直接计算贡献即可。
代码:https://paste.ubuntu.com/p/d2GZS9GHMw/。
例题 8:「POI2012」前后缀 Prefixuffix
如果两个串满足「循环等价」,那么肯定它们分别形如 \(AB\) 和 \(BA\)。不妨假设前缀形如 \(AB\),后缀形如 \(BA\)。
所有满足条件的 \(A\) 其实就是这个串的 border,暴力跳 next 即可求出。
问题变成了怎么求一个子串 \(S_{i,n-i+1}\) 的最长 border。
这个可以考虑增量法构造。假设我们已经求出了 \(S_{i+1,n-i}\) 的最长 border,那么肯定有 \(B_{\max}(S_{i,n-i+1})\le B_{\max}(S_{i+1,n-i})+2\),这是因为 \(S_{i+1,n-i}\) 的 border 可以通过 \(S_{i,n-i+1}\) 的 border 去掉头和尾的字符得到。那么从最中间的字符开始暴力往两边扩展即可。
例题 9:「POI2005」SZA-Template
思路巧妙.jpg
设 \(dp_i\) 表示要覆盖 \([1,i]\) 所需要的印章长度的最小值。
考虑 \(dp_i\) 实际上只有两种取值:\(i\) 和 \(dp_{nxt_i}\),因为不可能没有覆盖 \(nxt_i\) 就覆盖了 \(i\),不然最后会没办法填到 \(i\)。
问题变成在什么时候 \(dp_i\) 能取到 \(dp_{nxt_i}\)。其实很简单,考虑印印章的过程,在印 \(i\) 的时候肯定起点在 \([i-nxt_i+1,i]\) 中,所以只有在满足 \(\exist i-nxt_i\le j\le i-1,dp_j=dp_{nxt_i}\) 的时候 \(dp_i=dp_{nxt_i}\)。
代码就很好写了:https://pastebin.ubuntu.com/p/zzMsTwB6Y3/。
KMP 自动机
概述
KMP 自动机是一种 确定有限状态自动机。
实质就是在 KMP 求出的 \(nxt\) 数组的基础上,额外求出 \(trans_{i,j}\) 表示在 \(i\) 之后接上字符 \(j\) 会转移到什么状态,即:
和 KMP 的转移类似,应该很好理解。
代码
rep(i, 0, n - 1) rep(j, 0, 25)
{
if (j + 'a' == s[i + 1]) trans[i][j] = i + 1;
else if (i) trans[i][j] = trans[nxt[i]][j];
else trans[i][j] = 0;
}
例题 10:「HNOI2008」GT考试
对输入的字符串建出 KMP 自动机。
设 \(g_{i,j}\) 表示现在匹配的长度为 \(i\),加入一个字符后匹配长度变成 \(j\) 的方案数,\(f_{i,j}\) 表示已经填了 \(i\) 个字符,匹配长度为 \(j\) 的方案数。转移可以用矩阵乘法加速。
代码:https://paste.ubuntu.com/p/Q4ZfQ4bqQT/。
border 与周期理论
定理
\(r\) 是 \(S\) 的周期当且仅当 \(S\) 有长度为 \(|S|−r\) 的 border。
Weak Periodicity Lemma
内容
若 \(p\),\(q\) 是字符串 \(S\) 的周期,且 \(p+q\le |S|\),则 \(\gcd(p,q)\) 是 \(S\) 的周期。
证明
不妨设 \(p<q\),\(d=q-p\)。
- 若 \(i>p\),则有 \(s_i=s_{i-p}=s_{i-p+q}=s_{i+d}\);
- 否则 \(i\le p\),有 \(s_i=s_{i+q}=s_{i+q-p}=s_{i+d}\)。
这样我们就证明了 \(d\) 是字符串 \(S\) 的一个周期。
根据更相减损术,最终能得到 \(\gcd(p,q)\) 是 \(S\) 的一个周期。
引理
\(S\) 所有不超过 \(\frac{|S|}{2}\) 的周期都是其最短周期的倍数。
或者等价的,\(S\) 所有长度不小于 \(\frac{|S|}{2}\) 的 border 长度构成等差数列。
不难通过 Weak Periodicity Lemma 得出。
定理
\(S\) 的所有 border 长度(周期)构成 \(\mathcal{O}(\log n)\) 个值域不交的等差数列。
Periodicity Lemma
内容
若 \(p\),\(q\) 是 \(S\) 的周期,且 \(p+q−\gcd(p,q)\le|S|\),则 \(\gcd(p,q)\) 也是 \(S\) 的周期。
失配树
概述
在 KMP 算法中,观察到 \(nxt_i<i\),那么如果我们连一条边 \((nxt_i,i)\),就可以得到一棵树,我们称之为 失配树。
性质:失配树上每个节点的祖先都是它的一个 border。
因此,如果我们要求两个前缀 \(S_{1,p}\) 和 \(S_{1,q}\) 的最长公共 border 的长度,可以直接在失配树上求出 \(p,q\) 两点的 LCA。注意如果 \(p\) 和 \(q\) 具有 祖先-后代 关系,那么应该输出深度较低的那个点的父亲,因为一个串的 border 不能是它自己。
代码
const int N = 1000003, M = N << 1;
char s[N];
int n, m, nxt[N], fa[20][N];
int dep[N];
int main()
{
scanf("%s", s + 1);
n = strlen(s + 1);
int p = 0;
fa[0][1] = 0;
dep[0] = 1;
dep[1] = 2;
rep(i, 2, n)
{
while (p && s[p + 1] != s[i]) p = nxt[p];
if (s[p + 1] == s[i]) ++p;
nxt[i] = p, fa[0][i] = p;
dep[i] = dep[p] + 1;
}
rep(j, 1, 19) rep(i, 1, n) fa[j][i] = fa[j - 1][fa[j - 1][i]];
m = gi <int> ();
while (m--)
{
int p = gi <int> (), q = gi <int> ();
if (dep[p] < dep[q]) swap(p, q);
per(j, 19, 0) if (dep[fa[j][p]] >= dep[q]) p = fa[j][p];
per(j, 19, 0) if (fa[j][p] != fa[j][q]) p = fa[j][p], q = fa[j][q];
printf("%d\n", fa[0][p]);
}
return !!0;
}
例题 11:「BOI2009」Radio Transmission 无线传输
根据周期理论那一套,一个字符串的最短周期就是 \(n-nxt_n\)。
输出 \(n-nxt_n\) 即可。
例题 12:「POI2006」OKR-Periods of Words
根据失配树的定义,一个字符串的最长周期就是它在根节点之下深度最小的点,在失配树上倍增跳即可。