字符串算法学习笔记(不定期更新)

暂时咕咕咕了。

1.SA

模拟退火后缀数组(Suffix Array)是一种很奇妙的算法。主要原因是它可以做到在 \(O(n\log n)\) 时间内完成排序。

关于如何完成这个比较基础,具体可见洛谷日报

而后缀排序的重点在于“字典序排序”的一些奇妙性质。所以对于一般字符串的字典序排序,以下性质也适用。

首先可以发现的是 \(\operatorname{LCP}(i,j)=\min(\operatorname{LCP}(i,k),\operatorname{LCP}(k,j)),k\in[i,j]\)。这个比较显然主要我也不怎么会严格证明。具体可以见洛谷日报的证明。

考虑有了这个我们可以干什么。考虑这样一道题:按一定方式给定一堆字符串(总长度可能很大),问其中本质不同前缀的个数。

那么显然可以发现,相邻两字符串的 \(\operatorname{LCP}\) 就是他们本质相同的前缀。换句话说,除此之外的部分都是本质不同的。

而根据那个奇怪的性质,相邻两个字符串 \((x,x+1)\)\(\operatorname{LCP}\) 一定 \(\geq (i,k),k\geq i+1\)\(\operatorname{LCP}\)。所以显然成立。

但是这个相邻的 \(\operatorname{LCP}\) 怎么求呢?

其实是有一个很simple的 \(O(n)\) 求法。什么SA-IS?完全不会。

具体来说,我们可以求出第 \(i\) 个位置与字典序在它前面的串的 \(\operatorname{LCP}\) \(h_i\)。可以发现有 \(h_{i}=h_{i-1}+1\)。于是乎就均摊 \(O(n)\) 了。

那么我们可以做什么了呢?求本质不同子串!每个后缀的前缀唯一对应一个子串,所以直接减就好了。

例:本质不同子串

#include<iostream>
#include<cstdio>
#include<cstring>
#define N 100010
using namespace std;
int b[N],sa[N],rk[N],a[N],id[N];
char s[N];
void SA_(int n,int m)
{
	for(int i=0;i<=m;i++) b[i]=0;
	for(int i=1;i<=n;i++) b[rk[i]]++;
	for(int i=1;i<=m;i++) b[i]+=b[i-1];
	for(int i=n;i>=1;i--) sa[b[rk[id[i]]]--]=id[i];
}
void SA(int n)
{
	int m=124;
	for(int i=1;i<=n;i++) rk[i]=s[i]-'0'+1,id[i]=i;
	SA_(n,m);int t=0;
	for(int p=1;p<n;m=t,t=0,p<<=1)
	{
		for(int i=1;i<=p;i++) id[++t]=n-p+i;
		for(int i=1;i<=n;i++) if(sa[i]>p) id[++t]=sa[i]-p;
		SA_(n,m); swap(id,rk); rk[sa[1]]=t=1;
		for(int i=2;i<=n;i++) rk[sa[i]]=(t+=id[sa[i-1]]!=id[sa[i]] || id[sa[i-1]+p]!=id[sa[i]+p]);
	}
}
int ht[N];
void get_ht(int n)
{
	for(int i=1,p=0;i<=n;ht[rk[i]]=p,i++)
	if(rk[i]!=1) for(p=p-!!p;sa[rk[i]-1]+p<=n && i+p<=n && s[i+p]==s[sa[rk[i]-1]+p];p++);
}
int main()
{
	int n;
	scanf("%d%s",&n,s+1);
	SA(n);
	get_ht(n);
	long long ans=1ll*n*(n+1)/2;
	for(int i=1;i<=n;i++) ans-=ht[i];
	printf("%lld\n",ans);
	return 0;
}
// 压行?怎么可能?这叫 建筑美(

看到这你或许会问:这个不是SAM也能做吗?而且SAM是 \(O(n)\) 的。

的确,绝大部分SA能做的SAM都能做,而且SAM跑得快、支持在线、还更好些(所以我学SA干什么)。

别急,这里还有一个SA的晋升版本:

2.后缀平衡树

没想到吧,后缀平衡树居然不是后缀树变过来的(我也没想到)。

首先我们还是考虑一般情况:给定一个字符串 \(S\) 的一堆子串,每次问某个子串 \(s0\) 与其他每个串的 \(\operatorname{LCP}\) 最大是多少。动态修改子串集合。

这个可以怎么做?考虑使用平衡树套Hash。具体可以见Hash学习笔记中的一道口胡的题(里面好像还有一个强制在线)。

这个是 \(O(n\log^2 n)\) 的,虽然比较暴力已经足够优秀了。但是如果我们插入的字符串有一些规律可循,是不是有更快的做法。

【模板】后缀平衡树

题意

维护一个字符串,支持加一堆字符,删一堆字符,询问某个字符串出现次数。强制在线。总字符串长度 \(\leq 10^6\)

出题人真的是丧心病狂。。。

AC自动机能过?那就强制在线。

SAM还能过?那就每次加一堆字符。

啥?两只 \(\log\) 艹过去了?那就开到 \(10^6\)真·5步出题法

显然我们需要一个更高妙的做法。考虑多一只 \(\log\) 的瓶颈在于每次判断字典序时必须 \(O(\log n)\) 处理。再加上判断 \(O(\log n)\) 次,所以总 \(O(\log^2 n)\)

平衡树的 \(\log n\) 没办法优化,考虑优化判断字典序。可以发现,我们要加入的字符串 \(u\) 在加入前它的前缀一定已经出现过了,所以前缀和当前要比较的节点 \(p\) 均出现过。

可以发现,当前加入的字符串 \(u\) 除了最后一个字符之外其他都与前缀 \(u-1\) 完全一致,所以我们先暴力比较 \(u\)\(p\) 的最后一个字符,如果相同意味着这个 \(u-1\)\(p-1\) 的字典顺序决定了 \(u\)\(p\) 的字典顺序。但是直接这样比较还是 \(O(\log n)\)

考虑如果我们维护出了所有前缀的 \(rank\),那么显然 \(rank\) 的相对顺序就对应最后的结果。但是我们不能直接维护rank,这样会平白多出一个 \(\log n\)。考虑我们只需要知道 \(rank\) 的相对顺序即可。考虑利用平衡树的性质,每个点取一个权值 \(v_i=\frac{L+R} 2\),然后根据 \(v_i\) 将区间分为两段递归处理。可以发现,这样满足 \(v_{ls_u}< v_u< v_{rs_u}\)

这样建树的时间复杂度 \(O(|S|\log |S|)\)

考虑这题维护的东西:出现次数。

这个就很好办了。考虑差分,比如要查 \(\texttt{AB}\),我们就查字典序在 \(\texttt{AA[}\)\(\texttt{AB[}\) 之间的字符。(\(\texttt{[}\) 的字典序大于所有大写字母)。具体来说,由于后缀平衡树中不存在字符 \(\texttt{[}\) ,我们可以直接用字典序小于 \(\texttt{AB[}\) 的数量减去小于 \(\texttt{AA[}\) 的数量。

由于这里需要保证相对顺序,所以我们必须使用重量平衡树,这里可以是替罪羊树(当然好像treap也行,splay应该会被卡)。

总时间复杂度 \(O(|S|\log |S|)\),空间复杂度 \(O(|S|)\)

特别,这里需要支持删除。但是我们不能按照普通的替罪羊树那样打标记,因为这个点会被替换掉。

所以我们直接暴力删除,按照BST的方式找到左子树中最右端的点,然后拼接上去。由于树深是 \(O(\log n)\),所以这样复杂度也是对的。

#include<iostream>
#include<cstdio>
#include<cstring>
#include<algorithm>
#define N 2000010
#define db double
#define alp 0.72
#define MAXD 1e16
using namespace std;
char str[N],s[N];
db v[N];
int ch[N][2],siz[N];
int swp[N],stot,rt;
void upd(int u){siz[u]=siz[ch[u][0]]+siz[ch[u][1]]+1;}
void dfs(int u)
{
	if(!u) return;
	dfs(ch[u][0]),swp[++stot]=u;dfs(ch[u][1]);
	ch[u][0]=ch[u][1]=0;
}
void build(int &u,int l,int r,db lf=0,db rf=MAXD)
{
	if(l>r) return;
	int mid=(l+r)>>1;db mf=(lf+rf)/2;
	u=swp[mid];
	v[u]=mf;
	build(ch[u][0],l,mid-1,lf,mf),build(ch[u][1],mid+1,r,mf,rf);
	upd(u);
}
void reb(int &u,db lf,db rf)
{
	if(max(siz[ch[u][0]],siz[ch[u][1]])<siz[u]*alp) return;
	stot=0;dfs(u);
	build(u,1,stot,lf,rf);
}
int cmp(int x,int y){return s[x]==s[y]?v[x-1]<v[y-1]:s[x]<s[y];}
void insert(int &u,int k,db lf=0,db rf=MAXD)
{
	if(!u){siz[u=k]=1;v[u]=(lf+rf)/2;ch[u][0]=ch[u][1]=0;return;}
	if(cmp(k,u)) insert(ch[u][0],k,lf,v[u]);
	else insert(ch[u][1],k,v[u],rf);
	upd(u),reb(u,lf,rf);
}
void erase(int &u,int k)
{
	if(u==k)
	{
		if(!ch[u][0] || !ch[u][1]){u=ch[u][0]|ch[u][1];return;}
		int p=ch[u][0],las=u;
		for(;ch[p][1];las=p,p=ch[p][1]) siz[p]--;
		if(las==u) ch[p][1]=ch[u][1];
		else ch[p][0]=ch[u][0],ch[p][1]=ch[u][1],ch[las][1]=0;
		u=p;
		upd(u);
		return;
	}
	if(cmp(k,u)) erase(ch[u][0],k);
	else erase(ch[u][1],k);
	upd(u);
}
bool cmp_s(int u){for(int i=1;str[i];i++,u=u-!!u) if(str[i]!=s[u]) return str[i]<s[u];return false;}
int answer(int u)
{
	if(!u) return 0;
	if(cmp_s(u)) return answer(ch[u][0]);
	else return answer(ch[u][1])+siz[ch[u][0]]+1;
}
void get_c(char s[],int mask)
{
	int len=strlen(s);
	for(int i=0;i<len;i++)
	{
		mask=(mask*131+i)%len;
		char t=s[i];
		s[i]=s[mask];
		s[mask]=t;
	}
}
char opt[7];
int main()
{
	int n,m,k,las=0;
	scanf("%d%s",&m,s+1);n=strlen(s+1);
	for(int i=1;i<=n;i++)
	insert(rt,i);
	for(int i=1;i<=m;i++)
	{
		scanf("%s",opt);
		if(opt[0]=='D'){scanf("%d",&k);while(k --> 0) erase(rt,n),n--;continue;}
		scanf("%s",str+1);
		get_c(str+1,las);
		int l=strlen(str+1);
		if(opt[0]=='A') for(int j=1;j<=l;j++) s[++n]=str[j],insert(rt,n);
		else if(opt[0]=='Q')
		{
			reverse(str+1,str+l+1);
			str[l+1]='Z'+1,str[l+2]='\0';
			int ans=answer(rt);
			str[l]--;
			ans-=answer(rt);
			printf("%d\n",ans);
			las^=ans;
		}
	}
	return 0;
}
//压行?不存在的。

bzoj3682 Phorni

题意

维护一个 \(n\) 个元素的序列,每个元素是字符串 \(s\) 的一个后缀。

支持:在 \(s\) 开头加一个字符,改变序列 \(a_i\) 表示的后缀位置,求序列 \([l,r]\) 元素中字典序最小的编号(如有相同输出编号最小)。强制在线

第一眼:这不是**题吗?直接SA就好了。

然后发现:强制在线

SA没了,直接后缀平衡树就好了。本来后缀平衡树也被叫做“动态后缀数组”。

首先将串反过来,就变成了在末尾加字符,查询前缀。

用平衡树维护各前缀之间的相对位置,然后用一颗线段树处理 \(n\) 个前缀的相对权值即可。

注意这里这个相对权值的精度要求可能比较高,直接比较可能把两个相同的前缀看做不同的。需要特判该情况。

复杂度 \(O(n\log n)\)

#include<iostream>
#include<cstdio>
#include<cstring>
#include<algorithm>
#define N 2000010
#define db double
#define alp 0.74
#define MAXD 1e16
using namespace std;
char s[N];
db v[N];
int siz[N],ch[N][2];
void upd(int u){siz[u]=siz[ch[u][0]]+siz[ch[u][1]]+1;}
int ton[N],td;
void dfs(int u)
{
	if(!u) return;
	dfs(ch[u][0]),ton[++td]=u,dfs(ch[u][1]);
	ch[u][0]=ch[u][1]=0;
}
void bld(int &u,int l,int r,db lf=0,db rf=MAXD)
{
	if(l>r) return;
	int mid=(l+r)>>1;
	u=ton[mid];v[u]=(lf+rf)/2;
	bld(ch[u][0],l,mid-1,lf,v[u]),bld(ch[u][1],mid+1,r,v[u],rf);upd(u);
}
void reb(int &u,db lf,db rf)
{
	if(max(siz[ch[u][0]],siz[ch[u][1]])<=siz[u]*alp) return;
	td=0;dfs(u);
	bld(u,1,td,lf,rf);
}
int cmp(int x,int y){return s[x]==s[y]?v[x-1]<v[y-1]:s[x]<s[y];}
void insert(int &u,int k,db lf=0,db rf=MAXD)
{
	if(!u){siz[u=k]=1;v[u]=(lf+rf)/2;ch[u][0]=ch[u][1]=0;return ;}
	if(cmp(k,u)) insert(ch[u][0],k,lf,v[u]);
	else insert(ch[u][1],k,v[u],rf);
	upd(u);reb(u,lf,rf);
}
int val[N<<2];
int p[N];
void update(int u){val[u]=p[val[u<<1]]==p[val[u<<1|1]]?min(val[u<<1],val[u<<1|1]):(v[p[val[u<<1]]]<v[p[val[u<<1|1]]]?val[u<<1]:val[u<<1|1]);}
void build(int u,int l,int r)
{
	if(l==r){val[u]=l;return;}
	int mid=(l+r)>>1;
	build(u<<1,l,mid),build(u<<1|1,mid+1,r);
	update(u);
}
void change(int u,int l,int r,int k)
{
	if(l==r) return;
	int mid=(l+r)>>1;
	if(k<=mid) change(u<<1,l,mid,k);
	else change(u<<1|1,mid+1,r,k);
	update(u);
}
int qry(int u,int l,int r,int L,int R)
{
	if(L<=l && r<=R) return val[u];
	int mid=(l+r)>>1,w1=0,w2=0;
	if(L<=mid) w1=qry(u<<1,l,mid,L,R);
	if(R>mid) w2=qry(u<<1|1,mid+1,r,L,R);
	if(!w1 || !w2) return w1+w2;
	return p[w1]==p[w2]?min(w1,w2):(v[p[w1]]<v[p[w2]]?w1:w2);
}
char opt[3];
int main()
{
	int n,m,l,T,rt=0;
	scanf("%d%d%d%d",&n,&m,&l,&T);
	scanf("%s",s+1);
	reverse(s+1,s+l+1);
	for(int i=1;i<=l;i++) insert(rt,i);
	for(int i=1;i<=n;i++) scanf("%d",&p[i]);
	build(1,1,n);
	int las=0;
	for(int i=1;i<=m;i++)
	{
		int v,w;
		scanf("%s%d",opt+1,&v);
		if(opt[1]=='I')
		{
			v^=las*T;
			s[++l]='a'+v;
			insert(rt,l);
		}
		else if(opt[1]=='C')
		{
			scanf("%d",&w);
			p[v]=w;
			change(1,1,n,v);
		}
		else
		{
			scanf("%d",&w);
			printf("%d\n",las=qry(1,1,n,v,w));
		}
	}
	return 0;
}

3.SA-IS

这里

4.回文树

这玩意似乎比后缀自动机什么的性质好很多。

几个显然的性质:

  1. 一个回文串的回文前缀同时也是它的回文后缀。
  2. 一个长度大于 \(2\) 回文串同时删去首字符和尾字符后仍然是一个回文串。

类比后缀树,我们现在想要得到一颗树来表示字符串 \(s\),每个节点 \(u\) 表示互不相同的字符串 \(S_u\),字符串的每个回文子串都被恰好一个 \(S_u\) 表示。并且使得每条连接 \((u,v)\) 树边 \(\texttt{c}\) 满足 \(S_v=\texttt{c}S_u\texttt{c}\)

当然奇回文串和偶回文串不能互相到达,所以这里其实有两颗树。特别的奇根表示的字符串长度为 \(-1\)。由于每个节点表示的字符串两两不同,所以回文树的节点数等于当前字符串的本质不同回文子串数量

接下来我们假设这个回文树最后一次插入后位于 \(u\),想要扩展一个字符 \(\texttt{c}\)。可以发现,\(u\) 存的其实是当前字符串的最长回文后缀。

我们意外发现这个操作和 \(\text{KMP}\) 中求 \(\text{next}\) 数组很类似。不过一个是向后匹配,一个是向前匹配。

这启发我们:不断找到 \(S_u\) 的最长回文后缀 \(S_v\),直到 \(S_v\) 上一位字符为 \(\texttt{c}\)。如果 \(v\) 不存在一条 \(\texttt{c}\) 的树边链接的点 \(x\),我们就新建一个点 \(x\),令 \(S_x=\texttt{c}S_v\texttt{c}\)

接下来我们需要证明不存在一个新的回文子串没有被表示。考虑使用反证法,假设存在一个后缀 \(S'\) 没有被表示:

  1. \(|S'|<|\texttt{c}S_v\texttt{c}|\) ,由于新增的回文子串必然是原字符串的后缀,所以有 \(S'\)\(\texttt{c}S_v\texttt{c}\) 的后缀。由性质 \(1\) 可知 \(S'\) 必然也是 \(\texttt{c}S_v\texttt{c}\) 的前缀。由于\(|S'|\neq|\texttt{c}S_v\texttt{c}|\)\(S'\) 必然是 \(\texttt{c}S_v\) 的前缀,所以 \(S'\) 并不是一个新的回文子串,矛盾。
  2. \(|S'|>|\texttt{c}S_v\texttt{c}|\),由于 \(|S'|\) 首尾字母都是 \(\texttt{c}\),那么存在一个 \(S_p\) 满足 \(|S_p|>|S_v|\)\(S'=\texttt{c}S_p\texttt{c}\)。那么 \(S_v\) 不是 \(S_u\) 的最长回文后缀,矛盾。

由此也可以知道一个字符串 \(S\) 的本质不同回文串数量 \(\leq|S|\)

那么我们怎么求出 \(S_u\) 的最长回文后缀呢?我们考虑在加入 \(u\) 的时候就处理出最长回文后缀 \(P_u\)

我们假设加入 \(u\) 时的字符串为 \(\texttt{c}S_v\texttt{c}\)。如果存在最长回文后缀,那么其首字母必然也是 \(\texttt{c}\)。那么我们仿照上面的思路,不断跳 \(S_v\) 的最长回文后缀直到其上一位字符为 \(\texttt{c}\)。那么这个就是最长回文后缀。

那么为什么它的复杂度是正确的?它的瓶颈在于跳最长回文后缀,而这个步骤本质和 \(\texttt{KMP}\) 的步骤是基本相同的。一个感性的证明是,每跳一次最长回文后缀,后续的所有插入操作的长度至少 \(-1\),而每加入一个字符,后续插入的操作长度至多 \(+1\)。故总复杂度仍是 \(O(n)\)

代码:

int get_nxt(int u){while(s[n-len[u]-1]!=s[n]) u=nxt[u];return u;}
int insert(int c)
{
	int u=get_nxt(las);
	if(!ch[u][c])
	{
		nxt[++tot]=ch[get_nxt(nxt[u])][c];
		len[tot]=len[u]+2;
		ch[u][c]=tot;
	}
	return ch[u][c];
}

拓展1:广义回文树

后缀自动机可以扩展到 \(\text{Trie}\) 上,那回文树是不是也可以?

然而遗憾的是,直接扩展回文树的复杂度是错误的,复杂度是 \(O(\sum{|s_i|})\),即所有字符串长度之和。不过虽然不能处理 \(\text{Trie}\) 树的问题,但是单纯的多串问题,复杂度仍然是对的。具体的做法基本同广义后缀自动机。

但是参考\(\text{AC}\)自动机的思路,我们能否直接处理出每个回文串最长的回文后缀 \(link[c]\) 使得其前面的字符为 \(c\)。答案是可行的,不过这样时间复杂度就变为 \(O(n\Sigma)\)

同样,这样处理的回文树也是支持后删。

拓展2:双向回文树

考虑回文树如何支持前加。首先当然要记录最长回文前缀对应的位置。

可以发现,由于回文串的回文前缀等于回文后缀,所以型如 \(\texttt{c}S_v\) 的后缀一定对应型如 \(S_v\texttt{c}\) 的前缀,可以直接用拓展 1 中的 \(link[c]\)。甚至更新都与拓展 1 中的部分几乎一样。

特别的,对于前加字符时,如果最后得到的最长回文前缀是整个串,那么需要同时更新最长回文后缀对应的位置。对于后加时同理。

复杂度仍是 \(O(n\Sigma)\)

5.Lyndon&Runs

由于篇幅过长,移至 Lyndon Word&Runs 学习笔记

6.KMP自动机与 border tree

咕咕咕。

7.隐式后缀树

暂时移到这里,密码可以猜一猜或者线下问我。

posted @ 2020-09-30 12:59  Flying2018  阅读(1705)  评论(0编辑  收藏  举报