NOI 算法梳理

2022/8/7 1:22:53

本文主要是介绍NOI 算法梳理,对大家解决编程问题具有一定的参考价值,需要的程序猿们随着小编来一起学习吧!

距离国赛只剩 15 days 了,而 tzc_wk 甚至在上周四的杭电多校中被 polya 定理板子卡了好久,原因竟然是忘了 polya 定理的板子怎么写了,这不是菜是什么,所以,趁着时间还算有点充足,好好复习下 NOI 要考的知识点吧(

下文已将知识点按照大模块分类,由于时间不够了某些地方可能会写得很简略,尽量更,如果实在更不完也没关系。这一周大概不会刷新题了,大概会把每类知识点的解题方法梳理一下(

1. 字符串方向(考察概率:40%)

1.1 KMP

KMP 算法一般用于解决以下两类问题:

  • 给定字符串 \(s\),对于 \(s\) 的每个前缀求其最长 border \(kmp_i\)。
  • 给定字符串 \(a,b\),对于每个 \(1\le i\le |a|\),求最大的 \(j\) 使 \(a[i-j+1...i]=b[|b|-j+1,|b|]\)。

求解前者的方法就是维护一个指针 \(j\),初始为 \(kmp_{i-1}\),然后不断跳 \(kmp\) 直到 \(s_{j+1}=s_i\) 为止。求解后者的方法也类似,每插入一个字符就跳 \(fail\) 直到当前指针的下一个位置与待插入字符相同。由于每次插入字符最多使指针在 KMP 树上的深度加 1,所以复杂度线性。

1.2 Z 算法

Z 算法可以做以下事情:

  • 给定字符串 \(s\),对于每个 \(i=2...|s|\) 求 \(s[i...|s|]\) 与 \(s\) 的 LCP \(z_i\)。

求解方法类似于暴力,但是与暴力不同的地方在于,假设我们已经求完了 \(z_2\sim z_i\),我们实时维护使 \(z_j+j\) 取到最大值的 \(j\),设其为 \(x\),那么我们求一个 \(z_i\) 时,如果 \(i<z_x+x\),我们就以 \(\min(z_{i-x+1},z_x+x-i)\) 为 \(z_i\) 的初始值。不难发现,\(z_x+x\) 是单调递增的,因此该算法复杂度也是 \(\mathcal O(|s|)\) 的。

1.3 AC 自动机

可以看作 KMP 的扩展,只不过复杂度需要乘一个 \(\Sigma\)。

如果模式串只有一个,那 KMP 显然是没有问题的,但是如果模式串有多个直接 KMP 显然复杂度多个 \(n·|t|\)。考虑建出这些模式串的 trie 树,对于 trie 树上的每个节点 \(x\) 定义 \(fail_x\) 表示 trie 树上深度最深的节点 \(y\) 满足 \(root\to y\) 组成的字符串是 \(root\to x\) 的一个后缀。求解 fail 的过程就从根节点开始 BFS,类比 KMP,求解 \(fail_x\) 就从 \(fa_x\) 开始不断跳 fail 直到存在一个等于 \(x\) 与 \(fa_x\) 之间的字符的出边,移到对应节点即可。

事实上关于这个 \(fail\) 还有更深层次的理解,考虑文本串与这一堆模式串匹配的过程,那么在任意时刻肯定存在一个最深的点 \(x\) 使得当前字符串的后 \(dep_x\) 个节点刚好对应节点 \(x\),显然在匹配的时候我们不用关心 \(dep_x\) 个字符往前的内容,因为它不可能走到一个模式串的位置,因此我们就设 \(x\) 为文本串匹配的状态。那么加入一个字符 \(c\) 的时我们会转移到一个新的状态 \(nw\),那么如果 \(x\) 存在 \(c\) 的出边,显然 \(nw\) 就是对应的儿子,否则我们就要退一步,但是为了避免错过可能的状态,我们只能退到 trie 树上能表示出来的最深的点,也就是 \(fail_x\),如此跳下去直到能接上字符 \(c\) 为止。

AC 自动机是离线算法,即,不能支持在某个文本串后面加入字符后动态维护 \(fail\) 的变化,如果碰到类似的题需要时间轴分块/二进制分组。

将 \(x\to fail_x\) 连边,会得到一棵树称为 fail 树,那么求模式串在给定文本串的总出现次数,等价于对于文本串每个前缀,在 AC 自动机 trie 图上定位到其位置,然后将所以模式串的终止节点标记为关键节点,统计 fail 树上该节点到根路径上关键节点个数求和,因此 AC 自动机也常与树论结合。

1.4 SA

考虑倍增:我们考虑维护一个长度 \(len\),初始 \(len=1\),然后每一轮令 \(len\) 乘 \(2\) 并通过形如 \(s[i...i+len-1]\) 的 \(n\) 个子串排序后的结果来得到形如 \(s[i...i+2len-1]\) 排序后的结果。具体方法就是,设 \(rk_i\) 表示 \(s[i...i+len-1]\) 的排名,那么等价于将形如 \((rk_i,rk_{i+len})\) 当作二元组排序,由于值域只有 \(\mathcal O(n)\),排序可以桶排。注意相同的二元组的排名也应相同,其他方面在实现上还有一些注意点,譬如二元组的第二维其实并不需要桶排,可以直接 \(O(n)\) 地扫一遍得到其大小关系,然后再对第一维桶排即可,时间复杂度线性对数。

后缀数组可以延申出一套定理,在下面的讨论中我们设 \(sa_i\) 表示排名为 \(i\) 的后缀是谁,\(rk_i\) 表示 \([i...n]\) 的排名,那么我们假设 \(ht_i=\text{LCP}(s[sa_i...n],s[sa_{i+1}...n])\),那么对于排名 \(x,y\) 的后缀 \(sa_x,sa_y(x<y)\),有定理:\(\text{LCP}(s[sa_x...n],s[sa_y...n])=\min\limits_{i=x}^{y-1}ht_i\),证明略。这是一个 RMinQ 的形式,因此 SA 常与 DS 结合。那么如何求 \(ht_i\) 呢?令 \(h_i=ht_{rk_i}\),那么有定义 \(h_i\ge h_{i-1}-1\),因此从下标 \(1\) 枚举到 \(n\) 顺着扫一遍就好了。

这也就是为什么 SA 题目一般 getsa, getht, buildst, queryst 一遍写过去(

1.5 Manacher

Manacher 算法一般用于求解一个字符串最长回文子串长度。

考虑先对字符串做一个变换:对于字符串 \(s_1s_2\cdots s_n\) 我们构造 \(t=|s_1|s_2|\cdots|s_n|\),即在相邻两个字符之间加入分隔符。这样可以避免对字符串长度的奇偶性进行分类讨论。

考虑设 \(len_i\) 表示以 \(i\) 为中心的回文串半径的最大值,那么不难发现一个字符串最长回文子串的长度就是 \(\max\limits_{i=1}^{|t|}len_i-1\)。接下来考虑如何求 \(len\) 数组。类比 Z 算法,我们从左到右求这些 \(len\),定义一个位置的回文串 box 为 \([i-len_i+1,i+len_i-1]\),那么我们实时维护右端点最大的回文串 box,然后求解 \(len_i\) 的时候,假设右端点取到最大值的 box 为 \([x-len_x+1,x+len_x-1]\),那么如果 \(i\in[x-len_x+1,x+len_x-1]\),我们就令 \(len_i\) 的初始值为 \(\min(len_{2x-i},x+len_x-i)\),然后开始扩展即可,这样每多匹配一格右端点就会加一,复杂度线性。

推论:一个字符串本质不同回文子串个数是线性的,因此碰到回文串有关问题可以考虑这些性质。

1.6 SAM

1.6.1 SAM 与后缀树

由于学 SAM 时比较咕没写学习笔记所以现在甚至不太记得 SAM 怎么写了(捂脸,毕竟最近 NOI 模拟赛也没考过 SAM)

对于给定字符串 \(s\),SAM 是一个能够识别其所有子串的自动机。更具体地,从初始状态到所有状态的路径都是 \(s\) 的一个子串,并且 \(s\) 的所有子串都可以通过初始状态到某个状态的某条路径表示出来。显然 SAM 有 \(n^2\) 的建法,太逊了,考虑如何 \(O(n)\) 地建 SAM。先抛出一些定义:

  • \(endpos(t)\),\(t\) 在 \(s\) 中所有出现次数的结束位置的集合。
  • \(shortest(t)\),所有 \(endpos\) 与 \(t\) 相同的字符串中长度中长度最小的那个。
  • \(longest(t)\),所有 \(endpos\) 与 \(t\) 相同的字符串中长度中长度最大的那个。
  • \(minlen(t)\),\(shortest(t)\) 的长度。
  • \(maxlen(t)\),\(longest(t)\) 的长度。

有了这些定义以后我们可以直观地想到:将所有 endpos 相同的状态缩成一个等价类,那么感性地理解这些等价类个数不会太多,因此我们考虑将每个等价类看作一个状态。在进行接下来的讨论之前先抛出一些引理:

  1. 任意两字符串的 endpos 要么包含要么不交,读者自证不难。

  2. endpos 相同的字符串的长度构成一段连续的区间,且较短者永远是较长者的后缀,读者自证不难。

  3. 状态数的上界是 \(2n-1\),读者自证不难(显然将区间的包含关系连边会连出一棵树,其叶子节点上界是 \(n\))


引理三为我们将后缀自动机复杂度降到线性埋下了基础。接下来引入另一个定义:

  • 定义一个状态的后缀链接表示,该状态所表示的字符串中,最长的 endpos 不等于该状态的后缀表示的状态的后缀所表示的状态,下文中记作 \(link(p)\)。

定义一个字符串的后缀树为 \(i\to link(i)\) 连边后形成的树(为什么是树呢?因为显然在一个字符串开头砍掉若干个字符后它的 \(endpos\) 集合大小肯定是单调不降的,根据引理 \(1\),这张图必然不会成环,因此它是树)

当然,由 link 的定义也可以直接得出一些结论:

  • \(maxlen(link(i))+1=minlen(i)\)。
  • 后缀树上从 \(i\) 到根节点的路径本质上是从开头删去字符。
  • 对于一个状态 \(i\),它在后缀树上到根的路径上所有点的 \([minlen,maxlen]\) 不交且并集为 \([0,maxlen(i)]\)。

上面的讨论是基于后缀树的一些理论,那么我们又该如何将这套理论与自动机的理论结合在一起呢?由于我们将 edp 相同的缩成了一类,所以有关状态和转移的定义也需做相应的修改:

  • 状态:SAM 上的一个状态表示一个 edp 相同的等价类,即知道当前所在自动机上的节点,就可以知道当前字符串的 edp。
  • 初始状态:空串所表示的状态。
  • 转移:对于一个状态 \(x\),其转移 \(\delta(x,c)\) 表示对于该等价类中的所有字符串,在其末尾加上 \(c\) 之后能够到达的状态,根据该等价类所有字符串相同可知最多只会到达一个状态,\(\delta(x,c)\) 也就唯一表示了这个状态。

这样我们可以知道,对于一个字符串表示的状态,在其末尾插入字符可以视作在自动机上转移,在其开头插入字符可以视作在后缀树上向其儿子转移。

1.6.2 SAM 的建立

现在我们已经知道了后缀树与后缀自动机的联系,下面我们要知道如何构建后缀自动机。

假设现在我们已经知道了当前字符串 \(s[1...n]\) 的后缀自动机,我们要在后面 append 某个字符 \(c\),考虑其变化。

首先我们显然可以在每插入一个字符的时候就维护整个串表示的状态 \(x\),首先我们添加转移 \(\delta(x,c)\),并令 \(y\) 为转移到的新节点。考虑加入这个节点会多出哪些变化。显然存在一个最长的后缀 \(s[i...n]\) 满足 \(s[i...n]+c\) 还是 \(s[1...n]\) 的一个子串,对于比这个后缀更长的后缀表示的状态 \(t\),我们添加转移 \(\delta(t,c)=y\),这一部分暴力跳即可。特判掉不存在这样的后缀的情况,此时直接令 \(link(y)\) 为根节点并返回。我们假设 \(s[i...n]\) 表示的状态为 \(p\),\(\delta(p,c)\) 表示的状态为 \(q\)。

显然,根据 edp 的定义,\(maxlen(q)\ge maxlen(p)+1\),此时我们分两种情况讨论:

  • \(maxlen(q)=maxlen(p)+1\),此时并不会多出新的等价类,对于 \(q\) 在后缀树上的祖先节点,它们的 edp 中会多一个 \(n+1\),其余节点的 edp 并不会改变,而根据定义 \(y\) 的后缀链接就是 \(q\),因此我们直接令 \(link(y)=q\) 然后返回即可。
  • 否则我们发现 \(q\) 等价类所包含的字符串可以分为两类:一类长度 \(\le maxlen(p)+1\),一类长度 \(>maxlen(p)+1\),二者几乎相同:由于在它们后面插入任何一个字符以后,所得的的字符串的 edp 都不会包含 \(n+1\),所以它们在 SAM 上的的转移也完全一致,唯独不同之处在于前者的 edp 包含 \(n+1\),而后者不包含,所以原来的等价类需要分裂成两个等价类。那么分裂成两个等价类又会对其他节点的转移产生怎样的影响呢?我们令前者的状态为 \(cl\),后者保持它原来的标号 \(q\) 不变,那么该自动机的 \(\delta\) 和 link 会产生如下变化:
    • \(link(q)=link(y)=cl\),\(link(cl)\) 则等于原先的 \(link(q)\),这是显然的。
    • 对于原先 \(link(t)=q\) 的状态 \(t\),它们现在的 \(link\) 肯定还是 \(q\) 保持不变。因此除了 \(link(q),link(y),link(cl)\) 其他点的 \(link\) 均不会发生变化。
    • 对于 \(p\) 在后缀树上到根节点的那段路径上 \(\delta(t,c)=q\) 的状态,在这些等价类的所有字符串中加入 \(c\) 后它们的 edp 都会包含 \(n+1\),因此它们的 \(\delta(t,c)\) 都会改为 \(cl\),而对于其他包含 \(c\) 的转移边且 \(\delta(t,c)=q\) 的点,它们在加入字符 \(c\) 后的 edp 都不会包含 \(n+1\),因此它们的 \(\delta(t,c)\) 都不会发生变化。这样我们就直接从 \(p\) 开始跳 link 暴力修改它们的 \(\delta(t,c)\) 即可。

时间复杂度可以被证明是线性的。但是限于篇幅原因,这里不证明。

1.6.3 一些后缀自动机的基本技巧

  • 一个字符串的 edp 集合:如果表示 \(s[1...i]\) 的状态在该字符串所表示状态的后缀树的子树内,那么该字符串的 edp 则包含 \(i\),否则该字符串的 edp 不包含 \(i\)。可以使用线段树合并维护
  • \(s\) 的某个子串 \([l,r]\) 所表示的状态:找到 \(s[1...r]\) 表示的状态,倍增找到最浅的的 \(maxlen\ge r-l+1\) 的点。
  • 该图父亲的 len 值一定比儿子小,因此可以对 len 进行桶排求解 DFS 的顺序,类比 DFS 序倒着遍历在某些卡常题中的作用。

至于 PAM 什么的感觉 NOI 考的概率 \(<\epsilon\) 所以也不准备复习了,毕竟早忘了(

1.7 总结

对于 NOI 级别的字符串题,首先先需要明确它需要用什么知识点,一般来说如下:

  • 碰到多个字符串的题,要么考虑 AC 自动机,要么考虑把字符串拼接起来 SA,要么考虑对多个串建广义 SAM,但是第三个由于实现起来复杂不推荐在考场上使用,如果涉及到这 \(n\) 个字符串的子串则只能用第二个,因为 AC 自动机是将所有模式串当作整体处理的。
  • 碰到类似于求给定字符串出现次数,先考虑 KMP/Z 这种简单算法能不能解决,如果不能再考虑高级结构如 SA/SAM。
  • 碰到给定字符串和某个代价函数让你每次对某个子串的代价函数求值,其中字符串的代价函数为对该字符串的所有子串的某个东西求和,一般都要用到 SA/SAM,SA 的大致思想是枚举 \(l\),这样转化为两个前缀上的问题,SAM 的大致思想是对每个子串分开来求贡献,根据 edp 集合计算出现次数。
  • 有的 \(\sum |s_i|\le L\) 的多字符串题可以考虑根分?

1.8 好题整理

先咕着,有时间再进行这项操作。



这篇关于NOI 算法梳理的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!


扫一扫关注最新编程教程