字 符 串 全 家 桶

字符串

Trie

基础的内容,但是当然要会。

哈希

没什么好说的。哈希算法和大部分的字符串算法似乎不是一个体系的。使用的时候注意尽量双哈希即可。

KMP 与 border

border 的定义:如果 \(i\) 满足 \(s[1\cdots i]=s[|s|-i+1\cdots |s|]\),则称 \(i\)\(s\) 的一个 border。

\(s\) 的所有 border 构成的集合记为 \(Border(s)\)。一般不认为 \(|s|\in Border(s)\)

KMP 算法可以在 \(O(n)\) 的时间内求出字符串 \(s\) 的所有前缀的最长 border。若记 \(s[1\cdots i]\) 的最长 border 为 \(f_i\)(不存在则为 \(0\)),则有结论:\(s[1\cdots i]\) 的所有 border 是 \(f_i,f_{f_i},f_{f_{f_i}},\cdots\)。因此求 \(f_{i+1}\) 时只要在这些 border 里面尝试就可以了。

结论的证明是容易的,画出图来就很直观。

for(int i=2,j=0;i<=n;i++){
    while(j&&s[j+1]!=s[i])j=f[j];
    if(s[j+1]==s[i])++j;f[i]=j;
}

border 还有一个重要性质,称为 border 引理:\(s\) 的所有 border 可以划分为不超过 \(\lceil \log_2|s| \rceil\) 个等差数列。更进一步地,对任何 \(k\)\([k,2k)\) 内的所有 border 构成等差数列。

至于 KMP 解决模式匹配问题就略过了。

AC 自动机

AC 自动机是 以 Trie 的结构为基础,结合 KMP 的思想 建立的自动机,用于解决多模式匹配等任务。——摘自 OI Wiki。

要建立 AC 自动机,首先建一个 Trie。然后添加一些 fail 指针,状态 \(u\) 的 fail 指针指向的状态 \(v\)\(u\) 的(出现过的)最长后缀。fail 指针的添加要按照深度处理,也就是需要做一遍拓扑排序。假设要求 \(x\) 的 fail 指针,\(x\) 的 父结点是 \(u\)\(x\) 是在 \(u\) 后面添加了一个字符 \(c\) 得到的。类似 KMP,从 fail[u] 开始不断跳 fail 指针,直到添加一个 \(c\) 之后的状态存在,就把 \(x\) 的 fail 指针指向这个状态。

实际实现时可以更简单一点,不用一直跳 fail,可以预先记下从这个点开始跳,后面增加 \(c\),最终会跳到哪个点。

queue<int> Q;
for(int i=0;i<26;i++)
	if(trie[0][i])Q.push(trie[0][i]);
while(!Q.empty()){
    int u=Q.front();Q.pop();
	for(int i=0;i<26;++i){
		if(trie[u][i]){
			fail[trie[u][i]]=trie[fail[u]][i];
			Q.push(trie[u][i]);
		}
		else trie[u][i]=trie[fail[u]][i];
	}
}

事实上会发现 KMP 就相当于是只有一个串时的 AC 自动机。既然 KMP 可以解决单模式串的匹配问题,AC 自动机就可以解决多模式串的匹配问题了。

一般的应用方法是把 fail 指针单拿出来看作一棵树去处理。比如说在状态 \(u\) 的子树内的所有状态都包含 \(u\) 对应的字符串作为子串。或许还可以在这棵树上 DP。

回文树

又叫回文自动机,可以简写为 PAM。它可以存储一个字符串中所有回文子串的信息。

首先容易知道 PAM 的状态数不超过 \(|s|\)。可以归纳证明这个结论。

PAM 的转移边表示在当前字符串前后各加一个相应字符,而 fail 指针指向最长的回文前缀(也就是最长后缀,还是最长 border)。另外每个状态上还要记下该回文串的长度。PAM 有两个初始状态,分别代表长度为 \(-1,0\) 的回文串,称为奇根,偶根。进行一个增量构造,假设已经构造了前 \(p-1\) 个字符的 PAM,添加 \(s_p\) 时,不断跳 fail 指针直到 \(s_p=s_{p-len-1}\)。然后如果新的状态存在就直接跳过去,否则新建一个状态,并且继续跳 fail 以获得这个状态的 fail 指针。

int tot,lst,trans[N][26],len[N],fail[N];
int node(int l){
	tot++;for(int j=0;j<26;j++)trans[tot][j]=0;
	len[tot]=l;fail[tot]=0;return tot;
}
void init(){tot=-1;lst=0;node(0);node(-1);fail[0]=1;}
int getfail(int x,int y){
	while(s[x-len[y]-1]!=s[x])y=fail[y];
	return y;
}
void build(char *s){
	int Len=strlen(s+1);
	for(int i=1;i<=Len;i++){
		int now=getfail(i,lst),c=s[i]-'a';
		if(!trans[now][c]){
			int x=node(len[now]+2);
			fail[x]=trans[getfail(i,fail[now])][c];
			trans[now][c]=x;
		}
		lst=trans[now][c];
	}
}

与回文串有关的问题都可以考虑使用 PAM。

Z函数,Manacher

这两个放到一起是因为它们真的很像。

Z 函数(或称为扩展 KMP)要解决的问题是:对所有 \(i\),求 \(s\)\(s[i\cdots |s|]\) 的最长公共前缀的长度 \(z_i\)。Manacher 要解决的问题是:对所有 \(i\),求以 \(i\) 为中心的最长回文串的长度 \(a_i\),这里 \(i\) 可能是某个字符或者某个缝隙。

二者都有线性解法,思路都是利用已有信息减少比较量。对于 Z 函数而言,如果有一个段 \(s[l,r]\)\(s\) 的前缀匹配了,那么在求 \(z_i\) 的时候,如果 \(i\le r\),可以知道要么 \(z_i=z_{i-l}\)(当 \(z_{i-l}\lt r-i+1\) 时),要么 \(z_i\ge r-l+1\)。因此维护最右边的这样的段,然后暴力往后扩展即可。会发现右端点是不降的,所以时间复杂度就是线性。

for(int i=2,l=1,r=1;i<=m;i++){
	if(i<=r&&z[i-l+1]<r-i+1)z[i]=z[i-l+1];
	else{
		z[i]=max(0,r-i+1);
		while(i+z[i]<=m&&b[z[i]+1]==b[i+z[i]])++z[i];
		l=i;r=i+z[i]-1;
	}
}

Manacher 也类似。以 \(i\) 是字符的情况为例,如果有一个回文段 \(s[l\cdots r]\),那么在求 \(a_i\) 的时候,如果 \(i\le r\),可以知道要么 \(a_i=a_{l+r-i}\)(当 \(a_{l+r-i}\lt r-i+1\) 时),要么 \(a_i\ge r-l+1\)。同样维护最右边的段,然后暴力往后扩展。同样右端点是不降的。一个小技巧是给原字符串的每两个字符之间加一个特殊字符,这样就可以避免掉 \(i\) 是缝隙的情况。

for(int i=1,l=1,r=0;i<=n;i++){
	int k=i>r?1:min(a[l+r-i],r-i+1);
	while(i-k>=1&&i+k<=n&&s[i-k]==s[i+k])++k;
	a[i]=k;--k;ans=max(ans,a[i]);
	if(i+k>r)l=i-k,r=i+k;
}	

后缀数组

后缀数组要解决的问题是把 \(s\) 的所有后缀按照字典序排序。排序得到的结果记为 \(sa\) 数组,同时用 \(rk\) 数组表示每个位置的排名。

\(O(n\log^2n)\) 做法:熟知比较两个字符串的字典序可以通过二分+哈希的方法在 \(O(\log)\) 的时间内完成,再套一个 sort 就可以做到 \(O(n\log^2n)\)

\(O(n\log n)\) 做法:运用倍增的思想。

\(suf_{j,i}\) 表示字符串 \(s[i\cdots \min(n,i+2^j-1)]\),当 \(sa_j\) 表示对 \(suf_{j,i}\) 的排序结果,\(rk_j\) 表示相应的排名。显然当 \(j\) 达到 \(\lceil \log_2 n\rceil\) 时就得到了需要的 \(sa\)\(j=0\) 的边界情况是容易的。对于从 \(j-1\)\(j\),要比较 \(suf_{j,i_1}\)\(suf_{j,i_2}\),需要先比较 \(suf_{j-1,i_1}\)\(suf_{j-1,i_2}\),再比较 \(suf_{j-1,i_1+2^{j-1}}\)\(suf_{j-1,i_2+2^{j-1}}\)。这个比较用的是 \(rk_{j-1}\)。所以这里就需要一个双关键字的排序,使用双关键字基数排序完成即可。

#include<bits/stdc++.h>
using namespace std;
const int N=1e6+5;
int n,m,rk[N],y[N],c[N],sa[N];
//sa[i]表示排名为i的后缀
//c数组是桶,基数排序的辅助数组
//rk=x,y分别表示两个关键字
char s[N];
int main(){
	scanf("%s",s+1);
	n=strlen(s+1);m=300;
    for(int i=1;i<=n;i++){rk[i]=s[i];++c[rk[i]];}//得到第一关键字并计入桶中
    for(int i=2;i<=m;i++)c[i]+=c[i-1];//对桶做前缀和,则字典序越大,对应的c越大
    for(int i=n;i>=1;i--)sa[c[rk[i]]--]=i;
    for(int k=1;k<=n;k<<=1){
        int num=0;
        //确定第二关键字,y[i]表示第二关键字排名为i的数
        for(int i=n-k+1;i<=n;i++)y[++num]=i;//不存在第二关键字的当作极小值
        for(int i=1;i<=n;i++)if(sa[i]>k)y[++num]=sa[i]-k;
        for(int i=1;i<=m;i++)c[i]=0;
        for(int i=1;i<=n;i++)++c[rk[i]];
        for(int i=2;i<=m;i++)c[i]+=c[i-1];
        for(int i=n;i>=1;i--){sa[c[rk[y[i]]]--]=y[i];y[i]=0;}
        //又一遍基数排序,在第二关键字已经排序完成的基础上
        //对第一关键字进行排序,所以只用把开头的i换成y[i]
        swap(rk,y);num=1;rk[sa[1]]=1;
        //现在y不是用作第二关键字了,它记下了原来的第一关键字,即长为k的排序结果
        for(int i=2;i<=n;i++){
            if(y[sa[i]]==y[sa[i-1]]&&y[sa[i]+k]==y[sa[i-1]+k])
                rk[sa[i]]=num;
            else rk[sa[i]]=++num;
        }//x[i]表示i的排名
        if(num==n)break;m=num;//m是字符集大小
    }
    for(int i=1;i<=n;i++)printf("%d ",sa[i]);
	return 0;
}

涉及到子串比较大小的问题可以考虑后缀数组。

后缀数组可以引申出 height 数组,简记为 h,它的定义是 h[i]=LCP(suf[sa[i-1]],suf[sa[i]]),这里 LCP 表示最长公共前缀(的长度)。h 数组可以 \(O(n)\) 求,因为有结论:h[rk[i]]>=h[rk[i-1]]-1。然后就可以暴力跳了。

for(int i=1,k=0;i<=n;i++){
    if(rk[i]==0)continue;if(k)--k;
    while(s[i+k]==s[sa[rk[i]-1]+k])++k;
    h[rk[i]]=k;
}

有结论:LCP(suf[sa[i]],suf[sa[j]])=min h[i+1...j],i<j。感性理解是容易的。因此在 ST 表预处理后可以 \(O(1)\) 求任意两个后缀的 LCP。

后缀树

考虑把 \(s\) 的所有后缀插入到一个 Trie 中。这个 Trie 有一个优越的性质:它的每一个非根结点恰好对应一个 \(s\) 的非空子串。但是这个 Trie 就很大,所以考虑压缩:如果某个点只有一个儿子,那么就把它和子结点缩起来。特别的,如果一个结点作为某个后缀的终止结点,也将其保留下来。这样得到的树称为后缀树。

后缀树就是反串的后缀自动机的 slink 建出的树。

后缀自动机

十级算法,先咕咕咕了。进省队了,不咕了!

字符串 \(S\) 的后缀自动机(简称 SAM),是一个可以接受 \(S\) 的所有后缀的最小自动机。SAM 最重要的是它包含了 \(S\) 的所有子串的信息——一个子串对应一条从初始状态 \(u_0\) 出发的路径。

对 SAM 很重要的概念有两个。其一是结束位置 endpos。对 \(s\) 的任意子串 \(t\),用 \(\text{endpos}(t)\) 表示 \(t\)\(s\) 中所有出现的末尾。根据 \(\text{endpos}\),所有子串可以分为若干等价类,它们在 SAM 中被存储在同一个结点内。同时我们还可以得到一些重要推论:

  • 如果 \(\text{endpos}(u)=\text{endpos}(v),|u|\le |v|\),则 \(u\)\(s\) 中的每次出现都是 \(v\) 的后缀。
  • 如果 \(|u|\le |v|\),要么 \(\text{endpos}(u)\cap \text{endpos}(v)=\O\),要么 \(\text{endpos}(u)\subseteq \text{endpos}(v)\)
  • 一个 \(\text{endpos}\) 等价类内的所有子串的长度互不相同,且恰好构成一段区间。根据这个结论,只用在结点上记录这个等价类的最长字符串长度 maxlen 或者简记为 len,以及最短字符串长度 minlen

其二是后缀链接 slink。对于一个 \(\text{endpos}\) 等价类 \(v\)(也就是 SAM 中的一个结点 \(v\)),\(\text{slink}(v)\) 连接到对应于 \(v\) 中最短的字符串 \(w\) 删掉第一个字符后(即最长真后缀)所在的 \(\text{endpos}\) 等价类。特别的,如果 \(w\) 只有一个字符,那么连接到 \(u_0\)。方便起见,令 \(\text{endpos}(u_0)=\{0,1,2,\cdots,|S|\},\text{slink}(u_0)=-1\)。实际上会发现 \(\text{slink}(v)\) 对应的 \(\text{endpos}\) 集合包含了 \(v\) 对应的 \(\text{endpos}\) 集合,于是后缀链接会构成一棵根结点为 \(u_0\) 的树,表示了 \(\text{endpos}\) 集合的包含关系。这棵树一般称为 \(\text{parent}\) 树。同时会有 \(\text{minlen}(v)=\text{maxlen}(\text{slink}(v))+1\)(所以就不用记 \(\text{minlen}\) 了。

SAM 的构建就是增加一个字符 \(c\),然后进行一些分类讨论:

  1. \(lst\) 为添加 \(c\) 之前整个串对应的状态。创建一个新的状态 \(cur\),令 \(\text{len}(cur)=\text{len}(lst)+1\),然后将 \(lst\) 的值更新为 \(cur\)
  2. 如果 \(lst\) 没有字符 \(c\) 对应的转移,添加到 \(cur\) 的转移。遍历后缀链接,将所有没有字符 \(c\) 转移的结点的这个转移定向到 \(cur\),直到找到第一个存在字符 \(c\) 的转移的结点,设为 \(p\)
  3. 如果 \(p\) 不存在,也就是到达了 \(-1\),相当于说 \(c\) 是一个新出现的字符,将 \(\text{slink}(cur)\) 赋值为 \(0\) 并退出。
  4. 否则,设 \(p\) 通过 \(c\) 转移到的状态为 \(q\)。如果 \(\text{len}(p)+1=\text{len}(q)\),直接将 \(\text{slink}(cur)\) 赋值为 \(q\) 并退出。事实上,\(\text{slink}(cur)\) 要连接到的状态包含了整个字符串在加入 \(c\) 前就出现过的当前最长后缀,也就是以 \(c\) 结尾且长度为 \(\text{len}(p)+1\) 的字符串。
  5. 否则,我们要从 \(q\) 中拆出一部分 \(r\) 作为 \(\text{slink}(v)\)。这时 \(\text{slink}(r)\) 等于原来的 \(\text{slink}(q)\)\(\text{slink}(q)\) 变成了 \(r\)\(\text{len}(r)=\text{len}(p)+1\)。然后从 \(p\) 遍历后缀链接往前跳,对所有字符 \(c\) 转移到 \(q\) 的结点,将这个转移重定向到 \(r\)

根据构造方式可以知道 SAM 是线性的。具体的,点数最大为 \(2|S|-1\),边数最大为 \(3|S|-4\)

void insert(int c){
    int cur=++tot;len[cur]=len[lst]+1;
    int p=lst;lst=cur;
    while(p!=-1&&!trans[p][c])trans[p][c]=cur,p=slink[p];
    if(p==-1){slink[cur]=0;return;}
    int q=trans[p][c];
    if(len[q]==len[p]+1){slink[cur]=q;return;}
    int r=++tot;len[r]=len[p]+1;slink[r]=slink[q];
    for(int i=0;i<26;i++)trans[r][i]=trans[q][i];
    slink[q]=slink[cur]=r;
    while(p!=-1&&trans[p][c]==q)trans[p][c]=r,p=slink[p];
}
posted @ 2024-01-11 10:38  by_chance  阅读(103)  评论(0编辑  收藏  举报