字符串学习笔记


Post time: 2021-02-02 18:36:35

一切字符串算法的本质都是有效利用失配信息进行匹配或查询!

一、Manacher算法

最长回文子串。暴力是枚举中间点然后左右依次查询,Manacher算法通过之前查询过的中间点来更新后面的。

首先,因为回文串有长度奇偶的区别,所以通过在两两字符之间加 '#' 来化为同种问题。

设之前查到的中间点中匹配的最靠后的为 \(id\),这个最靠后的位置为 \(mx\)。设每个点为中心点的最长回文子串长度为 \(l_i\),则:

\(i,那么我们可以通过 \(i\) 关于 \(id\) 对称的镜像点来给 \(i\) 赋值,即

\[l_i=\min(mx-i,l_{id\times2-i}) \]

\(\min\) 的原因是只能判断在 \(id\) 已经配完的内部可以,外边不一定可以。

否则,则 \(l_i=1\)

这样赋完初值之后再向外拓展可以最终得到 \(O(n)\) 复杂度求解最长回文子串。

模板题

点击查看代码
#include
#include
#include
#define rint register int
using namespace std;
const int N=1.1e7+13;
char s[N<<1],t[N];
int n,l[N<<1];
int manacher(){
	rint mx=0,id=0,ans=0;
	for(rint i=1;i<=n;++i){
		if(imx) mx=i+l[i],id=i; 
		ans=max(ans,l[i]);
	}
	return ans-1;
}
int main(){
	scanf("%s",t+1);n=strlen(t+1);
	s[0]=s[1]='#';
	for(rint i=1;i<=n;++i) s[i*2]=t[i],s[i*2+1]='#';
	n=n*2+2;s[n]=0;
	printf("%d\n",manacher());
	return 0;
}

二、最小表示法

判断两个字符串(可旋转)是否相等。暴力是取出所有可能得到的串,取最小串,看看是否相等。这启发我们可以利用失配信息求最小串:

字符串 \(s\)\(i\) 指针;字符串 \(t\)\(j\) 指针;两个字符串目前已经匹配的长度设为 \(k\)

\(s_{i+k}=t_{j+k}\),则直接 \(++k\)

\(s_{i+k}>t_{j+k}\),那么 \(i...i+k\) 这些都一定不是最小串的开头,直接把 \(i\) 改为 \(i+k+1\)。另一种同理。

模板题中要求一个串的轮换同构串中字典序最小的,直接以此串中两个不同开头的位置做最小表示法,即可最终求得最小串。注意此时两个指针一定不能相等,要特判。

模板题

点击查看代码
#include
#include
using namespace std;
const int N=3e5+13;
int n,s[N<<1];
inline int minshow(){
	int i=1,j=2,k=0;
	while(i<=n&&j<=n&&ks[j+k]) i+=k+1;
			else j+=k+1;
			if(i==j) ++i;
			k=0;
		}
	}
	return min(i,j);
}
int main(){
	scanf("%d",&n);
	for(int i=1;i<=n;++i){
		scanf("%d",&s[i]);
		s[i+n]=s[i];
	}
	int t=minshow();
	for(int i=0;i

三、KMP字符串匹配

匹配两个字符串,暴力就是两个指针 \(i,j\),从头开始匹配,如果不成功再从头开始,这样复杂度 \(O(nm)\)

如何利用失配信息?首先我们想,当 \(s_i\neq t_j\) 的时候,如果我们固定 \(i\) 单调不减,那么 \(j\) 应该减少的越少越好,因为 \(j\) 减少的越少,相当于此时已经匹配的字符数就越多。这启发我们可以先预处理一部分信息,然后每次失配之后 \(j\) 都跳到这样一个位置上继续匹配。

仔细思考一下这个位置需要满足什么性质。显然,如果设这个位置为 \(k\),那么相当于要找到一个最大的 \(k\) 使其满足 \(t_{1...k}=t_{j-k+1...j}\)

首先假设我们对于每个 \(i\) 都找到了上述这样一个位置 \(nxt_i\),那么就可以使用一开始说的方法来计算。注意到我们的匹配次数此时是线性的,复杂度O(m)。

考虑如何预处理。可以发现,预处理实际上就是自己匹配自己的过程,两个指针均指向同一字符串,首先定义 \(nxt_1=0\),接下来通过同样的方式做即可,每个位置的 \(nxt\) 值就是当 \(i\) 停留在这个位置时最终的 \(j\) 值。

模板题

点击查看代码
#include
#include
#include
using namespace std;
const int N=1e6+13;
char s[N],t[N];
int n,m,nxt[N];
inline void init(){
	nxt[1]=0;
	for(int i=2,j=0;i<=n;++i){
		while(j&&t[j+1]!=t[i]) j=nxt[j];
		if(t[j+1]==t[i]) ++j;
		nxt[i]=j;
	}
}
inline void KMP(){
	for(int i=1,j=0;i<=m;++i){
		while(j&&t[j+1]!=s[i]) j=nxt[j];
		if(t[j+1]==s[i]) ++j;
		if(j==n) printf("%d\n",i-n+1),j=nxt[j];
	}
}
int main(){
	scanf("%s",s+1);m=strlen(s+1);
	scanf("%s",t+1);n=strlen(t+1);
	init();
	KMP();
	for(int i=1;i<=n;++i) printf("%d ",nxt[i]);
	return 0;
} 

四、AC自动机

AC自动机就是在Trie树上对多个子串跑KMP。先建立一个Trie树,然后再Trie树上通过BFS建立fail(相当于KMP中next指针),匹配的具体流程基本等同于KMP。另外,如果一个单词走到了最后一个字母,那么不管是否匹配成功都应该回到它的fail指针。

模板题

点击查看代码
#include
#include
#include
#include
using namespace std;
const int N=1e6+13;
struct Aho_Corasick_Automaton{
	#define ACA Aho_Corasick_Automaton
	int ch[N][30],fail[N],val[N],cnt;
	ACA(){cnt=0;}
	inline void ins(char *s){
		int len=strlen(s),now=0;
		for(int i=0;iq;fail[0]=0;
		for(int c=0;c<26;++c){
			int u=ch[0][c];
			if(u) fail[ch[0][c]]=0,q.push(ch[0][c]);
		}
		while(!q.empty()){
			int u=q.front();q.pop();
			for(int c=0;c<26;++c){
				if(ch[u][c]) fail[ch[u][c]]=ch[fail[u]][c],q.push(ch[u][c]);
				else ch[u][c]=ch[fail[u]][c];
			}
		}
	}
	inline int query(char *s){
		int n=strlen(s),now=0,res=0;
		for(int i=0;i

五、扩展 KMP

扩展 KMP 可以求出一个子串 \(T\) 对一个子串 \(S\) 的每一个后缀的最长公共前缀。这个算法之所以被称为 \(exKMP\),是因为其与 \(KMP\) 有一些共同特性。

\(extend[i]\) 表示 \(T\)\(S[i...n]\) 的最长公共前缀的长度。假设此时我们已经匹配完了 \(extend[1...i-1]\),此时正在匹配 \(i\)。设之前匹配中能够匹配到 \(S\) 串中最远位置的位置为 \(l\),这个最远的位置为 \(r\)。那么此时有:

\[S[l...r]=T[1...r-l+1] \]

\[S[i...r]=T[i-l+1...r-l+1] \]

此时求 \(extend[i]\) 即相当于求 \(T[i-l+1...n]\)\(T\) 的最长公共前缀。假设我们已经求出了一个数组 \(nxt\)\(nxt[i]\) 表示 \(T[i...n]\)\(T\) 的最长公共前缀长度,那么在这里相当于是求的 \(nxt[i-l+1]\)。设这个值为 \(tmp\),那么如果 \(i+tmp<=r\) 则证明这个 \(tmp\) 可以取到,否则我们就把 \(ext[i]\) 调到 \(r\) 这个位置,然后继续向后匹配即可。复杂度 \(O(m)\)

另外,求 \(nxt\) 数组的过程相当于是自己对自己进行一次以上操作,所以复杂度 \(O(n)\)。这就是 \(exKMP\)\(KMP\) 最大的相似之处。

模板题

点击查看代码
#include
#include
#include
using namespace std;
const int N=2e7+13;
char s[N],t[N];
int n,m,nxt[N],ext[N];
inline void init(){
	nxt[1]=n;
	for(int i=2,l=0,r=0;i<=n;++i){
		int tmp=nxt[i-l+1];
		if(i<=r){
			if(i+tmp<=r) nxt[i]=tmp;
			else nxt[i]=r-i+1;	
		}
		while(i+nxt[i]<=n&&t[i+nxt[i]]==t[1+nxt[i]]) ++nxt[i];
		if(i+nxt[i]-1>r) r=i+nxt[i]-1,l=i;
	}
}
inline void exKMP(){
	for(int i=1,l=0,r=0;i<=m;++i){
		int tmp=nxt[i-l+1];
		if(i<=r){
			if(i+tmp<=r) ext[i]=tmp;
			else ext[i]=r-i+1;	
		}
		while(i+ext[i]<=m&&1+ext[i]<=n&&s[i+ext[i]]==t[1+ext[i]]) ++ext[i];
		if(i+ext[i]-1>r) r=i+ext[i]-1,l=i;
	}
}
inline void file(){
	freopen("P5410_1.in","r",stdin);
	freopen("P5410.out","w",stdout);
}
int main(){
	//file();
	scanf("%s%s",s+1,t+1);
	m=strlen(s+1),n=strlen(t+1);
	init();
	exKMP();
	long long ans1=0,ans2=0;
	for(int i=1;i<=n;++i) ans1^=1ll*i*(nxt[i]+1);
	for(int i=1;i<=m;++i) ans2^=1ll*i*(ext[i]+1);
	printf("%lld\n%lld\n",ans1,ans2);
	return 0;
}

六、子序列自动机

\(q\) 次询问一个数组 \(S\) 是不是 \(T\) 的子序列。

考虑先对 \(T\) 中出现的值维护一个下标集合,每次进来 \(S\) 之后从前往后扫,在当前位二分搜索当前值在 \(T\) 中的下标集合中第一个大于当前位置的位置,把指针移过去。一直这样做看看能否移到最后即可。

模板题

点击查看代码
#include
#include
#include
#include
inline int rd(){
	int res=0;char c=getchar();
	for(;!isdigit(c);c=getchar());
	for(;isdigit(c);c=getchar())res=(res<<1)+(res<<3)+(c-'0');
	return res;
}
const int N=1e5+13,M=1e6+13;
int n,q,m,b[N];
std::vector pos[N];
int main(){
	int useless=rd();n=rd(),q=rd(),m=rd();
	for(int i=1;i<=n;++i) pos[rd()].push_back(i);
	while(q--){
		int len=rd();
		for(int i=1;i<=len;++i) b[i]=rd();
		if(len>n){puts("No");continue;}
		bool flag=1;
		for(int i=1,j=0;i<=len;++i){
			if(pos[b[i]].empty()){flag=0;break;}
			std::vector::iterator now=std::upper_bound(pos[b[i]].begin(),pos[b[i]].end(),j);
			if(now==pos[b[i]].end()){flag=0;break;}
			j=*now;
		}
		puts(flag?"Yes":"No");
	}
	return 0;
}

七、后缀自动机

后缀自动机(SAM)是一个有限状态自动机,表示为一个有向图,分两部分:DAWG 和 parent 树。后缀自动机的定义是接受且仅接受串 \(S\) 的所有子串,最小化节点个数。

DAWG 是一个 DAG。每个节点表示一个或多个 \(S\) 的子串。起始节点对应 \(\varnothing\)。每条转移边都只代表一个字符。从起始节点开始的每一条路径都唯一对应 \(S\) 的某个子串(或者说,某些本质相同的子串)。每个节点代表的字符串是某些前缀长度连续的后缀,每个点维护三个信息:\(\min_u,\max_u\) 分别表示最小和最大长度的串,\(\mathrm{end}_ u\) 表示这个节点表示的前缀集合。

定理 1 任意两个点的 \(\mathrm{end}\) 集合互不相同。

证明:相同的话直接合并即可。

parent 树是一棵树。\(u\) 的 parent 指针指向 \(v\) 当且仅当 \(|\min_u|=|\max_v|+1\),且 \(v\) 代表的子串均为 \(u\) 代表的子串的后缀,记作 \(\mathrm{next}_ u=v\)。所有节点作为以起始节点的为根的树,所以称为 parent 树。

定理 2 \(\mathrm{end}_ u\subsetneqq \mathrm{end}_ {\mathrm{next_ u}}\)

这个很显然吧。真包含而不是包含是因为定理 1。

SAM 的构建:增量法。考虑在已经建出的 \(S\) 的 SAM 上扩展出 \(S+c\) 的 SAM。考虑下图:

\(start\) 节点就是 SAM 的初始节点。\(v_1,v_2,\ldots,v_m\) 指的是 \(S\) 所有后缀在的点,首先一定存在一个 \(v_1\) 没有 \(c\) 的转移边,我们设 \(v_1\sim v_2\) 这一段的点都没有 \(c\) 的转移边,\(v_3\sim v_6\) 是有转移边。接下来考虑三种情况:

Case 1:没有 \(v_3\)

\(start=v_2\),扩展出来的 \(u\) 点的 parent 应该为 \(start\)

Case 2:\(\max_{d}=\max_{v3}+1\)

这个就正常建,然后把 \(u\) 的 parent 设成 \(d\)

Case 3: \(\max_d\not= \max_{v3}+1\)

也就是说,本来是这样:

然后,\(v3+c\)\(x1+c,x2+c\)\(\mathrm{end}\) 集合出现了变动,这时候我们需要把 \(d\) 裂成两个点 \(v,dd\),其中 \(v3\to v\) 表示 \(\mathrm{end}\) 集合发生变动的那些后缀,\(dd\) 是剩下的那些,然后就变成了这样:

my SAM code:

点击查看代码
inline int newpos(std::array nson,int nlen){return ++ptot,len[ptot]=nlen,swap(son[ptot],nson),ptot;}
inline void insert(int c){
	int p=lastpos;int u=newpos(boom,len[p]+1);cnt[u]=1;
	while(p&&!son[p][c]) son[p][c]=u,p=nxt[p];
	if(!p) return lastpos=u,nxt[u]=1,void();
	int d=son[p][c];
	if(len[d]==len[p]+1) nxt[u]=d;
	else{
		int v=newpos(son[d],len[p]+1);
		nxt[v]=nxt[d],nxt[d]=v,nxt[u]=v;
		while(p&&son[p][c]==d) son[p][c]=v,p=nxt[p];
	}
	lastpos=u;
}