NOI 一轮复习 II:字符串
NOI 一轮复习 II:字符串
知识清单:
- 概念与定义
- Trie 和有限状态自动机
- 字符串周期结构
- 字符串子串结构
- 回文串结构
- Lyndon 理论
1. 概念与定义
在本文中我们默认字符集记为
对于字符串
- 定义空串是长度为
0 的串,记为\epsilon ; - 两个串相等,当且仅当它们长度相等且对应位置字符相等;
- 定义字符串的拼接操作:
R=S+T 是一个长度为|S|+|T| 的串,R_i=S_i\ (i\in [1,|S|]) ,R_{i+|S|}=T_i\ (i\in [1,|T|]) ,在无歧义的情况下也记为R=ST (S,T 也可以为单独字符),拼接操作具有结合律而没有交换律; - 可以发现,空串是满足对于任意串
S 都有\epsilon+S=S 的串,同时满足这一条件的串也只有空串; - 定义串
T 是串S 的子序列,当且仅当存在一组1\leq a_1<a_2<\ldots<a_m\leq |S| ,使得T=S_{a_1}S_{a_2}\ldots S_{a_m} ; - 定义串
T 是串S 的反串,当且仅当|T|=|S| ,且T_i=S_{|S|-i+1} ,记为T=S^R ; - 定义串
S 是回文串,当且仅当S=S^R ,将其最中间的字符(或最中间两个字符之间的位置)称为回文中心; - 定义串
T 是串S 的前缀,当且仅当|T|\leq |S| 且T_i=S_i ,记为T=S[1\ldots |T|] ,无歧义时也记为Pre(|T|) ,不等于原串的前缀称为真前缀; - 定义串
T 是串S 的后缀,当且仅当T^R 是S^R 的前缀,记为T=S[|S|-|T|+1\ldots |S|] ,无歧义时也记为Suf(|T|) ,不等于原串的后缀称为真后缀; - 定义串
T 是串S 的子串,当且仅当存在a,b 使得S[1\ldots a]+T+S[b\ldots |S|]=S ,记为T=S[a+1\ldots b-1] ,特别地,当b=a+1 时有S[i\ldots i-1] 为空串,而如果l>r+1 ,则S[l\ldots r] 没有意义,不等于原串的子串称为真子串; - 对于串
S,T ,定义T<S 或T 的字典序比S 小,当且仅当T 是S 的真前缀或存在i\leq \min(|T|,|S|) 使得\forall j<i,\ T_j=S_j ,而T_i<S_i 。
3. 字符串周期结构
本节前进行如下补充定义:
- 定义正整数
p 是串S 的周期,当且仅当p\leq |S| 且S_i=S_{i+p}\ (i\in [1,|S|-p]) ,如果p 还整除|S| 则称其为整周期; - 定义串
T 是串S 的 Border,当且仅当T 是S 的前缀且T 是S 的后缀,但T\ne S ,为了方便也称|T| 是S 的 Border;
每个字符串
单串 Border 结构
关于周期和 Border 有如下浅显结论:
结论:
证明:
接下来我们尝试分析一个串所有周期(或 Border)之间的关系和结构。
弱周期引理(Weak Periodicity Lemma):若
证明:不妨设
对于
对于
因此
而
然而
周期引理(Periodicity Lemma):若
证明:参考叉姐的知乎文章 https://zhuanlan.zhihu.com/p/85169630,将其翻译和理解了一下。
这可能不是一种直观证明,但是我并不会直观的证明。
在这里为了方便,设
我们设
根据它们的定义形式,它们应当是周期的,符号化:
其中
下面考虑求:
注意第二步转化是提取了
熟知地,
同理后一项,那么设括号里的整体为:
那么
由于
那么,根据裴蜀定理,设
根据弱周期引理,对于任意长度不超过串长一半的周期
短周期结构:字符串
根据 Border 与周期的关系,同时有:
长 Border 结构:字符串
接下来考察
首先我们有一个浅显的结论,若
设
接下来设最长的小于
以此递推,
字符串的周期/Border 结构:字符串
例题
1 :[WC 2016] 论战捆竹竿给定字符串
S ,问S 的所有周期的线性组合(系数为正整数)可以表示出多少个[1,w] 内的数。
将
考虑同余最短路,设此等差数列首项为
考虑优化转移,根据这种特殊连边规律,整个图将分为
再解决多个等差数列并的问题,其实我们只需要实现换模数即可,设原来的模数为
求所有 Border 需要用到下面介绍的 KMP 算法或其他方法。
时间复杂度为
前缀 Border 结构
接下来讨论串
令
命题:
证明:
因此我们只需要求出所有
接下来介绍的 KMP 算法可以在线性时间内求出所有
考察
也就是说,
由此,KMP 算法过程如下:
- 顺序扫描
i\in [1,n] ,维护B_{i} ; - 当
i 变为i+1 时,先令j=B_i ,随后只要j>0 且S_{j+1}\ne S_{i+1} ,就令j\leftarrow B_j ; - 若
j>0 或j=0 但S_1=S_{i+1} ,则B_{i+1}=j+1 ,否则B_{i+1}=0 。
第二步即从大到小枚举
但是这样的复杂度是正确的,考虑
事实上,根据前面的命题,我们就可以归纳出
定义:建立一棵
那么,一个前缀
例题
2 :[NOI 2014] 动物园给定字符串
S ,你需要对于所有S[1\ldots i] 求出其长度不超过\dfrac{i}{2} 的 Border 数量。
构建出
考虑模仿 KMP 算法的过程,我们发现仍然可以用一个变量
时间复杂度为
Aho-Corasick 自动机
下面我们考虑用失配树来解决字符串匹配问题。
在一般的字符串匹配问题中,我们给定若干个串
先讨论最简单的情形,即
一对一匹配:给定模式
实际上,这个问题和求前缀 Border 问题几乎是相同的,下面给出一种比较直观的理解:
考虑将
因此,我们直接复用 KMP 算法就可以解决这个问题,实现上并不用把它们连接起来,只需要基本仿照原先的 KMP 过程,首先预处理
接下来我们考虑这种做法的本质:不论是在求前缀 Border 时“自匹配”,还是模式串与文本串的一对一匹配,中间的一个核心过程都是根据当前指针和下一个“目标字符”(
令当前指针为
设字符串为
命题:
证明:只要考虑我们在 KMP 过程中的操作即可,要么直接从
又
对
如果将
下面我们将一对一的匹配问题扩展:
多对一匹配:给定
我们将上面的理论进行扩展,具体地,首先建立出
令字典树上以
容易解决的是:
同样思考 KMP 算法的过程,对于
由此构建出的自动机被称为 Aho-Corasick 自动机,简称 AC 自动机,通常我们不强调其终点集合,而主要强调其结构。
下面整理一下 AC 自动机的生成过程:
- 首先建立一棵包含所有模式串的字典树
TR ; - 对
TR 从根开始进行一次 BFS,在一个点处枚举所有后继字符,按照上面的规则计算t(x,c) ,对于其子结点,按照上面的规则计算失配指针。 - 最终我们计算出所有点的失配指针和转移,AC 自动机建立完毕。
此过程复杂度为
注意:AC 自动机中包含了两棵树,一棵是 Trie 树,还有一棵是失配树,它们是不同的。
接下来使用 AC 自动机解决多对一匹配:
建立模式串的 AC 自动机,从起始点开始依次按照文本串中字符进行转移,每转移一个字符,就给当前到达的结点的标记增加
最后我们对失配树进行一次 DFS,统计子树和,就得到了每个模式串的出现次数。
匹配过程的复杂度是
由此,多对多匹配问题也可以解决了,只要对每个文本串都进行一次上述匹配过程,最后 DFS 统计即可。
整个算法的复杂度是
现在看上去比较碍眼的就只有乘的那个
想要解决这个问题,我们需要用某种数据结构维护
如果采用这个优化,整个多对多匹配的复杂度变为
例题
3 :[NOI 2011] 阿狸的打字机给定一个包含字符和操作
\text{B,P} 的长为n 的操作序列,用于生成一个 Trie,顺序扫描这个序列,维护一个当前串,遇到字符就加到当前串末尾,遇到\text{P} 就将当前串加入 Trie,遇到\text{B} 就将当前串末尾字符删除。接下来有
m 次询问,每次给定x,y ,问第x 个加入 Trie 的串在第y 个中作为子串出现多少次。
时刻维护当前串在 Trie 上的位置,每次要么移动到儿子,要么移动到父亲,要么保持不变,因此 Trie 的总点数和建 Trie 复杂度都是
然后建立 AC 自动机,接下来考虑询问的实质:
询问第
而
这是个数据结构问题,具体不多赘述,我们离线询问,对 Trie 进行 DFS,可以以总共
时间复杂度为
例题
4 :[POI 2000] 病毒给定字符串集
\{S_1,\ldots,S_n\} ,求是否对于任意正整数m 都存在长度为m 的串T ,使得任何S_i 都不是它的子串。
首先构建
我们可以将未打上标记的点和它们之间的边拿出来,进行一次缩点,如果从起点出发能够到达的是一个 DAG,那么不存在这样的路径,否则只要走到一个大小不小于
时间复杂度为
这题的题解区流行一种 DFS 的方法,其中一部分是一个点只访问一次的,另一部分是每次都搜的,对于前者我不清楚其正确性,对于后者我不清楚其复杂度,所以这里没有采用 DFS 方法。
4. 字符串子串结构
下面我们讨论一个字符串
通常来说,刻画
在本节中,我们将详细介绍三者的原理和三种独特的构造方法(不会介绍后缀数组的单独线性构造),并且指出它们之间的内在联系。
其中最容易理解的是后缀树,下面就从后缀树开始说起。
后缀树的构建
对于串
我们将后缀字典树上所有不对应
现在我们考虑将这个后缀字典树进行如下压缩:
- 原本后缀字典树中每条边上带有一个字符,压缩后每条边上带有一个字符串;
- 压缩过程:每次找出一个二度点,将它的父亲与儿子连接,边上的字符串是它的父亲到它的字符串拼上它到它儿子的字符串,然后删除这个二度点。
将这样得到的树称为后缀树(Suffix Tree,ST),树上的一个点称为一个等价类或
-
称一个串
T 属于一个等价类s ,当且仅当从后缀树的根结点出发,每次走T 的一个前缀对应的边,最终走到了s 父亲到s 之间的边上的某个位置。 -
等价类
s 的代表元是属于s 的所有串中最长者,事实上这也就是等价类对应的那个后缀树上的点在原后缀字典树上对应的子串。
后缀树的另一种理解:
- 将后缀字典树上所有
S 后缀对应的点称为关键点,后缀树是关键点连同根结点的虚树。
根据构造过程,我们可以得到关于后缀树的如下性质:
性质
证明:每个子串肯定都对应后缀字典树上一个点,而压缩操作只是将所有二度点压到了边上,所以每个子串还是恰好在一条边上;而属于一个等价类的必定也是后缀字典树对应的一个点,即子串。
性质
证明:根据定义,
性质
证明:假设不是,那么在后缀字典树上,这个结点应该是在插入某个后缀时的中间结点,下面必然有子结点。
性质
证明:除根结点和对应后缀的点外,其他点至少有两个儿子,因此其数量小于非空后缀数量,再加上非空后缀和结点,就是
形如
接下来我们来讨论等价类的性质。
定义:一个串
那么等价类就满足:
定理:两个子串属于同一等价类,当且仅当它们的
证明:从后缀字典树的角度考虑,
继续从虚树的角度考虑问题,两个串属于同一等价类,等价于它们对应的后缀字典树的结点被缩到了后缀树上的同一条边,由于后缀树保留了所有后缀,这也代表了它们子树中的后缀集合相同,即
反之亦然,如果两个子串属于不同等价类,那么它们对应的后缀树上的点要么是祖先关系,要么是平行关系,那么对应的
有了这一视角,下面再给出一些关于后缀树和等价类的性质:
性质
证明:上面定理的证明过程已经说明了这一点。
性质
证明:这些子串构成一个等价类,等价类对应了后缀字典树上一段祖先链,自然满足这些条件。
有了上面这些基础,我们现在可以用后缀树的视角来归纳字符串的子串结构:
字符串子串结构:字符串
因此,从逻辑上,后缀树上的祖先关系指的其实是两个等价类对应
上面我们讨论了一个串子串的情况,这里也讨论一下多串情况。
给定字符串集合
接下来我们介绍如何构建一个串的后缀树。
下面介绍的 Ukkonen 算法可以在线性时间内构造给定字符串的后缀树。
Ukkonen 算法是从前往后增量构造当前前缀的后缀树局部。
这里有必要阐明所谓“后缀树局部”和后缀树的差别,在后缀树中我们保留的所有后缀对应的点,即使它们只有一个儿子,但是在这里的后缀树局部中,我们不保留这些点(因为它们后续可能就不是后缀了)。
考虑在最后加入一个字符产生的影响:
- 每一个原先的后缀都会在后面新增上这个字符(注意包括空后缀)。
而原先的后缀在后缀树上有两种表现形式:叶子和非叶子。
对于叶子,可以在它的父亲到它的边上直接追加这个新字符,我们称此为自然延续。
而对于非叶子,由于它可能并没有被剥离出一个单独的结点,所以我们无法延续,那么如果一定要构造出这个后缀,就需要拆点。
如图,原先一条边上字符串为
那么,哪些后缀是无法自然延续的呢?事实上,设当前插入了前缀
更本质地说,这个长为
在 Ukkonen 算法的过程中,我们始终维护当前的这个
- 对于当前的
l ,如果l 对应的后缀树上的位置存在下一个字符为c 的转移,那么加c 之后的后缀还是在之前出现过,所以l 保持不变,同时我们也无从拆点(例如上图中原本的\text{abc} 边加上了一个后缀\ldots \text{abc} ,那么无法拆出一条新边和一个新点); - 如果不存在下一个字符为
c 的转移,那么我们就如上图进行拆点,同时令l 减小1 (因为当前的l 已经成为叶子了)。
直接暴力进行这个过程,总复杂度是
对于后缀树上结点
同时我们可以说明,若
那么,我们维护当前的最长隐后缀时,如果执行一次拆点后就要令隐后缀的长度减小
这里我们用一张图直观理解这一过程:图中实线为树边,虚线为一条后缀链接,蓝色箭头指向当前最长隐后缀,当我们加入字符
因此,只要我们能合理维护后缀链接和最长隐后缀位置,就能快速跳转到拆点后的下一个最长隐后缀位置,也就可以快速完成后缀树的构建。
而这个最长隐后缀位置只需要用二元组
剩下的一步是我们需要维护所有非叶结点的后缀链接,对于拆出来的点(例如上图中非叶红点),我们只需要将其后缀链接设置为:下一个拆出来的点(如果存在,如上图中
因此,优化完的 Ukkonen 算法描述如下:
- 从前往后依次插入
S 的每个字符; - 维护当前最长隐后缀
(p,l) ,当前字符插入完成后使其自动增长(l+1\rightarrow l ,若超过这条边长则调整p ),同时所有叶结点也自动增长(实现时只需将其长度设为+\infty 表示可以无限延申; - 插入字符时,尝试在当前边上拆点(如果根本不存在这条边即
l=0 且不存在以插入字符为首字符的边,那么直接新建一个结点),如果成功拆点则跳转到后缀链接(如果p 不是根)或长度减小1 (如果p 是根),如果不成功(即上面所说的边上有一个相同字符导致无法拆点)则最长隐后缀维持,当前字符插入完成,无论哪种情况,都按照上面的规则维护新拆点的后缀链接。 - 为了最后使所有后缀都不是隐后缀,最后插入终止字符
\Delta 。
可能更详细:EternalAlexander https://www.luogu.com.cn/blog/EternalAlexander/xuan-ku-hou-zhui-shu-mo-shu
Ukkonen 算法的时间复杂度为
如果我们将后缀树重新“展开”成后缀字典树,那么之前建立的所有后缀链接是什么呢?其实就是所有后缀形成的 AC 自动机的失配指针,对比可以知道它们的定义是一致的,这也揭示了后缀数据结构和 AC 自动机之间的联系。
关于后缀树的更多性质,我们在介绍完另两种后缀数据结构后再统一分析。
后缀数组的构建
取字符串
一般,我们还会同时求出
后缀数组只保留的后缀树的一部分信息,因为建立后缀树存在线性做法,因此通常而言后缀树是比后缀数组更有力的工具。
下面介绍后缀数组的构建算法,这里介绍两种比较简单的方法。
-
字符串哈希法:
直接使用一个 sort 函数,将 cmp 定义为比较两后缀的字典序大小,而这一点我们可以利用字符串哈希在
O(\log |S|) 的时间内完成。总复杂度为
O(|S|\log^2|S|) 。 -
倍增法:
我们要求的是后缀的排名,不妨先做一部分:维护出所有
S[i,\min(|S|,i+2^b-1)] 间的排名(也就是维护每个后缀的一个长度相等的前缀的字典序排名),然后从b 向b+1 扩展,直至b>\log |S| ,就完成了后缀的排序。如何在线性复杂度内进行一次倍增呢?
如果
S[i,i+2^{b+1}-1]< S[j,j+2^{b+1}-1] ,则或者有S[i,i+2^b-1]<S[j,j+2^b-1] ,或者有S[i,i+2^b-1]=S[j,j+2^b-1] 并且S[i+2^b,i+2^{b+1}-1]<S[j+2^b,j+2^{b+1}-1] 。所以我们只需要以
S[i,i+2^b-1] 的相对大小为第一关键字,S[i+2^b,i+2^{b+1}-1] 的相对大小为第二关键字进行基数排序,这样复杂度是O(|S|) 的,并且我们完成了一轮倍增。总复杂度为
O(|S|\log |S|) 。
现在我们得到了后缀数组
一种方法是仍然借助字符串哈希,我们可以简单地求出任何两个后缀的 LCP。
然而还存在一种线性做法,考虑
性质:
证明:通俗地说,就是要证
这一点容易证明,设
如果
如果
如果
上面的证明有些边界没有详细讨论,但仔细推敲可以发现不影响结论成立。
根据这一性质,我们依次确定
后缀数组也存在直接的线性构建方法,例如 SA-IS,DC3 等,本文不涉及。
后缀自动机的构建
建议先学习后缀树,否则可能看不懂。
后缀自动机(Suffix Automaton,SAM)是接受字符串
在这里限于作者能力,我并不能证明下面构造出的是最小的,本文中默认下面所描述的就是后缀自动机。
后缀自动机有一个起始状态
- 对于一个状态,
s 到它的所有路径构成了一个S 子串的\text{Endpos} 等价类。
注意这一句话描述的是一个很强的性质:它还隐含了
根据之前后缀树的性质
结论:每个
设这个等价类为
类似于后缀树,我们定义后缀链接或
- 对于等价类
Sta ,定义L(Sta) 的长度为mn(Sta)-1 的后缀对应的等价类为link(Sta) 。
也即是:
不难证明
根据和后缀树中相同的规律,我们知道后缀链接将所有等价类连成一棵树。
但我们要的不是这个树,而是自动机结构,下面令
讲了这么多前置的定义,下面该开始介绍后缀自动机的构建方法了,这里介绍的是最常见的 Blumer 算法:
- 采用增量构造,每次向当前字符串
S 末尾加入新字符c ,令S 对应的等价类为las ; - 由于
S+c 一定产生了一个新等价类(\{|S|+1\} ),所以新建一个结点,称为cur ; - 对于
las 中的某个串T ,它是S 的后缀,并且T+c 不在S 中,然而现在末尾新增后T+c 就会出现在cur 状态,所以令f(las,c)=cur ; - 上面考虑了
S 的长度为[mn(las),len(las)] 的后缀,下面考虑长度<mn(las) 的后缀,即跳到link(las) 处,如果当前link(las) 仍不存在字符c 的转移,那么同理令f(link(las),c)=cur ; - 如果
las 一直跳到s 都能进行上述转移,那么c 是个之前从未出现过的字符,直接令link(las)=s 即可。 - 以此类推,直到
las 的某个祖先p 存在字符c 的转移,令q=f(p,c) : - 第一种情况,
q 代表的正好是p 后加上一个c 得到的所有串,那么我们直接令link(las)=q 即可,这一点类似 AC 自动机中的行为——fail(x)=f(fail(fa_x),c_x) 。 - 否则,例如
S=\text{ABC} ,c=B ,那么此时的p 代表空串,而L(q)=\text{AB} ,并不是由p 后直接加\text{B} 形成的,这时它就不是las 的后缀了,所以las 的后缀链接要专门指向一个新点cp ,并且L(cp)=\text{B} ,可以将cp 看作q 的一份“复制”,除了缩短长度之外都和q 一致,由于它实际上是q 的后缀,所以要令link(q)=cp ,而link(cp) 是原来的q :本质上,这是将p\to q 这个转移中间拆成了两个不同类的转移p\to cp 和link(q)=cp 。 - 此时,类似于插入
c 字符,对于p 的所有满足f(p',c)=q 的祖先p' ,由于cp 是一个夹在p' 和q 之间的串,所以要将f(p',c) 改成cp 。
与广义后缀树类似,可以建立多个串的广义后缀自动机,接受它们的子串的并。
广义后缀自动机的建立:
- 先将所有串插入一棵 Trie,对 Trie 进行 DFS,类似 Blumer 算法插入每个点,以其父亲对应的点为
las 插入即可。
Blumer 算法的复杂度证明:
可以用一个 map 维护 SAM 中的边,复杂度可优化至
联系与区别
后缀树是对一个字符串所有子串结构的一个简洁的概括。
后缀树的结构是自然的,因为所有子串的
- 后缀数组与后缀树的关系:后缀数组中的后缀排列顺序是按照后缀树从小字符到大字符的 DFS 序得到的,后缀树上的一个子树中的后缀对应了后缀数组的一个区间。
- 后缀自动机与后缀树的关系:后缀自动机构造过程中的
link 构成的树就是反串的后缀树结构,根据定义不难发现这一点。 - 后缀自动机与后缀树的区别:后缀自动机是一个 DAG,而后缀树中不保存这一结构。
在本文中,我们将后缀自动机中的 DAG 结构称为自动机结构,将其
接下来的结论揭示了后缀自动机和 AC 自动机的联系:
- 后缀自动机可看作所有后缀构成的 AC 自动机的压缩形式,其中自动机结构对应 AC 自动机中的 Trie 树,而树结构对应 AC 自动机中的失配树,但我们一般建成的后缀自动机并没有添加除 Trie 边以外的边(在 BFS 过程中加的那些边)。
根据二者的定义不难说明这一点:AC 自动机的自动机结构接受所有子串,即所有后缀的前缀(如 Trie 树中接受所有其中串的前缀),而树结构对应的是当前点的最长后缀(如失配树的定义),唯一不同的是,失配树中的二度点被压缩,因此 Trie 树也被压成了 DAG 的样子,而不再是一棵树。
因此我们可以利用类似 AC 自动机的方法用后缀自动机解决多串匹配问题。
接下来的过程中,我们主要使用后缀自动机和 Blumer 算法解决问题,因为:
- 后缀树可以通过建立反串 SAM 的 Blumer 算法顺便求出,Blumer 算法相对较好记忆;
- 后缀数组的所有信息都包含于后缀树。
常见维护技巧
在此之前,我们再次回顾自动机结构和树结构的性质:
- 自动机结构:从起始点(称为
1 )出发的所有路径和字符串S 的所有本质不同子串一一对应,1 到一个定点的路径对应着一些互有后缀关系且长度连续的字符串集合。 - 树结构:以
1 为根的有根树,互为祖先后代关系的点对\text{Endpos} 集合为包含关系,其他点对\text{Endpos} 集合为不交关系,父结点对应的最长字符串是子结点对应的最短字符串的最长真后缀,一个串的所有后缀是一条从某边端点或中间开始的祖先链。
子串出现次数查询:求给定字符串
答案即为每个结点的
因此首先将所有后缀对应点的答案加上
时间复杂度为
例题
6 :求给定字符串
S 的本质不同子串个数。
- 自动机结构上的解:即求自动机结构的从
1 出发的路径数量,统计 DAG 上的路径数量容易使用拓扑排序解决。 - 树结构上的解:一个结点对应的不同子串的长度构成连续段,因此其数量就是该结点与其父结点
len 的差,求和即可。
时间复杂度为
扩展:求每个前缀的本质不同子串个数。
解法:Blumer 算法每次插入一个字符后将答案加上当前最后一个点和其父亲的
例题
7 :[TJOI 2015] 弦论求给定字符串
S 的字典序第k 小子串,分别讨论位置不同的相同子串算作多个和一个的情况。
-
自动机结构上的解:
若位置不同的相同子串只算一次,即求
1 出发的字典序第k 小路径,反向拓扑排序计算出每个点出发的路径数量,并从1 开始贪心即可。若位置不同的相同子串算多次,那么我们给每个结点赋权为其对应字符串出现次数,将以其结尾的路径权值定义为其点权,再执行上面的算法即可。
-
树结构上的解:
由于字典序从前比较,所以要求反串的后缀自动机的树结构,即原串后缀树。
同样从根出发枚举儿子,贪心选取可以选的最小儿子,根据位置不同的相同子串算一个还是多个讨论子树大小的计算方法,和上面类似。
时间复杂度为
子串定位:
首先找到
由于
时间复杂度为
子串匹配:给定
令
考虑递推计算
后缀自动机可以看成由所有后缀建成的 AC 自动机,所以我们按照 AC 自动机的方法进行匹配,并针对后缀自动机的特点稍作修改:
- 计算
pos(r) 和len(r) 时,首先令cur=pos(r-1) 和curlen=len(r-1) ; - 只要
cur 不为1 且不存在T_r 这条出边,就跳转到lk(cur) 处(即失配指针处),并将curlen 设置为新的len(cur) ; - 如果
cur 有了T_r 这条出边,就沿着这条边走一步,并curlen\leftarrow curlen+1 ; -
注意,我们维护
时间复杂度为
扩展:队列匹配,支持在
解法:如果在开头删除字符,类似构建后缀树的 Ukkonen 算法过程,只需要将当前的
例题
8 :求
m 个给定字符串S_1,\ldots,S_m 的最长公共子串。
不妨设
对
则
设
在 Blumer 算法每次插入一个字符后当前终点的
此外,每个结点的
可以采用线段树合并来维护,DFS 树结构时将父结点的线段树并上儿子的线段树,注意使用不销毁点写法的线段树合并。
然而维护出了
区间子“SAM”:
对于给定字符串
我们尝试通过
-
自动机结构:
对于每个原串后缀自动机中的转移
u\to v ,设某个字符串在自动机上运行时走到u 时长度为len ,下面我们要解决的是:在只考虑S[l\ldots r] 的情况下,在当前长度为len 时u\to v 的这条转移是否可用。为此,我们需要在原自动机上通过线段树合并求出每个点的
\text{Endpos} 集合,然后考虑v 的\text{Endpos} 集合,我们实际上要判断对应点v 的长度为len+1 的串是否是S[l\ldots r] 的子串,那么我们找出v 的\text{Endpos} 集合中不超过r 的最大元素,设为w ,显然长度为len+1 的串是S[l\ldots r] 的子串就等价于它以w 结尾出现了一次,只需核验w-l+1\ge len+1 即可,如果满足则可以转移,否则不能转移。 -
树结构:
有了上面得到的一个子串
与此同时,与
部分 SAM:
给定字符串
处理方法和上面是类似的,令
例题
9 :[NOI 2018] 你的名字给定串
S ,下有q 次询问,每次给定S 的子串S[l\ldots r] 和另一个字符串T ,求T 有多少本质不同子串不是S[l\ldots r] 的子串。
显然对于
如何求出
最后要求
实现时,并不需要维护
时间复杂度为
扫描线+LCT 维护区间子串信息:
此处以求多次询问区间本质不同子串为例,介绍一类使用扫描线+LCT 维护区间子串信息的方法。
设给定的区间为
考虑询问离线后按照
对于某一个点,设其
因此一种暴力的思路是:访问
然而此做法复杂度错误,要得到一种复杂度正确的算法,我们需要借助 LCT 的分析方法:
- 用一个 LCT 维护当前 SAM 的树结构,使得每条实链上的点对应的
rb 都相同,这样,修改时我们只需对p 进行一次 Access,这样就可以处理p 到根的所有实链,先除掉它们的所有贡献,再建立p 到根的整条实链,其rb 值为r ,进行一次区间加即可。
由于所有操作都是 LCT 上的自然操作,根据 LCT 的理论可以直接说明复杂度是正确的。
时间复杂度为
例题
10 :[集训队作业 2018] 后缀树节点数给定字符串
S ,m 次询问,每次给定区间[l,r] ,求S[l\ldots r] 的后缀树结点数。
赞歌。
感觉我出的力脑和这题解法可能有一点关系。
其他技巧
这里主要介绍一些后缀自动机的外沿内容和后缀数组的一些技巧。
广义 SAM 出现子串查询:对于
方法和线段树合并维护
例题
11 :[CF 666 E] Forensic Examination给定字符串
S 和m 个字符串T_1,\ldots,T_m ,有q 个询问,每次询问S[pl\ldots pr] 在T_l,\ldots,T_r 中哪个字符串里出现次数最多,如果最大值不唯一则取最靠前的。
首先建出
- 求出
S[pl\ldots pr] 在 SAM 上的位置p ,这可以通过之前介绍的子串定位完成; - 求出点
p 在[l,r] 中哪个T_i 出现次数最多,这可以用线段树合并维护每个点在每个串的出现次数,然后查询区间最大值得到。
令
两个后缀的最长公共前缀(LCP):
建立反串的后缀自动机(或原串后缀树),根据树结构的意义可知两个后缀的最长公共前缀对应的点是这两个后缀对应的点的 LCA。
而此问题在后缀数组上也有一种相应的做法。
考虑后缀数组中
从这个角度理解,后缀树和后缀数组的关系有点类似于笛卡尔树和数列的关系(但并不是)。
所以,许多类后缀树上数据结构合并的问题都可以通过后缀数组
例题
12 :[NOI 2015] 品酒大会给定字符串
S ,每个后缀有整数权值a_i ,对于每个i\in [0,|S|-1] 输出有多少对后缀的 LCP 长度不小于i ,并输出这些后缀对中权值之积的最大值。
最经典的一道题目。
将后缀数组中
可以使用类似链表的结构或线段树维护连续段,时间复杂度为
后缀平衡树:
要求维护一个初始为空的字符串,支持
对于前端插入并维护后缀结构,我们通常有两种维护思路:
-
建立反串的后缀自动机,在 Blumer 算法的实现中采用 LCT 维护树结构,即可支持动态的加边和一些查询。
不过本题要查询后缀排名,对于 SAM 来说不太友好。
-
采用后缀平衡树。
后缀平衡树是将后缀数组的信息放到一棵重量平衡树上维护,支持插入一个新的后缀(即前端增加一个字符)。
首先,我们需要维护当前所有后缀的“绝对排名”:用一个实数
那么如何维护这个绝对排名呢?考虑到我们是在平衡树上,让平衡树的中序遍历为
随后,插入时我们按照和平衡树中插入相同的方法,从根开始向下走,每次比较插入后缀和当前点后缀的大小,具体地:
- 设插入字符为
c ,当前点后缀的首字符为d ,如果c\ne d 则已经比较完成; - 如果
c=d ,那么去掉首字符后比较的是两个既有后缀的大小,直接根据f 值即可O(1) 比较。
插入的过程中可以顺便求出前驱后继,也就可以求出这个点的
由于比较两个现有后缀大小是
而如果朴素地采用哈希实现后缀数组,也可以利用一些数据结构完成前端插入,但复杂度通常无可避免地达到
你已经学会基本的几种字符串数据结构动态维护技巧了,下面来做几道例题试看看吧!
例题
13-1 :(题目来源:我的口胡)String Master L
维护一个初始为空的字符串
S ,支持n 次操作,操作为:
- 末尾插入;
- 给定字符串
T 查询其在当前S 中的出现次数。
解法一:我会 AC 自动机。
离线后建出所有模式串的 ACAM,然后用最终的
解法二:我会后缀自动机。
建出最终
解法三:我会动态后缀结构。
采用 SAM+LCT 或者后缀平衡树的技术维护带插入的字符串
这些做法中,大部分时间复杂度都是单
例题
13-2 :(题目来源:我的口胡)String Master XL
维护一个初始为空的字符串
S ,支持n 次操作,操作为:
- 末尾插入;
- 末尾删除;
- 给定字符串
T 查询其在当前S 中的出现次数。
解法一:我会 AC 自动机。
类似上题,不过在维护时间时带上一点技巧(将删除也看做时间轴上的一点,用栈维护
解法二:我会后缀自动机。
后缀自动机的构造复杂度证明是均摊的,反复插入删除可以让它爆炸。
噔噔咚(未知有无可行办法)。
解法三:我会后缀平衡树。
如果用后缀平衡树,和上题基本没什么区别。
例题
13-3 :(题目来源:我的口胡)String Master XXL
维护一个初始为空的字符串
S ,支持n 次操作,操作为:
- 末尾插入;
- 首端插入;
- 给定字符串
T 查询其在当前S 中的出现次数。
这部分和字符串倒关系不大,主要是一个数据结构上的技巧。
将首端插入的字符串和末尾插入的字符串看成两个独立的串
对于询问,分成三部分:
例题
13-4 :(题目来源:我的口胡)String Master XXXL
维护一个初始为空的字符串
S ,支持n 次操作,操作为:
- 末尾插入;
- 末尾删除;
- 首端插入;
- 首端删除;
- 给定字符串
T 查询其在当前S 中的出现次数。强制在线。
关于这题:去年寒假的时候出完这题觉得自己很 nb,本来想出到公开赛里的,然后问了下发现很久以前有个差不多的题,被爆了。
由于强制在线,所以之前的 AC 自动机解法是比较难了。
解一:正统的动态后缀数据结构:
将
由于这时候有删除了,所以其实前后分别是个栈,采用两个动态后缀结构分别维护,对于跨过分界点的贡献用 KMP 暴力计算。
看上去很好,但这个做法有漏洞:如果其中一个栈被删完了,但还要继续从这端删除,怎么办呢?
答案是:当一个栈被清空时,就重构整个结构,将另一个栈中的元素平分后再作为新的两个栈。
复杂度简单理解一下:每个元素除了第一次插入外每次被重构到,一定有等量的元素被删除,所以每个字符被重构的次数和应当是和操作数线性的。
如果采用后缀平衡树,复杂度可能达到
不过实情是我自己也没写过这题。
解二:哈希文艺复兴:
我们就完全用比较无脑暴力的哈希和 KMP 来解决问题!
然后想想什么东西比较贴合暴力算法的需要,那就是根号分治:
-
如果
|T|\leq B ,那么我们可以用一个\text{set} 维护S 的所有长度不超过B 的子串哈希值,然后直接查询T 的哈希值出现了几次。当然,这部分的
\text{set} 再用另一个哈希表代替也是可以的。总复杂度为
O(nB\log n) 或O(nB) 。 -
如果
|T|>B ,那么这样的T 就不超过\dfrac{n}{B} 个,所以暴力和S 做一次 KMP。总复杂度为
O(\dfrac{n^2}{B}) 。
然后平摊一下二者复杂度,可以得到
5. 回文串结构
这里讨论的是一个回文串或一个串的所有回文子串的结构。
回文串有许多优美的性质,下面以一个经典算法为例:
例题
14 :给定字符串
S ,求出以所有字符(包括相邻字符之间位置)为回文中心的最长回文子串。
这题的经典解法是 Manacher 算法。
首先,我们在
用
当前计算
- 若
ans_x\ge mid+ans_{mid} ,那么将ans_x 初始化为0 ,否则将其初始化为ans_{2\times mid-x} ; - 此后尝试扩展
ans_x :即若S_{x+ans_x}=S_{x-ans_x} 则令ans_x\leftarrow ans_x+1 。
考虑上述过程的意义:
也就是说,如果
类似地,在第二步中被扩展一次后由于
这里运用到了回文串的对称性。
类似地,我们可以得到一个回文子串的基本性质:
性质:长度为
证明:
考虑以
如果以
因此以每个
下面介绍的回文树描述了一个串的所有本质不同回文子串之间的关系。
回文树
定义:将一个回文串从回文中心开始的后缀称为其半串。
将字符串
这样,长度为
可以看出,回文树可以看成是所有本质不同回文子串的半串构成的 Trie,但对奇数和偶数分开了(事实上可以看作在奇数或偶数部分的开头多加了一位标识符,把两个根统一,但我们下面不采用这个意义)。
有了 Trie 我们自然思考构建 AC 自动机,我们可以直接建出 AC 自动机,但因为有了两个根,我们作如下初始化,先变成一棵树:
将奇根的所有儿子初始化为偶根,偶根的失配指针指向奇根,同时我们将奇根指向偶根的边也当成空边处理(即遇到新字符时就将这条边指向一个新结点)。
如此建出的 AC 自动机上,每个点的失配指针的半串是它对应的半串的在树上的最长真后缀,将半串转化为回文串,就会得到:
性质:回文串的 Border 是回文串,且一个点对应的回文子串的最长回文 Border 是它在回文树上的失配指针对应的回文串。
回文树因为有了 AC 自动机的结构,所以也称为回文自动机(PAM)。
那么,我们如何快速构建回文自动机呢?
回文自动机的构建
这里介绍一种常用的在线构造失配指针的构建方法。
记当前字符串的最长回文后缀对应的点为
- 通过跳
las 的失配指针,找到las 对应的串的一个最长 BorderS[A-len+1\ldots A] ,使得S_{A-len}=c ,这样我们就找到了以S_{A+1}=c 结尾的最长回文串S[A-len\ldots A+1] (最长性可以用反证法证明); - 如果这个串已经出现过,那这一轮插入已经结束了(因为这意味着
S_{A+1} 结尾的所有回文子串都已经在前面出现过); - 否则,我们加入这个点,然后计算它的失配指针,利用和第一步相似的方法,我们从当前点的父亲开始不断跳失配指针,直到再找到一个
S_{A-len}=c 的点,其出边c 对应的点就是当前加入点的失配指针。
时间复杂度为
常见维护技巧
由于 PAM 和 SAM 本质上都可看作 ACAM,所以这里列举的一些应用也是相似的。
回文子串出现次数:统计
类似后缀自动机地,我们只需要在每个前缀的最长回文后缀处将答案增加
类似后缀自动机地,在失配树上线段树合并。
回文子串匹配:给定
类似后缀自动机地,在当前点能走对应出边时就走一步,否则就跳失配指针。
例题
15 :给定
m 个字符串S_1,\ldots,S_m ,求最长公共回文子串。
不妨设
分别将
匹配复杂度为
6. Lyndon 理论
此部分将不会在 NOI2022 前更新。