专栏文章

字符串全家桶

个人记录参与者 1已保存评论 0

文章操作

快速查看文章及其快照的属性,并进行相关操作。

当前评论
0 条
当前快照
1 份
快照标识符
@miql940x
此快照首次捕获于
2025/12/04 06:38
3 个月前
此快照最后确认于
2025/12/04 06:38
3 个月前
查看原文

AC 自动机

解决多串匹配问题。具体地,所有查询串构成一棵 Trie 树,对于每一个节点维护 fail 指针,指向这个节点最长的在Trie树中出现的真后缀
暴力的构建方式是简单的。设 uu 通过字符 cc 指向 ppt(u,c)=pt(u,c)=p),现要得出 fail(p)fail(p)。若 t(fail(u),c)t(fail(u),c) 存在,fail(p)fail(p) 设为 t(fail(u),c)t(fail(u),c)。否则查看 t(fail(fail(u)),c)t(fail(fail(u)),c)t(fail(fail(fail(u))),c)t(fail(fail(fail(u))),c),直到找到一个存在的,或全部不存在,设为根节点。
但是这样看起来复杂度就不对。而且匹配也很难。考虑魔改 Trie 树成为字典图:将一条 t(u,c)t(u,c) 定义为在状态 uu 后添加一字符 cc 后在原 Trie 树上到达的状态。
建字典图和 Fail 指针可以揉在一起做:若 t(u,c)t(u,c) 在 Trie 上存在,则 t(u,c)t(u,c) 照样连。否则 t(u,c)t(fail(u),c)t(u,c)\leftarrow t(fail(u),c)。无论如何 fail(t(u,c))t(fail(u),c)fail(t(u,c))\leftarrow t(fail(u),c)。bfs 维护即可。
fail 指针显然同样构成一棵树,一个节点通过 fail 的到根链即为其所有重要的后缀。而通过 Trie 的到根链是前缀,所以一个节点通过走这两棵树可以到达的节点就是其所有重要的子串。

SA

绷,半年前学的东西现在忘得一干二净了。

saisa_i 表示字符串 SS 中排名为 ii 的后缀是哪个(记录其起始位置),rkirk_i 表示后缀 S[i,n]S[i,n] 的排名。核心思路是,如果我们有 SS 所有长度为 ww 的子串的大小顺序,通过一次双关键字排序即可得到所有长度为 2w2w 的子串大小顺序。操作直到 w>=nw>=n 即可。这里双关键字的值域是 nn 所以可以用计数排序削掉一个 log\log
下面逐行分析代码在干什么。(字符串以下标1开头)
CPP
int sa[N],rk[2*N],tmp[N],buc[N];
void Sort(int n,int w){
	int ptr=0;
	for(int i=n;i>n-w;i--) tmp[++ptr]=i;
	for(int i=1;i<=n;i++) if(sa[i]-w>0) tmp[++ptr]=sa[i]-w;
	for(int i=1;i<=n;i++) buc[i]=0;
	for(int i=1;i<=n;i++) buc[rk[i]]++;
	for(int i=1;i<=n;i++) buc[i]+=buc[i-1];
	for(int i=n;i>=1;i--) sa[buc[rk[tmp[i]]]--]=tmp[i];
}
void SA(string s){
	int n=s.length()-1;
	for(int i=1;i<=n;i++) buc[s[i]]++,tmp[s[i]]=1;
	for(int i=1;i<=256;i++) buc[i]+=buc[i-1],tmp[i]+=tmp[i-1];
	for(int i=n;i>=1;i--) sa[buc[s[i]]--]=i,rk[i]=tmp[s[i]];
	for(int w=1;w<=n;w*=2){
		Sort(n,w);
		int ptr=0;
		for(int i=1;i<=n;i++){
			if(rk[sa[i]]!=rk[sa[i-1]]||rk[sa[i]+w]!=rk[sa[i-1]+w]) ptr++;
			tmp[sa[i]]=ptr;
		}
		for(int i=1;i<=n;i++) rk[i]=tmp[i];
		if(ptr==n) break;
	}
}
注:在代码中,对于在当前长度下相等的子串,其 rk[i] 相等(即 rk[i] 是在去重意义下的),但 sa[i] 直接代表某个有序排列,两两不等。对于两个相等的子串,其 sa[] 的大小关系没有保证。

SA() 的初始化部分:获得长度为 1 意义下的 sa[]rk[]
第二行 buc[s[i]]++,tmp[s[i]]=1;buc[c] 记录 s[i]==c 的个数,tmp[c] 记录存不存在 s[i]==c
第三行 buc[i]+=buc[i-1],tmp[i]+=tmp[i-1];:做前缀和,此时 buc[c] 记录 s[i]<=c 的数量,可以理解为所有 s[i]==ci 在排列中构成一个“块”,buc[c] 是这个块的末尾。 tmp[c] 记录 c 的去重排名。
第四行 sa[buc[s[i]]--]=i,rk[i]=tmp[s[i]];:前半句找到 s[i] 所在的块,将 s[i] 放在这个块的末尾,然后将末尾前移,使同一块内的元素从后往前填满整个块。后半句就是记录 s[i] 的排名。
Sort(n,w):当前的 sa[]rk[] 均是在长度为 w 意义下,将 sa[] 更新到 2w 意义下,没管 rk[]
Sort 中,tmp[] 存储子串的临时顺序,和 sa[] 类似。第一轮中,tmp[] 存储 i 按照第二关键字 rk[i+w] 的顺序。
第二行 将 i+w>ni 放在最开头。它们的 rk[i+w]=0
第三行 if(sa[i]-w>0) tmp[++ptr]=sa[i]-w;:升序遍历 rk[i+w],挨个放入 tmp[] 中。升序的方式是依据 rk[sa[i]] 对于 i 升序。我们需要加入的第二关键字是 rk[w+1] ~ rk[n],我们只要这部分。
第五行 buc[rk[i]]++;SA() 第三行,维护“块”的末尾,只不过关键字变成了 rk
第六行倒序处理 sa[buc[rk[tmp[i]]]--]=tmp[i];:按照第二关键字(tmp[])降序的顺序,找到每个元素对应的“块”,从后往前填充。“块”的顺序保证第一关键字升序,遍历顺序保证块内第二关键字升序。
SA() 的倍增部分:根据 sa[] 更新出 rk[],临时放在 tmp[] 里。tmp[sa[i]] 应该是单调不降,相等当且仅当 sa[i-1]sa[i] 的两个关键字都相等。这是简单的。
优化 if(ptr==n) break; 第一关键字互不相同,后面的没必要做了。

heightheight 数组:heighti=lcp(S[sai1,n],S[sai,n])height_i=\operatorname{lcp}(S[sa_{i-1},n],S[sa_i,n])。即字典序相邻的两个后缀的最长公共前缀。
引理:heightrki+1heightrki1height_{rk_{i+1}}\geq height_{rk_i}-1。简证一下。
sarki+11=a,sarki1=bsa_{rk_{i+1}-1}=a,sa_{rk_i-1}=b,那么上面的话翻译一下就是 lcp(S[a,n],S[i+1,n])lcp(S[b,n],S[i,n])1\operatorname{lcp}(S[a,n],S[i+1,n])\geq \operatorname{lcp}(S[b,n],S[i,n])-1a=b+1a'=b+1 时等号成立,而真正的 aa 只优不劣。
求两子串 lcp\operatorname{lcp}lcp(S[sai,n],S[saj,n])=mink=i+1j(heightk)\operatorname{lcp}(S[sa_i,n],S[sa_j,n])=\min_{k=i+1}^j(height_k)。把后缀画成 Trie\mathrm {Trie} 看看就明白了。可以使用 ST 表 O(1)O(1) 求右边的东西。

可能还有一个比较有意义的 trick 是:在字符串上每 xx 个位置放一个关键点,每一个长度为 xx 的子串会覆盖恰好一个关键点。

exKMP

终究还是学了这个玩意。感觉这个东西用处真不大,之前需要用 exKMP 的题多少都可以用其他算法做。
S[i]S[i] 表示 SS 的第 ii 位,S[l,r]S[l,r] 表示 SS 的第 ll 位到第 rr 位组成的子串。下标以 00 为起始。
ziz_ilcp(S,S[i,n])|\operatorname{lcp}(S,S[i,n])|,即 SS 和自己的后缀的 lcp 长度。特别地z0=0z_0=0这显然可以使用 SA 的 height 数组做到单 log 皆大欢喜
考虑像 manacher 一样递推求解。记当前找到的右端点最大且是 SS 前缀的子串为 S[p,q]S[p,q]。换句话说,设当前要计算 ziz_i,则已经有了 z0,z1,zi1z_0,z_1,\cdots z_{i-1}。则 q=maxj<i(j+zj1)q=\max_{j<i}(j+z_j-1)ppqq 取到最值时对应的 jj
如果 ipi\leq p,其落在 [p,q][p,q] 里。根据 [p,q][p,q] 的性质,S[0,l]=S[p,q]S[0,l]=S[p,q],其中 l=qp+1l=q-p+1。设 [0,l][0,l]ii 对应的位置为 j=ipj=i-p。分讨 j+zjj+z_j 有没有捅穿 [0,l][0,l]
因为 [0,l][0,l][p,q][p,q] 是一样的,所以如果 j+zjj+z_j 没捅穿 [0,l][0,l]i+zii+z_i 也捅不穿 [p,q][p,q],且 ziz_i 应该正好是 zjz_j
如果捅穿了,则 ziz_i 至少是 pi+1p-i+1。暴力往后找就行。
i>pi> p 和捅穿了没本质区别。都是暴力找。

又给了一个 TT,问对于 SS 的所有后缀,其和 TT 的 lcp。
可以照葫芦画瓢,记 extiext_ilcp(T,S[i,n])\operatorname{lcp}(T,S[i,n])p,qp,q 的定义简单地把上面的 zz 替换为 extext
如果 ipi\leq p 并且没捅穿,同样镜像一下就好,exti=zjext_i=z_j
否则暴力找。

文字列が嫌い

fi,jf_{i,j} 表示考虑前 ii 个串,现在拼成的串长度为 jj 最优的字符串。转移显然:fi,jfi1,jfi,jfi,jleni+sif_{i,j}\leftarrow f_{i-1,j},f_{i,j}\leftarrow f_{i,j-len_i}+s_i++ 是字符串拼接。
转移数 O(nk)O(nk),字符串比较 O(k)O(k),这是 O(nk2)O(nk^2) 的,考虑优化。
如果两个串 fi,j1,fi,j2,(j1,j2)f_{i,j_1},f_{i,j_2},\quad(j_1,j_2) 最终都可以拼到长度恰好为 nn(即状态 (i,j1)(i,j_1)(i,j2)(i,j_2) 均“合法”),考虑它们满足什么关系。
如果不管在 fi,j1f_{i,j_1} 的后面添加什么东西,其字典序都劣于 fi,j2f_{i,j_2},则 fi,j1f_{i,j_1} 是完全没用的。如果其不管添加什么东西字典序都更优,fi,j2f_{i,j_2} 是没用的。
换句话说,定义一个字符串 SS 强优于/强劣于另一个字符串 TT ,当且仅当存在某一位 ppS[p]<T[p]S[p]<T[p] / S[p]<T[p]S[p]<T[p],且 p<p,S[p]=T[p]\forall p'<p,S[p']=T[p']。则对于某一个 ii,其所有有用的 fi,jf_{i,j} 分不出强优劣关系,即全部为某个串的前缀。
我们维护这个串,再维护每一个有用的 jj,至少空间看起来能对。再看看现在怎么转移。
i=k1i=k-1 的“某个串”为 TTi=ki=k 的“某个串”为 TT',初始 =T=T。从小到大枚举 k1k-1 处理出来的所有有用的 jj
如果发现 T[0,jleni]+SiT[0,j-len_i]+S_i 强劣于 TT',什么都不做。
如果发现 T[0,jleni]+SiT[0,j-len_i]+S_i 强优于 TT',或者 TT' 是其前缀,转移并更新 TT'
如果发现 T[0,jleni]+SiT[0,j-len_i]+S_iTT' 的前缀,转移但不更新 TT'
然后你需要做的就是比较两个字符串的优劣。换句话说就是你需要求 LCP。
考虑 TT' 一定是 TT 的前缀拼上一个 SS,实际上你只需要求 TT 的后缀和 SS 的 LCP,与 SS 和自己的 LCPLCP 便可以 O(1)O(1) 分讨完成优劣比较。显然这是 exKMP,然后你就可以 O(nk)O(nk) 了。
细节上还有处理一个状态是不是合法的,这个也可以 O(nk)O(nk)

SAM

SAM 是一个接受给定串所有子串的最小 DFA。从形式上来说,是一个有唯一起点的 DAG,每一条转移边上标有字符,表示从当前状态后拼接特定字符后会到达哪里。
设原串为 SS,对于一个 SS 的子串 TTendpos(T)\operatorname{endpos}(T) 为字符串 TTSS 中匹配到的所有位置的右端点的集合。例如,当 S=ababS=\mathtt{abab} 时,endpos(ab)={2,4}\operatorname{endpos}(\mathtt{ab})=\{2,4\}。SAM 的节点和不同的 endpos\operatorname{endpos} 构成双射关系。
容易证明:
  • endpos\operatorname{endpos} 相同的 TT 互为后缀,且长度恰为一个区间。
  • 不同的 endpos\operatorname{endpos} 要么互相包含,要么无交。
  • 所有的 endpos\operatorname{endpos} 的包含关系构成一棵树。
  • 对于一个子串,不断删掉它的最前一个字符,endpos\operatorname{endpos} 在树上会不断跳父亲直到根。
对于 SAM 的一个节点 pp,除了转移边我们还维护两个值(为方便叙述,下将 pp 对应的 endpos\operatorname{endpos} 集合也称为 endpos(p)\operatorname{endpos}(p)
  • len(p)\operatorname{len}(p) 表示对于所有 endpos(T)\operatorname{endpos}(T) 恰为 endpos(p)\operatorname{endpos}(p)TT 中,最长的长度。
  • link(p)\operatorname{link}(p) 表示所有 endpos(q)endpos(p)\operatorname{endpos}(q)\supset\operatorname{endpos}(p)qq 中,endpos(q)|\operatorname{endpos}(q)| 最小的那个。即 endpos\operatorname{endpos} 构成的树上的父亲。
下给出增量构造 SAM 的做法,即给定 SS 的 SAM,构造 S=S+cS'=S+c 的 SAM。其中 cc 是一字符。记 SAM 上一条边为 pcδ(p,c)p\stackrel{c}{\longrightarrow} \delta(p,c)SS 本身在旧 SAM 上到达的位置为 lstlst
  1. SS' 必到达一个全新的节点,故直接新建节点 pp 表示 SS' 到达的节点,len(S)len(lst)+1\operatorname{len}(S')\gets\operatorname{len}(lst)+1
  2. δ(lst,c)\delta(lst,c) 不存在,则 lstlst 上的所有子串都没有后接过 cc,直接将 δ(lst,c)\delta(lst,c) 定为 pp 即可。然后处理 SS 的其它后缀后接 cc 的情况。具体地,直接让 lstlink(lst)lst'\gets\operatorname{link}(lst),然后重复此步骤即可。
  3. 现在我们不需要建新边了,而是用剩下的东西更新 link(p)\operatorname{link}(p)。设现在 lstlstttδ(t,c)\delta(t,c)qq,分讨:
    1. tt 不存在。之前都没有拼上一个 cc 的情况,直接 link(p)root\operatorname{link}(p)\gets root 即可。
    2. tt 存在,且 len(t)+1=len(q)\operatorname{len}(t)+1=\operatorname{len}(q)。此时,qq 上的子串恰好就是 tt 上的所有子串拼上一个 cc,直接用 qq 更新,link(p)q\operatorname{link}(p)\gets q 即可。
    3. tt 存在,且 len(t)+1<len(q)\operatorname{len}(t)+1<\operatorname{len}(q)。此时 qq 维护的子串中有一些过于长,并不是 SS' 的后缀。我们需要分裂出去一些。 设 qq' 为分裂出去的节点。它应该分走长度 qq 中长度较小的部分。即,len(q)len(t)+1\operatorname{len}(q')\gets \operatorname{len}(t)+1link(q)link(q)\operatorname{link}(q')\gets \operatorname{link}(q),然后 link(q)q\operatorname{link}(q)\gets q'。这样便分出来了恰好是 SS' 的后缀的部分,直接让 link(p)q\operatorname{link}(p)\gets q' 即可。 原先的一些连边也需要修改。δ(t,c),δ(link(t),c),δ(link2(t),c),\delta(t,c),\delta(\operatorname{link}(t),c),\delta(\operatorname{link}^2(t),c),\dotsc 均需要改为 qq'
  4. 最后 lstplst\gets p 即可。

评论

0 条评论,欢迎与作者交流。

正在加载评论...