字符串总结
感谢 DeepSeek 对本文写作的大力支持。
字符串
字符串是一串顺序排列的字符。(废话)
以下是一些有关字符串的定义:
- 字符集:字符串中所有可能出现的字符集合,记为
。 - 下标(index):位于字符串第
个位置的字符下标为 (本文采用 1-index)。 - 子串(substring):对于任意
取出下标范围为 的字符依次排列即为一个子串,记为 。 - 子序列(subsequence):对于任意
依次取出 排列即为一个子序列。 - 前缀(prefix):任意以
开头的子串。特别的,当 时,称之为真前缀。 - 后缀(suffix):任意以
结尾的子串。特别的,当 时,称之为真后缀。 - 字典序:对于任意两个字符串
, ,称 字典序小于 当且仅当满足以下两个条件之一: - 存在一个下标
,使得 ,且 。
- 存在一个下标
。
- 回文:如果一个字符串等于它的翻转,称它为回文串。
哈希
哈希即映射算法,是整个 OI 界性价比最高的算法几乎没有之一,搭配二分食用后它可以用几乎为零的学习成本和较高的效率解决下文大部分高级算法能解决的问题。
映射
我们知道双射的定义:对于集合
但是真的要找这样的算法是非常难的,因此我们退一步,如果我们认为这个关系只有极小的概率能让
生活中存在一些这样的哈希算法,比如将快递映射到取件码,将学生映射到学号;也存在一些会发生冲突的哈希算法,比如如果将首字母作为一个人名字的哈希值,就会出现诸如“主席树”之类令人忍俊不禁的情况。
哈希的主要用途是牺牲极小的正确率,将原本需要高时间复杂度比较的元素映射到低时间复杂度比较。这个牺牲的正确率一般是可以计算的:如果将
字符串哈希
字符串哈希是典型的降低比较时间复杂度的算法。
考虑下列问题:
求模式串
在文本串 中的出现次数。
如果使用朴素算法,就需要对字符串的每个位置都进行一次时间复杂度
常用的字符串哈希实际上进行了两次映射:先将字符串映射为
关于哈希的具体写法不再赘述。
1 | // 1-indexed |
KMP
(如果你理解不了这一节的内容,直接去看 ACAM 吧)
(本节所有字符串均为 1-indexed)
KMP 算法是一种常用于字符串匹配的算法,以其发明者 Knuth,Morris,Pratt 命名。
Border 与前缀函数
对于字符串
对于字符串
对于字符串 abaab
,
求前缀函数
接下来介绍如何快速求前缀函数。
维护当前指针
与 Border 指针 ,表示 。初始时 。 重复执行以下步骤:
。
- 重复执行以下步骤直到
:
- 重复执行以下步骤直到
- 如果
,则 。结束本步循环。
- 如果
- 如果
,结束本步循环。
- 如果
- 否则
。
- 否则
求得
。
匹配算法流程
求模式串
在文本串 中的出现次数。
匹配流程与求前缀函数有一些类似之处:
- 对
求出 。 - 接下来维护文本串指针
和模式串指针 ,表示 与 匹配。初始时 。 - 重复执行以下步骤:
。
- 重复执行以下步骤:
- 如果
,则 。结束本步循环。
- 如果
- 如果
,结束本步循环。
- 如果
- 否则
。
- 否则
- 如果在匹配的过程中
,说明匹配成功。为继续匹配应当使 。
正确性证明
如图。

如此我们可以保证指针部分始终匹配。
求
时间复杂度证明
由匹配流程可知当
模板
1 | struct KMP{ |
ACAM
(在阅读以下内容之前,建议先行学习虚树理论,至少理解虚树理论的基本思想)
每一个学过算法竞赛的人,都会就这个名字产生无尽的遐想,然后发现这玩意并不能自动
AC
AC 自动机(Aho-Corasick AutoMation,ACAM)是一个基于 Trie
树的数据结构(?),可以以
AC 自动机在某些意义上与 KMP 有相似之处,但不理解 KMP 也可以先学。
Fail 指针
首先对所有模式串建出 Trie 树。
我们定义某个结点的 fail 指针为去除这个结点对应字符串的前若干个字符后在 Trie 树上存在的最长串。
显然除了根结点(空串)所有结点都一定存在 fail 指针。下图用红色边标注了结点的 fail 指针:

我们可以从其定义发现一些性质:
- fail 指针一定是从较长串指向较短串。所以把 fail 指针视为有向边时会发现出现了这些边构成了树结构,任意结点都能不断跳 fail 指针得到空串。这就是 fail 树。
- 对应到字符串意义上,如果
的 fail 指针为 ,那 一定是 的后缀。
求 Fail 指针
考虑递归地求 fail 指针。
假设我们已经求得某个结点对应串
在完成了 fail 树的构建后,我们就完成了 ACAM 的构建,可以进行匹配了。
算法流程
给定模式串
与文本串 ,求 中多少个串在 中出现过。
首先我们在 Trie 树中标记所有模式串的结尾位置,然后建 ACAM。
从根节点出发,对于
容易发现由于任意结点只会在跳 fail
时访问一次(遇到已统计的结点直接退出),所以时间复杂度
Trie 图
在字符集较小的情况下我们可以通过补齐 Trie 上所有空转移,以
拓扑排序/DFS 优化
给定模式串
与文本串 ,求 在 中的出现次数。
在这种情况下,我们不能在访问结束后直接将结点标记为已统计优化时间复杂度。由于
fail 树的树高没有保证,最坏情况下时间复杂度将退化为
此时我们注意到每个模式串的出现次数实质上就是其结尾结点在所有跳 fail 树的过程中被访问的次数,也就是子树中被访问到的结尾结点个数。因此我们可以在跑完字符串后对于所有访问过的结点拓扑排序或 DFS 统计子树信息。
最终时间复杂度为
模板
1 | struct ACAM{ |
例题
题意:给定若干个 01 串,是否能构造一个无限长的字符串使得给定的 01 串均不会在这个字符串中出现。
对所有模式串建 ACAM。
考虑字符串匹配的流程,“无限长”等价于存在一种在 AC 自动机上无限匹配而不走到结束结点的方案,容易发现其充要条件为将 AC 自动机视为有向图的情况下存在一个不包含结束结点的环。
[CCF 201509-5] 最佳文章
题意:给定若干模式串,求一个长度为
的文本串,最大化文本串中模式串的出现次数。求次数。 。
建立 ACAM。定义每个结点对应结束标记个数为
考虑 DP,
转移:
题意:给定模式串集合
,求对于所有长度为 的字符串以下数值的期望:将 任意划分为若干段得到若干子串,所有模式串在这些子串中的总出现次数在所有划分方案中的最大值。 。
和上一题一样建立 ACAM,为了方便统计补全 Trie 图。
同样考虑 DP,
转移:
同样矩阵快速幂即可。
题意:给定模式串集合
与初始为空的文本串集合 。两种操作:
- 向
中添加一个字符串 。 - 询问
在 中多少个串中出现过。
对模式串建立 ACAM。
插入
再进一步观察发现,在每次插入时答案会
容易想到用树状数组维护子树和。但如果直接在这些结点上单点加然后子树求和,在两结点 LCA 以上会算重。
一个常见的 Trick 是,在单点加的同时,在 fail 树 DFN 序相邻的两结点 LCA 处减,可以证明这通操作以后最后所有点都只加了一次。(类似虚树思想)
设总字符数为
题意:给定一棵树,每条边上有字符。若干次查询,每次给定一个字符串
与两个点 ,求取出树上 到 的路径拼接得到的字符串在 中出现了多少次。
。
首先考虑匹配串的位置包含 LCA 的情况,容易发现只需取出 LCA 链上长度为
现在只需考虑有祖先关系的两点之间构成的字符串。由于字符串拼接方向并不统一,需要再考虑一遍
离线询问,对所有
最终时间复杂度
SA
后缀数组(Suffix Array,SA)是一个可以用于解决大量字符串问题的工具。
后缀排序
对于字符串的所有后缀按照字典序排序的问题被称为后缀排序。为了方便存储,我们使用一个数组来表示排序结果,数组中
举个例子,对于字符串 abaab
,其后缀排序结果为
1 | aab -> 3 |
所以其 SA 为
后缀排序有很多种实现方法,这里介绍两种:
Hash+二分
考虑采用传统排序算法框架。
对于两个后缀,如果逐字比较其字典序,单次比较时间复杂度
注意到对于两个后缀,真正需要比较的其实只有 LCP(Longest Common Prefix,最长公共前缀)以后的那一位。如果能快速求 LCP,就能优化时间复杂度。
只需花
最终时间复杂度
倍增法
定义
显然
答案是肯定的。注意到如果只考虑前
我们定义
此时我们只需执行
如果使用快速排序,时间复杂度
根据远古时代某 P 氏的实测,如果使用 std::sort
实现快速排序,运行时间约为基数排序的二倍。
除此之外还有一些线性的算法,但是太复杂了不予介绍。
模板
1 | // 1-indexed |
后缀树与 height 数组
(个人认为不理解后缀树是无法理解 height 的含义的,故作介绍)
如果我们想表示一个字符串的所有子串,一个朴素的想法是列举出字符串的所有后缀插入到一棵 Trie 上,上面的任何一个结点都代表一个子串。
然而鉴于这棵树的结点数量为
但我们发现由于整棵树只有最多
众所周知建立虚树的方式是对关键点的 DFN 排序,然后相邻 DFN 结点添加
LCA 到虚树中。我们观察在这个场景下 DFN
序等价于所有子串的字典序,又有所有关键点都是叶结点,因此此时添加的 LCA
的字符串本质是字典序相邻的两个后缀的
LCP,而这个结点的高度(深度)即为 LCP 的长度。(根结点深度
因此我们终于可以心安理得地定义 height 数组:
特别的,
height 数组有以下的性质:
。 - 可以用这个求 height。
。 - 也就是树意义下这个区间内所有关键点的 LCA。
例题
题意:求这个
纯粹的单调栈,极致的享受,丰矿的拆式子,吉列的编码
(老演员。以后还会出现的。)
题意:给定模式串集合与文本串集合,求每个模式串被包含在多少个文本串内,以及每个文本串内出现了多少个模式串。
对于第一问,把所有串都用不同的分隔符串在一起跑 SA。由 height 的性质,对于模式串开头对应的后缀,向左和向右找到的最后一个 height 大于模式串长度的位置构成的区间中的所有文本串对应后缀均合法。
找区间可以用单调栈,而后面的问题本质是区间数颜色种类数,写个莫队就做了。
第二问只需用一个小 Trick:在莫队时如果当前颜色出现次数从
设总字符数
(当然可以上 BIT+扫描线做到真
题意:给定一个串,对其中每一位求出包含这一位且仅在原串中出现一次的最短子串长度。
求 SA。
“在原串中仅出现一次的子串”对应在 SA 中表现为其长度大于对应后缀
时间复杂度
题意:定义对字符串拆分为四段是“优秀”的,当且仅当这四段满足 AABB 的形式(A,B 均为非空字符串),如将
aabaabaa
拆分成aab|aab|a|a
是优秀的,a|a|baa|baa
也是优秀的。现在给定一个字符串,求多少种取出子串并将其进行优秀的拆分的方案。
枚举 AA 与 BB 的分界点,现在问题变成如何枚举 AA。
然后一个比较神秘的做法是枚举 A 的长度,然后容易(???)发现如果每隔
由调和级数,这个做法的时间复杂度是
(闲话:此题
题意:给定字符串,每次询问给
,输出 的子串中和 的 LCP 长度的最大值。
容易发现子串是假的,答案区间
预处理 SA 后二分答案
如果用 ST 表处理 RMQ,最终时间复杂度
Manacher
Manacher 是一个用于求解回文子串的算法,优势在于其线性的时间复杂度与非常简单的实现。
朴素算法
求解字符串的所有字串中回文串的个数。
首先为了方便计数,在相邻的字符间添加分隔符,则原字符串的回文子串数是新字符串的奇数长度回文子串数。
对于长度为
那么我们可以很容易地设计朴素算法,就是每个位置不断向两边拓展其回文半径。这样是
优化
考虑能否快速用前
维护包含
证明:分情况讨论。
在这种情况下,
与 以及其周边的情况完全对称,所以 。 在这种情况下,首先
(图示浅绿色部分),而至于图中紫色部分与问号紫色部分是否对称有待验证,进行拓展。
此外还有一种情况,即 我来成为
至此我们完成了朴素算法的优化。我们发现每次拓展
SAM
后缀自动机(Suffix AutoMaton,SAM) 是一个非常强大的数据结构(?),可以在线性时间复杂度下解决大量有关字符串子串的问题。
强烈建议在阅读本段前掌握 ACAM
前置定义
本节包含一些重要的定义,其中包含了 SAM 的精髓,务必熟悉。
为了行文方便,我们做出以下辅助定义:
字符串 的长度 :字符串 区间构成的子串 字符串集合 中,拥有最长长度的字符串 字符串集合 中,拥有最短长度的字符串
endpos
对于字符串
我知道这段话很绕,所以需要例子辅助理解:
ababc
,则ab
,因为 ab
在中匹配的位置分别是 ,因此取这些位置的末端位置 构成集合。
特别的,我们认为空串的
我们可以得到下面两条显然的关于
结论一
如果有子串
结论二
如果有子串
上述两条结论的严格证明比较繁琐,但是结合定义与实例可以轻松理解。
endpos 等价类
我们不妨将所有
结论三
对于任意类
换句话说,给定
初始等于 ,一定存在一种构造方式,每次向 的前方添加一个字符直到它等于 ,使得 中的所有字符串都被遍历了一次。
证明:关于后缀的部分由结论二即可得。同时也可以由结论二得到每种长度的字符串只会出现一次。
考虑任意
又由等价类的定义:
link 树
由结论二,对于子串
形式化地,设
我们注意到,
此时如果将等价类视为点,这些不等的关系视为边,那么我们可以得到一张图。关于这张图有以下性质:
- 无环(集合包含的单向性,结论二)
- 连通(任意点都可以通过不断地缩减到空串以到达全集)
因此,这是一棵树。我们称这棵树为 link 树(或 parent 树)。
下图是 abaab
的
link
树。为了方便下一步的内容,我们在图中标注每个结点对应类的最长串:
结论四
对于 link 树上的任意结点
结论五
link 树中,每个结点的父亲是该结点的断头指针(即所有等价类中的最长真后缀,类似于 ACAM 和 KMP 的 fail 指针)
- 引申:link 树中每个结点最长串是所有儿子对应最长串的最长公共后缀。
上文的定义已经验证了这两个结论。
这里需要说明 link 和 fail 的一个区别在于:fail 针对的是一个确切的字符串,而 link 需要首先取出当前等价类中长度最大的后缀,然后对于这个后缀找到其余等价类中最长长度最大的后缀等价类。
SAM 的构建
SAM 是一个 DFA,其中每个结点都代表了一个等价类,每条边都代表一个转移。
SAM 能表示字符串的任意子串,即对于任意子串,从起始结点出发,一定能找到一条路径,该路径表示此子串。
为了实现以上功能,设计了下列算法在线构建 SAM:
流程
定义
初始时,SAM 只有一个结点
当插入字符
- 新建结点
与转移, - 从
开始,不断跳父亲结点,沿途添加转移 ,直到找到结点 满足存在转移 停下。如果不存在这样的 ,那么 。 - 如果
,则 。 - 否则执行复制操作。创建一个新结点
,从 开始向上遍历将沿途所有转移到 的转移改为转移到 (直到找不到),然后修改 。
- 如果
- 令
。
在插入所有字符后,我们从
正确性证明
FBI Warning:这一节内容极为抽象,也是整篇文章写作过程中耗时最长的部分。如果你是背板选手,请自行跳过这一节。
我知道给谁放上面那个构建方式都头大,所以这一节对于上面的关键步骤都给出详细解释。
首先我们定义一个转移
新建结点
,
废话。 好吧还是有用。新建一个结点
从
开始,不断跳父亲结点,沿途添加转移 ,
回忆 link 树的性质,发现跳 link
树等价于不断切掉前端字符,那么对于树上的这些父亲结点,也理应存在
直到找到结点
满足存在转移 停下。
设想一下这种情况:之前的某个后缀已经有了
如果不存在这样的
,那么 。
也就是从来没有过上面提到过的那个子串,那么就不存在什么浪费的问题。忽略即可。
如果
,则 。
接下来我们记
当
否则执行复制操作。创建一个新结点
,从 开始向上跳 树遍历将沿途所有转移到 的转移改为转移到 (直到找不到),然后修改 (直到不存在)。
当
我们希望
同时为了做到彻底的分裂,需要重定向
至于 link 树,我们注意到
这样,原先的
时间复杂度分析
谁爱自己研究就研究吧。我累了。
SAM 的性质与应用
紧凑性
SAM 是一个高度压缩的数据结构。可以证明,SAM 的状态数和转移数都为
证明
上述的构建过程已经证明状态数的数量级。初始有
至于转移数,我们首先考虑连续转移数,显然它不可能成无向图意义下的环(否则这两条路径的内容完全一样,完全浪费了),那么这部分不会超过
图论性质
从 SAM 的转移角度看,不难看出它是一个 DAG,且正如前文提到的,对于任意子串,从起始结点出发,一定能找到一条路径,该路径表示此子串。
从 link 树角度看,每个结点
模板
其实 SAM 比很多数据结构都短很多的说。。。
实现时如果字符集很小可以空间换时间,否则只能上 umap(并导致时间翻倍)。
1 | // https://www.luogu.com.cn/problem/P3804 |
常见应用
求不同子串个数/总长度([SDOI2016] 生成魔咒)
可以在 DAG 角度 DP,也可以直接用 link 树结论。
求字典序第
小子串([TJOI2015] 弦论)
求出每个结点对应字符串个数后按顺序遍历出边即可。
求 endpos 集合大小
显然可以 dfs link 树,答案为子树大小,但是我们认为只有非复制得到的结点拥有大小(可以认为复制得到的结点在 link 树内直接合并了子树)
查找模式串出现次数
在文本串的 SAM 上跑一遍,如果找到了对应的结点,答案就是这个点 endpos 集合的大小。
求 endpos 集合
如果一定要求确切的 endpos 集合,那就上线段树合并吧。
求两个串的最长公共子串
对其中一个串建立 SAM,把另一个串丢到上面跑同时记录当前长度,失配了就跳 link 树父亲(并同步更新当前长度)。
广义 SAM
主播主播,你的 SAM 固然很强,但还是只能处理单个串,有办法让 SAM 跑在 Trie 上吗?
有的兄弟,有的。
我们发现 Trie 上已经有一些现成的连续转移了,并且对于每个结点,它的
例题
题意:给定模式串集合与文本串集合,求每个模式串被包含在多少个文本串内,以及每个文本串内出现了多少个模式串。
对所有文本串加上分隔符建立 SAM,然后把模式串丢到 SAM
上跑,跑到对应结点后第一问的答案就是 link
树子树数颜色,第二问就是到根路径并上有多少个询问点,这两个问题爱咋做咋做,反正是可以做到
题意:给定字符串,求字符串中出现次数恰好为
次的子串中哪个长度出现次数最多,求这个长度(相同长度出现次数时输出最大长度)。
出现
题意:定义一个字符串的子串的“存在值”为其出现次数乘以长度,求所有回文子串中“存在值”的最大值。
PAM 秒了
用 Manacher 求出每个位置的回文半径,标记每个位置回文半径最大值对应的子串终止位置,然后 DFS link 树,这些对应的回文串的出现次数就是其终止位置对应结点对应子树终止状态数。
题意:给你一棵树,每个结点上都有一个字符,求树上的所有点对路径上的字符串依次拼接后能形成多少本质不同的子串。叶结点数很少。
对每个叶结点都为根跑一次 DFS 然后对形成的总 Trie 跑广义 SAM 上就做完了。
题意:给定一些模式串和一些文本串。定义文本串的“熟悉度”为
,当且仅当存在一种分割文本串的方式,使得其中占总长 及以上的子串都满足长度不小于 且是某个模式串的子串。对每个文本串最大的 。
接下来我们称被划分到模式串子串中的字符是”有用的”。
可以发现答案有单调性,可以二分。考虑怎么 check。
考虑 DP。
最终时间复杂度
PAM
回文树(Palindromic Tree,又称 EER Tree),又称回文自动机(PAM),是一个可以高效管理字符串的所有回文子串的数据结构(?)。
思想
PAM 与 KMP/ACAM 思想类似,都是用失配指针实现快速跳转。不过我们需要先解决一个问题:如何高效管理长度为奇数和偶数回文串?
答案非常简单:建两棵树不就对了?
所以 PAM
由两棵树构成,每个结点记录其对应串的长度,其两个根分别被称为奇根与偶根,长度分别为
和 SAM 一样,我们维护上一个字符插入结束后的当前结点
last,在插入新字符时和 ACAM 一样不断跳 fail 指针直到找到结点
模板
1 | const int SIGMA=26; |