字符串

0. 阅读前须知

作者这个菜鸡是向 oi.wiki 学的,很容易出现相同的东西。但写的东西,都是我自己理解的。

1. 字符与字符串的存储

什么是字符串呢?就是一堆字符的东西。很容易想到用 char s[MAXN] 定义。

这里介绍一种新的数据结构:string

如果要定义一堆字符串呢?用 string s[MAXN] 就可以了。

不过给定另一种定义方式——字典树(Trie)

先来看看字典树是怎么定义的。例如这几个字符串:iakioiiakioiioiakme

字典树上用边存贮每个字符,用每个简单路径存储每个字符串。如上图。

例如:魔族密码

字典树的加入:

void insert(string s)
{
	int u=1;//根 
	int len=s.size();
	int res=0;
	for(int i=0;i<len;i++)
	{
		int z=s[i]-'a'+1;
		if(trie[u][z]==0)
			tot++,trie[u][z]=tot;
		u=trie[u][z];
		res+=word[u];
	}
	word[u]++,res++;
	ans=max(res,ans);
}

使用 insert(s) 插入字符串 sss

字典树就是这样定义的。

2. KMP

2.1 border

如果一个字符串的前缀等于他的后缀,我们称这个字符串是原字符串的 border。
例如,abcakmeabc 中,abc 就是一个border。

2.2 KMP

定义一个字符串的 nxtnxtnxt 数组表示:前 iii 个字符的最长 border 长度。(不为自己)

nxtnxtnxt 是干什么的呢?这里直接给出结论:

nxtnxtnxt 是用来回溯的,当模式串和文本串不匹配时,应该从哪来重新开始匹配。

KMP经常用来求解模式串匹配的问题。即询问 sssttt 中出现的次数。一般来说,代码如下:

j=0;
for(int i=1;i<=m;i++)
{
		while(j>0&&b[j+1]!=a[i])
			j=nxt[j];
		if(b[j+1]==a[i])
			j++;
		if(j==n)
			j=nxt[j];
}

其中,m=∣t∣,n=∣s∣m=|t|,n=|s|m=t,n=s

问题是 nxtnxtnxt 怎么获得呢?用自己匹配自己即可。代码如下:

void kmp(int n,string b)
{
      int j=1;
      for(int i=2;i<=n;i++)
      {
          while(j&&b[i]!=b[j]) j=nxt[j];
          if(b[j]==b[i]) j++;//前后缀相同,j往后移动
          nxt[i]=j;
      }
}

时间复杂度 O(n)O(n)O(n)

3. exKMP/扩展KMP/Z函数

3.1 什么是 Z 函数

注:字符串从 000 开始编号。

什么是 Z\text ZZ 函数呢?我们定义 ziz_izi 表示 ssssss 的每一个后缀 i∼n−1i\sim n-1in1 的最长公共前缀(LCP)的长度,此时我们称 zzzsssZ\text ZZ 函数。特殊的,z[0]=0\mathbf{z[0]=0}z[0]=0

举几个栗子:

z(z(z(aaaaa)=[0,4,3,2,1])=[0,4,3,2,1])=[0,4,3,2,1]z(z(z(aaabaab)=[0,2,1,0,2,1,0])=[0,2,1,0,2,1,0])=[0,2,1,0,2,1,0]

3.2 Z 函数线性求法

就像 nxtnxtnxt 一样,Z 函数也可以在 O(n)O(n)O(n) 复杂度中求。

首先假设已知 z0,z1,⋯ ,zi−1z_0,z_1,\cdots,z_{i-1}z0,z1,,zi1,现在要求 ziz_izi

如果 ziz_izi 被求出来了,那么 s[i,i+zi−1]s[i,i+z_i-1]s[i,i+zi1] 一定是 sss 的前缀,且 s[i,i+zi]s[i,i+z_i]s[i,i+zi] 一定sss 的前缀。那么现在正在处理 ziz_izi,区间 s[i,i+zi−1]s[i,i+z_i-1]s[i,i+zi1] 就称为我们的匹配段,简称 Z-box。

由于 LaTeX\LaTeXLATEX 太难敲了,所以我们简称 [l,r][l,r][l,r] 是我们的匹配段。由于左端点已知,我们只需要扩展右端点。根据定义,[l,r][l,r][l,r]sss 的前缀。我们维护的时候 l≤il\le ili,初始 l=r=0l=r=0l=r=0

分两种情况讨论

  • 当前处理的 iiirrr 的左边(i≤ri\le rir):由于是匹配段,那么同一长度的 sss 肯定是相等的!(因为他们都是 sss 的前缀,长度又相等,必然相同)就有性质:s[i,r]=s[i−l,r−l]s[i,r]=s[i-l,r-l]s[i,r]=s[il,rl]。所以 zi=zi−lz_i=z_{i-l}zi=zil?别忘了,z[i]z[i]z[i] 的长度一定不超过 r−i+1r-i+1ri+1(怎么可能超过字符串长度?)所以 zi≥min⁡{zi−l,r−i+1}z_i\ge\min\{z_{i-l},r-i+1\}zimin{zil,ri+1}
    • 如果 zi−l<r−i+1z_{i-l}<r-i+1zil<ri+1,那么 zi=zi−lz_i=z_{i-l}zi=zil
    • 如果 zi−1≥r−i+1z_{i-1}\ge r-i+1zi1ri+1,那么 zi=r−i+1z_i=r-i+1zi=ri+1 的基础上,暴力扩展 ziz_izi
  • 如果 i>ri>ri>r,暴力扩展

最后,如果 i+zi−1>ri+z_i-1>ri+zi1>r,则令 l=i,r=i+zi−1l=i,r=i+z_i-1l=i,r=i+zi1 即可。

3.3 代码

for(int i=1;i<n;i++)
{
	int len=r-i+1;
	if(i<=r&&z[i-l]<len) z[i]=z[i-l];
	else
	{
		z[i]=max(0ll,len);
		while(i+z[i]<n&&a[z[i]]==b[i+z[i]]) z[i]++;
	}
	if(i+z[i]-1>r) l=i,r=i+z[i]-1;
}

3.4 时空复杂度分析

容易发现,内层 rrr 每次至少加一,外层 iii 每层加一。总的时间复杂度 O(n)O(n)O(n)

4. Manacher

4.1 反串、回文串

定义一个字符串 sss 的反串 srs^rsr 为:

  • 长度相等;
  • s[i]=sr[len−i+1]s[i]=s^r[len-i+1]s[i]=sr[leni+1]

如果一个串的反串等于他自身,我们称其为回文串。

回文串分为长度为奇数的回文串和长度为偶数的回文串。这是一句废话。

4.2 Manacher 是干嘛的

Manacher,也就是马拉车,可以求出每一对 l,rl,rl,r 使得 s[l,r]s[l,r]s[l,r]回文串l,rl,rl,r

假设有一个这样的串:

aaa⋯aa⏟n个a\underbrace{aaa\cdots aa}_{n\texttt{个}a}naaaaaa

这样的字符串会有 n2n^2n2 个回文串,所以马拉车看起来似乎不可能有线性做法。然而,它有。

4.3 Manacher 线性求回文串

考虑一个朴素算法:枚举每个点作为中心,往外扩展。

定义 pip_ipi 表示以点 iii 为中心,使得字符串 s[i−pi,i+pi]s[i-p_i,i+p_i]s[ipi,i+pi] 为回文串而 s[i−pi−1,i+pi+1]s[i-p_i-1,i+p_i+1]s[ipi1,i+pi+1] 不为回文串的回文串半径 pip_ipi

定义 [l,r][l,r][l,r] 为我们正在处理回文串边界。初始 l=0,r=−1l=0,r=-1l=0,r=1

这个东西很像 Z 函数。那么我们可以得到规律:当 i>ri>ri>r 时,暴力扩展。因为我们无论怎样都不可能使用 [l,r][l,r][l,r] 得到 pip_ipi

那当 i≤ri\le rir 时呢?考虑一下怎么从 [l,r][l,r][l,r] 中得到点信息。首先在 [l,r][l,r][l,r] 中,iii 对应的应该是 l+r−il+r-il+ri。为什么?

sssiii 所对应的点,依题意得:i−l=r−si-l=r-sil=rs,经移项,s=l+r−is=l+r-is=l+ri

那么有一种想法就是:因为 iii 对应的是 sss,那么 pi=psp_i=p_spi=ps。但是这东西不能超过 r−i+1r-i+1ri+1,所以 pi=min⁡{ps,r−i+1}p_i=\min\{p_s,r-i+1\}pi=min{ps,ri+1},然后暴力扩展即可。

那这种说法对不对呢?显然是对的。

最后更新 l=i−pi+1,r=i+pi−1l=i-p_i+1,r=i+p_i-1l=ipi+1,r=i+pi1 即可。

4.4 统一奇偶回文串

回文串分为长度为奇数的回文串和长度为偶数的回文串。这不是一句废话,因为偶回文串会让操作变得很不统一。所以我们在字符的两边都加上一个 |。比如:|a|b|c|d|e|f|g|

4.5 代码

//这是输入
void qr()
{
	char c=getchar();
	s[0]='~',s[n=1]='|';
	n=1;
	while(c<'a'||c>'z') c=getchar();
	while(c>='a'&&c<='z')
	{
		s[++n]=c,s[++n]='|';
		c=getchar();
	}
}
//这是Manacher
for(int i=1;i<=n;i++)
{
	if(i<=r) p[i]=min(p[l+r-i],r-i+1);//对应点对
	while(s[i-p[i]]==s[i+p[i]]) p[i]++;//暴力扩展
	if(p[i]+i>r) r=i+p[i]-1,l=i-p[i]+1;//更新左右端点
}

4.6 时空复杂度分析

容易发现,内层 rrr 每次至少加一,外层 iii 每层加一。总的时间复杂度 O(n)O(n)O(n)

5. 自动机

5.1 为什么讲“自动机”

你或许以为我要讲 AC 自动机,实际上我要先说自动机。

前后讲的 trie、kmp、AC自动机、后缀自动机 全部属于自动机。这也是为什么要将“自动机”。

5.2 自动机的形式化定义

那自动机到底是什么呢?你可以把它看成一个有向图。这些图中,有字符集 Σ\SigmaΣ、状态集合 QQQ(如初始状态集合 startstartstart、终止状态集合 FFF)、转移函数 δ\deltaδ。我们把自动机简称为 DFA\text {DFA}DFA

假设有一个 DFA A\text{DFA }ADFA A,如果 AAA 能接受 SSS,那么 A(S)=TrueA(S)=\text{True}A(S)=True,否则我们说 A(S)=FalseA(S)=\text{False}A(S)=False

DFA\text{DFA}DFA 的运行过程如下:每次按每一位进行转移,如果读入完所有的字符后进入了一个终止集合,那么我们说 AAA接受 SSS,否则说 AAA 不能接受 SSS

如果从状态 vvv 无法转移 ccc,我们称 δ(v,c)=null\delta(v,c)=\text{null}δ(v,c)=nullnull\text{null}null 能也只能转移到 null\text{null}null

5.3 一些自动机

  • Trie:对照定义,会发现这是一个典型的自动机
  • KMP:也是自动机
  • AC 自动机:都写脸上了还不是自动机?

6. AC 自动机

6.1 AC 自动机是干什么的?

AC 自动机 = KMP 炒鸡版。AC 自动机解决的也是一类匹配问题,但解决的是多模式匹配问题。

一般来说,AC 自动机 = Trie + KMP。推荐你先学完 KMP 和 Trie 后再来看 AC 自动机。

6.2 AC 自动机大体思路

  1. 构造所有模式串的 Trie
  2. 创建失配指针(即 fail 指针)

6.3 Step1.建立字典树

AC 自动机的第一步就是创建所有模式串的 Trie。AC 自动机建立在 Trie 之上。

容易发现,Trie 的每一个节点都是字符串的前缀(状态)

6.4 失配指针

失配指针?KMP!

AC 自动机的 fail 指针和 KMP 的 next 指针其实差不多。区别在 KMP 跳转的是最长 border,而 AC 自动机跳转的是所有模式串的前缀中匹配当前状态的最长后缀。

6.5 Step2.求 fail 指针

跟 KMP 简直一模一样。

假设现在我们处理到了节点 xxx,他的父节点是 fff,满足转移 δ(f,k)=x\delta(f,k)=xδ(f,k)=x,即通过字符 kkk 衔接。

  • 如果转移 δ(fail⁡f,k)\delta(\operatorname{fail}_f,k)δ(failf,k) 存在,即有一条路径 fail⁡f→k\operatorname{fail}_f\to kfailfk,因为 δ(f,k)=x\delta(f,k)=xδ(f,k)=x,所以 δ(fail⁡f,k)=fail⁡x\delta(\operatorname{fail}_f,k)=\operatorname{fail}_xδ(failf,k)=failx
  • 否则去寻找 δ(fail⁡fail⁡f,k)\delta(\operatorname{fail}_{\operatorname{fail}_f},k)δ(failfailf,k) 是否存在,就这样一直 fail⁡\operatorname{fail}fail 下去。
  • 还没有?fail⁡u=\operatorname{fail}_u=failu= 根节点。

考虑怎么实现处理 fail⁡\operatorname{fail}fail 指针。把这颗树进行 BFS。定义:

  • δ(f,k)\delta(f,k)δ(f,k),状态 fff 经过字符 kkk 后到达的状态。
  • qqq,队列
  • fail⁡u\operatorname{fail}_ufailuuuu 的失配指针。
void build()
{
	queue<int>q;
	for(int i=1;i<=26;i++)
		if(a.trie[0][i]) q.push(a.trie[0][i]);
	while(!q.empty())
	{
		int u=q.top();q.pop();
		for(int i=1;i<=26;i++)
			if(tr[u][i])
				fail[tr[u][i]]=tr[fail[u]][i],q.push(tr[u][i]);//oi.wiki修改了
			else
         		tr[u][i]=tr[fail[u]][i];
	}
}

AC 自动机通过修改字典树,将其变成了一张字典图

6.6 Step3.进行匹配

我们用 query⁡\operatorname{query}query 函数来进行匹配。代码如下:

int query(string s)
{
	int u=0,ans=0;
	for(int i=0;i<s.size();i++)
	{
		u=tr[u][s[i]-'a'+1];
    	for(int j=u;j&&e[j]!=-1;j=fail[j])
    		ans+=e[j],e[j]=-1;
	}
    return ans;
}

这里我们定义 e[j]=1 表示在编号为 jjj 的节点上,有一个完整的模式串。

原理很简单:通过 fail⁡\operatorname{fail}fail 指针跳转,标记出现模式串记录下来即可。

6.7 AC 自动机的放松小视频

 

 

转自 B 站博主 科研界干饭王

6.8 例题:AC 自动机(简单版)

就用讲过的 AC 自动机就可以了。

#include<bits/stdc++.h>
#define int long long
using namespace std;
const int MAXN=2000005;
struct Trie{
	int trie[MAXN][30],e[MAXN*30],tot=0;
	void insert(string s)
	{
		int u=0,len=s.size();s=" "+s;int res=0;
		for(int i=1;i<=len;i++)
		{
			int z=s[i]-'a'+1;
			if(trie[u][z]==0) tot++,trie[u][z]=tot;
			u=trie[u][z];
		}
		e[u]++;
	}
};
struct AC{
	Trie a;
	int fail[MAXN*30];
	//略
}tree;
signed main()
{
	int n;cin>>n;
	string s;
	for(int i=1;i<=n;i++)
		cin>>s,tree.a.insert(s);
	tree.build();
	cin>>s;s=" "+s;
	cout<<tree.query(s);
	return 0;
}

6.9 例题:AC 自动机(简单版 II)

考虑记录 fail 跳转的次数即可。

int query(string s)
{
	int u=0,ans=0;
	for(int i=0;i<s.size();i++)
	{
		u=a.trie[u][s[i]-'a'+1];
		for(int j=u;j;j=fail[j])
    	{
			if(a.Id[j]) a.e[j]++;
		}
	}
	for(int i=1;i<=n;i++) ans=max(ans,a.e[a.id[i]]);
	return ans;
}

6.10 例题:【模板】AC 自动机/AC 自动机的优化

哇,好像比简单版II还水!然后 T 了。

为什么会 T 呢?我们发现 fail 指针跳的时候,有大量的重复计算

我们发现 fail 指针不会出现环,那么 fail 边一定会构成一颗树。那么 AC 自动机的匹配其实就是求一条链的长度。考虑一下怎么优化。

有两种优化方式,拓扑排序子树求和

  • 拓扑排序
    首先建立一颗 fail 树。容易想到,有一条边是 u−fail⁡uu-\operatorname{fail}_uufailu。然后先为 sss 所在的状态打上标记。
void query(string s)
{
	int u=0;
	for(int i=0;i<s.size();i++)
	{
		u=a.trie[u][s[i]-'a'+1];
        e[u]++;
	}
}

最后用一个拓扑排序求出答案。

  • 子树求和
    求出每颗子树的和即可。
posted @ 2024-10-08 21:04  sLMxf  阅读(58)  评论(0)    收藏  举报  来源