字符串基础学习笔记

前言

开这个坑的目的是巩固一下字符串的基础内容,毕竟自己对这一块的接触还不是很多。

其实字符串算法最大的特点就是 最大化利用已经求出的信息,几乎所有算法都是基于这句话的。

一些定义:

  • \(\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 就是把一个字符串映射成一个数字的过程,常见的构造函数类似于:

\[hash(S)=\sum\limits_{i=1}^{|S|}S_i\times Base^{|S|-i}\bmod p \]

\(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 去掉头和尾的字符得到。那么从最中间的字符开始暴力往两边扩展即可。

代码:https://loj.ac/s/1412375

例题 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\) 会转移到什么状态,即:

\[trans_{i,j}= \begin{cases} i+1 &s_{i+1}=j\\ 0 &s_{i+1}\not =j\land i=0\\ trans_{nxt_i,j} &s_{i+1}\not=j\land i>0 \end{cases} \]

和 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

题面

根据失配树的定义,一个字符串的最长周期就是它在根节点之下深度最小的点,在失配树上倍增跳即可。

代码:https://loj.ac/s/1415484

posted @ 2022-03-20 11:27  csxsi  阅读(94)  评论(4编辑  收藏  举报