从零开始学串串

故事的开始是:初识串串,质疑串串,厌恶串串。

我能否在严重失忆的情况下理解曾经最讨厌的知识点呢?

字符串哈希

hashing 通过对每个字符串构造一个哈希函数 \(h(s)\) 得到快速判断两个字符串是否相等的必要条件。

hashing 需要满足下面两个条件:

  1. 串串一样时 hash 值必须一样。
  2. 串串不一样时 hash 值最好不一样。

“不一样的串串映射到相同的位置”这个被称为哈希冲突的现象,很有可能会导致你的程序复杂度退化或直接倒闭。

常用的字符串哈希方法是将字符串当作一个很长很长的大进制数并取摸处理。具体地,\(f(s)=\sum s_i\times b^i \pmod{m}\)

其中 \(b\) 是一个 大于字符集大小 的常数,\(m\) 是一个比较大的数。

了解上述概念后可以尝试 P8023 [ONTAK2015] Tasowanie, 对上面这些概念的理解有很大的帮助。

哈希冲突

我们已经提到了,哈希给出的是一个必要且很可能充分的条件,所以你一定要关心哈希冲突的概率,这是基与哈希做法的正确性基石。

假设我们的哈希值共有 \(m\) 种可能,我们从哈希值中随机抽取一个作为第 \(i\) 种字符串的哈希值,直到为所有 \(n\) 个串分配完对应的哈希值。下面计算这 \(n\) 个哈希值有冲突的概率。

不冲突的概率很好算,就是从 \(m\) 个数里挑 \(n\) 个不同数的概率,有:

\(1-p=\dfrac{m}{m}\times\dfrac{m-1}{m}\times\dots\times\dfrac{m-n+1}{m}\)

\(1-p=\prod_{i=0}^{n-1}(1-\dfrac{i}{m})\)

两边取对数,得到:

\(\ln(1-p)=\sum\ln(1-\dfrac{i}{m})\).

根据不等式 \(x\ge\ln(x+1)\),有:

\(\ln(1-p)\approx\sum-\dfrac{i}{m}\).

两边再取指数,得到 (\(\exp(x)=e^x\)):

\(p\approx 1-\exp(-\dfrac{n(n-1)}{2m})\).

这个概率并不是很小,事实上取出 \(\sqrt m\) 个串就期望出现一次冲突。这个结论又被称作生日悖论。

所以根据上述推论,单模数哈希的正确性堪忧。自然溢出哈希也很好卡,详见 oi-wiki.

如果条件允许,请使用双模哈希。事实上,在知道了你的 \(base\) 和模数后双模哈希也好卡,但这么闲的出题人并不多,所以不必过于担心。
:::info[闲话]
这启发我们哈希模数不要经常用 \(998244353,10^9+7,10^9+9\), base 也不要用 \(127,131\),你应该找到自己的幸运数字。
:::

卡哈希的一些题目:

  1. https://www.luogu.com.cn/problem/P12197
  2. https://www.luogu.com.cn/problem/P12198
  3. 是一个给定 \(base\)\(mod\) 卡双哈希的题目。做法是先在第一种取模方式下构造长度比较小的小串,然后将这个小串作为单位字符卡掉第二种取模方式,用 map 维护哈希值直到找到冲突即可,每一步都期望做 \(\sqrt{mod}\) 次。但是我找不到这个题了 qwq

upd: https://www.luogu.com.cn/problem/P12201, 上面那个卡双哈希的题。

前缀函数与 KMP

做字符串匹配的暴力做法是枚举匹配点,然后滚一遍模式串看这个匹配点是否正确,并累加答案。

考虑滚的这个过程,当我们发现某一位 失配 时,就停止整个匹配过程,换下一个匹配点。

这其实是一个很浪费时间的过程,已经匹配的部分还有利用空间。

具体地,定义前缀函数 \(\pi(i)\) 为字符串 \(s\) 的前 \(i\) 位的这个前缀的最长相等的真前缀和真后缀长度。如果把这东西算出来了,匹配上模式串的前 \(i\) 位就意味着匹配上模式串的前 \(\pi(i)\) 位,前 \(\pi(\pi(i))\) 位……

这样,已经匹配上的元素就可以得到充分利用了。而求前缀函数的过程其实就是一个自匹配,我们可以充分利用之前的前缀函数。

能力有限,只能讲到这里了,配合代码消化:

#include<bits/stdc++.h>
using namespace std;
const int N=1e6+5;
char s1[N],s2[N];
int pr[N];
int main(){
	scanf("%s%s",s1+1,s2+1);
	int n=strlen(s1+1),m=strlen(s2+1);
	for(int i=2,j=0;i<=m;i++){//自匹配,求前缀函数
		while(j&&s2[i]!=s2[j+1]) j=pr[j];
		if(s2[i]==s2[j+1]) j++;
		pr[i]=j;
	}
	for(int i=1,j=0;i<=n;i++){//主串匹配
		while(j&&s1[i]!=s2[j+1]) j=pr[j];
		if(s1[i]==s2[j+1]&&++j==m){
			printf("%d\n",i-m+1);
			j=pr[j];
		}
	}
	for(int i=1;i<=m;i++) printf("%d ",pr[i]);
	return 0;
}

看似每次退回的复杂度也不低,但因为每一次 \(j\) 的增长都至少要 \(i\) 的一次增长换来,所以总复杂度是 \(O(n+m)\) 的。

前缀函数还有比字符串匹配更广的应用,如求字符串的周期。

\(T\) 被称为 \(s\) 的一个周期当且仅当 \(\forall i+T\le n\)\(s_i=s_{i+T}\).

\(s\) 的长度为 \(p\) 的前缀与相同长度的后缀相同,则 \(n-p\) 就是 \(s\) 的一个周期。因为 \(i\) 在前缀平移过去就是 \(n-p+i\),这天然满足了 \(s_i=s_{i+n-p}\).

而这个相等的前后缀就是前缀函数的定义,你可以通过不断取前缀函数得到 \(s\) 的所有周期。

这里有一道相关的水紫,但是我找不到了 qwq。

oi-wiki 中还给出了“统计每个前缀的出现次数”这一应用,个人认为这与 AC 自动机的应用高度重合。具体地,这一问题事实上就是 trie 退化为链的特殊情况。故详见 AC 自动机相关部分。

CF808G 是一道很好的例题。

第一种做法是记 \(f_{i,j}\) 为考虑大串的前 \(i\) 为,匹配上 \(j\) 位的数。遇到问号就枚举这个问号是什么即可,复杂度 \(O(nm|\Sigma|)\)。注意需要建出 trie 图,详见 AC 自动机部分。

第二种做法则是考虑每一个匹配的时刻。记 \(g_i\) 为考虑前 \(i\) 个元素且 \([i-m+1,i]\) 匹配的最大答案,对于模式串的每一种前缀函数值 \(x\)\(i-(m-x)\) 转移而来。或从 \(j\le i-m\) 直接转移而来。后半部分显然可以前缀 \(\max\) 优化,复杂度 \(O(nm)\).

第二种方法的实质就是当你完整地匹配一次模式串后,后续直接给你减 border,类似超市的满减优惠,买了一个冰箱后今天买的洗衣机打八折这样子。

Manacher

通过一些处理 \(O(n)\) 求出以每个字符为回文中心的“半回文串”长度。

先要让所有回文串都有中心,这里是每两个字符中间插入一个字符 '#' 实现的。

然后 manacher 通过维护右端点最靠右的回文串实现了下列事情:

  1. \(i\) 与最右回文串无交时,暴力扩展。
  2. \(i\) 与最右回文串有交时,\(i\) 的回文情况很可能跟 \(i\) 关于这个最右回文串中心的对称点 \(j\) 的回文情况相同。如果 \(j\) 被最右回文串完全包含且左端点不与大串重合,则 \(i\) 也应该被完全包含且没有扩展空间;否则,\(i\) 可以至少获得 \(j\) 串与大串的重合回文部分的贡献,然后在此基础上暴力扩展。

因为我们维护的“最右回文串右端点”单调不减,所以总复杂度为 \(O(n)\).

代码非常简单。

#include<bits/stdc++.h>
using namespace std;
const int N=2.5e7+5;
char ss[N],s[N];
int p[N];//manacher: O(n) 求出以 i 为中心的回文串的"半长度-1".
int main(){
	scanf("%s",ss+1);
	int n=strlen(ss+1);
	s[1]='#';
	for(int i=1;i<=n;i++){
		s[i*2]=ss[i],s[i*2+1]='#';
	}
	int m=2*n+1;
	int l=0,r=0;//右端点最大的回文串串是 [l,r].
	int res=0;
	for(int i=1;i<=m;i++){
		if(i>r){
			while(1<=i-p[i]-1&&i+p[i]+1<=m&&s[i+p[i]+1]==s[i-p[i]-1]) p[i]++;
			l=i-p[i],r=i+p[i];
		} else {
			int j=l+r-i;
//			找到关于大串串中心对称的串, 当前串的状态很可能跟那个串相同.
//			如果那个串被大串包含了, 那肯定一样.
//			如果没有, 则可能没那个串那么长, 需要裁减然后暴力拓展.
			if(j-p[j]>l) p[i]=p[j];//注意这里不能取等号,因为相等时也是可以拓展哒
			else {
				p[i]=j-l;
				while(1<=i-p[i]-1&&i+p[i]+1<=m&&s[i+p[i]+1]==s[i-p[i]-1]) p[i]++;
				l=i-p[i],r=i+p[i];
			}
		}
		res=max(res,p[i]);//len = 2 * p[i] + 1, res = (len-1)/2 = p[i].
	}
	printf("%d",res);
	return 0;
}

拓:

反回文,例题:P3501 [POI 2010] ANT-Antisymmetry.

这次要求关于中心对称的点值必须相反。这几乎可以无损拓展,因为反回文反对称后仍然是反回文,所以只需要把暴力扩展时判相等改成判不等就行了。

但有个小小的关键点要注意,正常的单个字符一定是回文,但正常的单个字符一定不是反回文。所以 \(p_i\) 的最小值不是 \(0\) 而应该是 \(-1\).

扩展 KMP (Z 函数)

exkmp 和 manacher 一样是对朴素算法的大优化。此处约定字符串从 \(0\) 开始,\(Z_0=0\)(与 oi-wiki 保持一致)。

\(Z_i\) 为从 \(i\) 开始的后缀与整个大串串的 LCP, 即 \(Z_i\) 是满足 \(s_{i+j}=s_j\)\(j\) 的最大值 \(+1\)。且我们称 \([i,i+Z_i-1]\)\(i\) 与大串的匹配段, 简称匹配段(Z-box)。

和 manacher 一样, 我们维护右端点最靠右的匹配段。

关键性质:由定义, \(s[l,\dots,r] = s[0,\dots,r-l]\), 从而 \(s[i,\dots,r] = s[i-l,r-l]\),这启发我们将 Ta 和 \(Z_{i-l}\) 联系起来,有 \(Z_i\geq\min(Z_{i-l},r-i+1)\).

注:\(r-i+1=r-l-(i-l)+1\).

也就是说我们利用一个大匹配段, 将 \(i\) 开始的后缀 平移 到先前已经计算的位置减小复杂度,这与 manacher 对称 算回文子串有异曲同工之妙。

核心部分代码简直简单得不能再简单了:

int l=0,r=0;
z[0]=0;
for(int i=1;i<=m;i++){
	if(i<=r){
		z[i]=min(z[i-l],r-i+1);
	}
	while(i+z[i]<=m&&s[z[i]]==s[i+z[i]]) z[i]++;
	if(i+z[i]-1>r) l=i,r=i+z[i]-1;
}

以备不时之需,下面是下标从 \(1\) 开始的 Z 函数模板代码:

int l=0,r=0;
z[1]=0;
for(int i=2;i<=n;i++){
	if(i<=r) z[i]=min(z[i-l+1],r-i+1);
	while(i+z[i]<=n&&s[z[i]+1]==s[i+z[i]+1-1]) z[i]++;
	if(i+z[i]-1>r) l=i,r=i+z[i]-1;
}

与 KMP 的关系 (?)

我求出了 \(b\) 的每一个后缀与 \(a\) 的 lcp,相当于我把 \(b\) 的每一个后缀都和 \(a\) 作了一次匹配。如果我想知道 \(a\)\(b\) 中的每一次出现,只需找到所有 lcp 等于 \(|a|\) 的位置就可以了,因此 Ta 完成了 KMP 能做的基本事情。但是,求出来的这个东西有很多和前缀函数应用不同的其他应用,所以跟 KMP 也没啥太大关系啦?

应用

首先因为 Z 函数是 扩展 KMP,所以你可以通过遍历 \(z\) 数组得到前缀函数数组(少记一个板子,好耶),具体地,若 \(z_i>0\),则 \(\pi(i+z_i-1)=z_i\),根据两者定义容易证明。所以理论上来说你可以使用 Z 函数做掉所有前缀函数应用题(迷惑行为)。

不过我在 嘎嘎猫 的博客上学习的时候确实发现了一道正周期的题 (/bx),虽然不是上面的水紫,是 CF526D。就是问你每个前缀是否有恰完整出现 \(k\) 次或 \(k+1\) 次的正周期,即 \(\le\lfloor\dfrac{i}{k}\rfloor\)\(\ge\lceil\dfrac{i}{k+1}\rceil\) 的正周期。求出最小正周期乘一下就行了。

核心代码(\(f\) 是前缀函数数组):

for(int i=1;i<=n;i++){
    int d=i-f[i];
    putchar((((i+k)/(k+1)+d-1)/d*d<=i/k)+'0');
}

字典树(trie)

暂时没学明白 awa

AC 自动机

见我的 另一篇笔记

后缀数组

后缀数组就是把 \(s\) 的所有后缀按字典序排好。

一种很简单的倍增求后缀数组的方法:

  1. 定义当前块长为 \(2^w\), 每个结点有一个二元权值 \((\operatorname{rk}_{w-1}(i),\operatorname{rk}_{w-1}(i+2^{w-1}))\)。超出范围的记为 \(-\inf\).
  2. 按照该权值排序,得到 \(\operatorname{rk}_w\) 数组。
  3. \(w\times 2\to w\),直到 rk 各不相同。

\(2^w\) 大于 \(n\) 时,求出的 rk 数组就与后缀数组等价。

单次排序 \(O(n\log n)\),共需要进行 \(O(\log n)\) 次,总复杂度是两只 \(\log\).

对于主串在一开始给出,模式串在询问时给出时的字符串匹配问题,则可以借助后缀数组完成。每一次匹配都意味着模式串是匹配点开始的后缀的一个前缀。从而所有的匹配位置在排序完毕的后缀数组中形成一个区间。我们二分区间的左右端点,可以轻松地得到匹配数。

posted @ 2025-11-26 19:45  xwxabc  阅读(3)  评论(0)    收藏  举报