【学习笔记】字符串
字符串下标从 \(1\) 开始。
kmp
在匹配一个模式串 \(s[1...n]\) 和文本串 \(t[1...m]\) 时,如果失配,记录当前匹配到的位置是模式串的第 \(i\) 位,找到最大的 \(k\) 满足 \(s[1...k]=s[i-k+1...k]\),我们可以把第 \(i\) 位跳到第 \(k\) 位继续尝试匹配,有效避免往回退文本串导致的重复匹配,这是 kmp 的基本思想,其中对于每一个 \(i\) 寻找的 \(k\) 被描述成一个 \(bor_i\) 数组,即对于子串 \(s[1...i]\),能够令前后缀不同但相等的最长长度 \(k\)。
得到 \([1,i]\) 的答案,记录 \(kmp=bor_i\),考虑得到 \(bor_{i+1}\)。新增一个字符,如果会破坏原有的匹配,就需要把 \(kmp\) 往 \(bor_{kmp}\) 跳,尝试新一轮匹配,直到匹配成功或没有 \(border\) 跳。
复杂度线性,每一次操作最多只会让 \(border\) 加一,所以失配往回跳的过程总共也是 \(O(n)\) 量级。
与文本串的匹配类似把文本串接到模式串后面再次进行 kmp,具体做法类似甚至相同。
manacher
经典 trick:给每两个字符中间插一个特殊符号,此时原字符串中的所有回文都是奇长度会问。设 \(p_i\) 表示以 \(i\) 为中心扩张的最大回文半径。
记录当前扩展的最右边界 \(mr\) 和对应的点 \(md\)。如果对于当前 \(i<mr\),由于回文的对称性,\(i\) 对应的 \(j=2md-i\) 可能即为 \(i\) 的答案。
为什么说是可能,因为 \(i+p_j\) 可能超过 \(mr\),超过的部分并不能确定是否满足回文,所以要和 \(mr-i\) 取最小值。
然后暴力扩展更新即可。
复杂度是线性的,因为如果需要进行暴力扩展的操作,每一次扩展都会使得结束后 \(mr\) 对应地扩展,所以只会暴力扩展 \(O(n)\) 次。
exkmp/Z 函数
感觉相比 kmp 更像 manacher?
同样考虑 \(z_i\) 表示 \(s[i...n]\) 和 \(s\) 的 \(\text{lcp}\) 长度。对于 \(z_1=n\),其本身为相同的串,不做讨论,从 \(i=2\) 开始。
维护一个 \(zl,zr\),意为当前维护到的 \(\text{lcp}\) 中结束位置最大的子串。
如果当前 \(i\le zr\),根据 \(\text{lcp}\) 的性质,每个位置往前推 \(zl-1\) 位得到的是一样的字符,那么 \(z_i\) 可以为 \(z_{i-zl+1}\)。
但是 \([zl,zr]\) 这段可能没那么长,如果此时你发现 \(i+z_i-1>zr\),那么 \([zr,i+z_i-1]\) 这一段实际是无法确定是否相同的,所以上面那个东西还要跟 \(zr-i+1\) 取 \(\min\),代表最远取到右端点。
最后你暴力扩展得到最后的 \(z_i\) 即可。
复杂度分析和 manacher 一样都是线性。与另一个模式串的匹配可以参考 kmp,相当于直接把新串接到原串后面继续求。
AC 自动机(ACAM)
应对很多模式串在文本串的匹配。
如果是单个串的匹配就是使用 kmp 完成,同样地考虑记录每个串第 \(i\) 的失配数组 \(fail_i\)。用 trie 处理跳失配时有多个字符串共享一个失配前缀的情况。
以下简称 \(fail_i\) 为 \(fa_i\),代表第 \(i\) 个点失配后跳到的最深节点。容易发现 \(fa_i\) 一定是 \(trie[0\to i]\) 的后缀所以深度一定相较 \(i\) 更小,那么对于第一层的节点显然有 \(fa_i=0\)。
假如说我们在第 \(i\) 个节点匹配 \(k\) 失配了,即 \(i\) 没有 \(k\) 的儿子,那么我们往 \(fa_i\) 跳尝试匹配到 \(fa_i\) 的 \(k\) 儿子。为了简化匹配的过程我们直接令 \(fa_i\) 的 \(k\) 儿子成为 \(i\) 的 \(k\) 儿子,意义上进行了失配的过程。同时这种做法避免了匹配失败继续往上跳的重复失配过程,确保了复杂度的 \(O(26\times n)\),\(n\) 是节点个数。
如果 \(i\) 有 \(k\) 儿子,根据定义此时失配应该跳到 \(fa_i\) 的 \(k\) 儿子,所以更新儿子的失配数组然后往儿子更新。由于 \(fa_i\) 的深度一定小于 \(i\),为了确保更新时是准确的,可以用 bfs 的方法深度递进地转移。
在更新答案的时候把从自己往前的所有 \(fa_i\) 都更新了,因为那些是自己的后缀,也就是被 \(i\) 包含了。最后做一遍拓扑累加就得到每个节点代表的串的匹配答案。
PAM
感觉比 manacher 好用。考虑和 AC 自动机相同的构造方法。对于每个节点维护 \(f_i\) 表示 \(i\) 的最大回文后缀。
奇偶问题就直接开两个树一个奇一个偶,连 fail 的时候把偶根连到奇根上来,原因是单独的一个字符也是回文所以奇根永远不会失配。然后 PAM 的每一个点都代表一个回文串。
以 \(1\) 为奇根,\(0\) 为偶根。
判断什么时候失配需要保存当前回文串的长度然后推出来自己对面的是哪个字符。所以有 \(len_1=-1\) 来保证单个字符的匹配。
还是很板子的。注意更新 fail 要在建点之前。不然会出现自己的 fail 连到自己身上然后死循环。
有一个结论是长度为 \(s\) 的字符串最多有 \(s\) 个本质不同的回文子串。
证明:
-
对于 \(s=1\),显然正确。
-
考虑新添加一个字符,新增的回文串一定以它为右端点。令构成的最长的回文串左端点为 \(l\),当前为 \(r\),中点是 \(k\),其他更小的回文串的左右端点和中点为 \(l',r',k'\)。
-
那么对于 \(k\le l'\) 的所有回文串,它们没有跨过中点,根据回文的对称在对面一定出现过,也就是不是一个新串。
-
对于 \(l'\le k\) 的回文串,它有一部分跨过了 \(k\),令这一部分为 \(a\),根据对称性最大的回文串以 \(k\) 为回文,左边是 \(a\) 右边应该是 \(a\) 的倒串。也就是说你把右半边倒到左半边来一定能和右边的 \(a\) 的倒串连出一个一模一样的回文串。
-
也就是说每一次最多只会多一个本质不同的回文串,而且是以自己为右端点能够构成的最大回文串。
复杂度证明:
-
考虑
while
循环外一次都是 \(O(1)\) 的,看跳 fail 的函数中while
执行的次数。 -
容易看出每跳一次当前点的深度至少减 \(1\)。
-
单个字符插入完之后深度只会固定加 \(1\)。
-
而深度归 \(0\) 后不可能继续往上面跳了,所以复杂度是 \(O(n)\) 的。
SA
定义编号为 \(i\) 的后缀指 \(s[i...n]\)。\(sa_i\) 表示将所有后缀排序后,第 \(i\) 大的后缀编号,\(rk_i\) 表示将所有后缀排序后,编号 \(i\) 的排名。显然这是一个映射关系,即 \(sa_{rk_i}=rk_{sa_i}=i\)。
使用倍增合并两个当前的后缀,每次合并完重新排一次序,得到最后的后缀数组,比较的时候先比较前面的大小,把后面接上来作为第二关键字。
这里可以用到基数排序,即用桶把出现的东西记下来之后做前缀和,这样就大致知道每个数的排名。然后从后往前推每个数的排名,每次把对应的桶值减一,这样就不会有重复。注意这种排序是稳定排序。
我们可以一开始直接构建一个以第二关键字为排序的第一关键字数组,因为第二关键字原本就是排好序的。这样重新用基数排序就可以得到原来的以开头段为关键字,接上来那段的比较也在一开始就提了出来经过稳定的基数排序得到了正确的顺序。
复杂度 \(O(n\log n)\),显然的。
用 SA 的题基本上都会用到 \(h\) 数组,令 \(lcp(i,j)\) 指后缀 \(i\) 和后缀 \(j\) 的最长公共前缀的长度,则 \(h_i\) 表示 \(lcp(sa_i,sa_{i-1})\)。有一个比较显然的结论就是 \(lcp(i,j)=min_{k=i+1}^{j}h_k\),基本上要出 SA 的题绕不开这个。
于是我们在知道 \(h\) 数组之后就可以 \(O(n\log n)-O(1)\) 地求两个后缀的最长公共前缀。
求法的话,结论是 \(h_{rk_i}\ge h_{rk_{i-1}}-1\),线性推过去即可。
证明(照搬自 OI-WIKI):
-
如果 \(h_{rk_{i-1}}\le 1\) 直接成立。
-
所以 \(lcp(i-1,sa_{rk_{i-1}-1})=h_{rk_{i-1}}>1\)。令这个前缀是 \(\text{aA}\),其中 \(\text{a}\) 是单个字符,\(\text{A}\) 非空。
-
根据 \(rk\) 的定义 \(i-1<sa_{rk_{i-1}-1}\)。因为后面那个排名比 \(rk_{i-1}\) 小,是 \(rk_{i-1}-1\)。所以令 \(\text{aAK}\) 代表 \(i-1\),\(\text{aAB}\) 代表 \(sa_{rk_{i-1}-1}\)。其中 \(\text{B}\) 可为空,\(\text{K}\) 非空。
-
那么后缀 \(i\) 就是 \(\text{AK}\),并且存在后缀 \(sa_{rk_{i-1}-1}+1\) 为 \(\text{AB}\)。
-
由于 \(sa_{rk_i-1}\) 大小关系紧接着 \(sa_{rk_i}\) 也就是后缀 \(i\),那么 后缀 \(sa_{rk_i-1}<\text{AK}\) 而且中间没有其他字符串。
-
又因为 \(\text{AB}<\text{AK}\),所以 \(AB\le\) 后缀 \(sa_{rk_i-1}<\text{AK}\)。也就是说有一个公共前缀 \(text{A}\)。回过头来看 \(\text{A}\) 代表的是 \(h_{rk_{i-1}}\) 减去前面的单个字符 \(\text{a}\),即 \(h_{rk_{i-1}}-1\)。
-
所以 \(lcp(i,sa_{rk_i-1})\ge h_{rk_{i-1}}-1\),化成结论中的式子,得证。
额每一个后缀的所有前缀都是字符串的一个不重不漏的子串,应该只有我觉得这个不是一眼得出来的吧。
SAM
给定字符串 \(S\),称为原串/母串。
除空间外薄沙 SA,同时据 wyb 讲通过哈希能够把 SAM 空间也开成 \(O(n)\) 不带字符集。但是我不会/bx。
构建出来的 SAM 是一个 DAG,称点为节点/状态,边为转移。那么我们构建的 SAM 状态上限为 \(2n-1\),转移上限为 \(3n-4\)。这保证了 SAM 的复杂度优越性。但是我不会证转移上限。
SAM 有一个源点 \(t\),称为初始状态,其可以到达所有其他状态。转移标有字母,表示这个状态可以通过在其后面添加该转移表示字符的转移得到连接的状态。从每一个状态出发的转移都不同。
SAM 中存在一个或多个终止状态。终止状态满足从初始状态到终止状态的转移连起来一定是原串的一个后缀。
从初始状态到任意一个状态结束的路径的转移连起来都是原串的子串。
一个状态可能表示不同的子串。令一个串在 \(S\) 中的结束位置集合为 \(endpos\)。显然若有两个串 \(a,b\),\(a\) 为 \(b\) 的后缀,那么必有 \(endpos(a)\subseteq endpos(b)\),也就可能出现 \(endpos(a)=endpos(b)\) 的情况。这种情况我们发现如果开两个节点转移也都是一样的,我们可以直接把这两个点合并。同时我们得到 SAM 中所有节点的 \(endpos\) 互不相同。同时一个节点中所有子串的长度不同且在值域上连续。
定义一个节点的后缀链接 \(lik(v)\) 连接到不处这个节点的最长该节点表示的字符串的后缀所处的节点。所有 \(lik\) 构成一棵以 \(t\) 为根的树。因为 \(t\) 是空后缀。为方便定义 \(lik_t=-1\)。
再定义一个节点包含的最长串长度为 \(le\)。在大多数 SAM 的题中有用的其实是 \(lik\) 和 \(le\)。
ok 然后读一遍求法。SAM 为增量构造,每次增加一个字符 \(c\)。
令 \(las\) 为增加 \(c\) 之前推出构造时处于的节点。初始时令 \(las=0\)。然后创造新的节点 \(p\),定义 \(le_p=le_{las}+1\),显然因为此时的 \(p\) 就是现在的串,也就是原先的串后面加了个字符。然后 \(p\) 还没有转移所以我们只需要再确定 \(lik_p\) 即可。然后从 \(las\) 开始跳 \(lik\),由于 \(lik\) 存的是后缀,则所有后缀加 \(c\) 都应该出现在 SAM 中。所以我们一直跳到 \(c\) 的转移出现。令此时这个节点为 \(ps\)。因为之前的 SAM 满足 SAM 性质所以再往后面跳也都满足存在 \(c\) 的转移。如果发现 \(ps=-1\) 即到了根的前面就代表没有出现过 \(c\),直接把 \(lik_p\) 连到 \(0\) 然后结束。
好我们假设存在 \(ps\),令其转移为 \(fp\),判断 \(le_p+1\) 是否等于 \(le_{fp}\)。如果相等根据上述性质 \(fp\) 仅表示 \(ps\) 后面增加 \(c\) 这一个字符串,此时我们把 \(lik_p\) 连到 \(fp\),因为在这里 \(endpos\) 集合第一次不等,且 \(fp\) 恰好表示连上后缀的字符串。
好那么只剩 \(le_p+1\ne le_{fp}\) 的情况。把 \(fp\) 拆成两个点,共用转移和后缀链接。令复制出来的节点为 \(np\),更新其 \(le\)。接下来从 \(ps\) 继续跳后缀链接,把所有转移到 \(fp\) 的转移改为转移到 \(np\),注意如果此时转移不到 \(fp\) 那么以后也转移不到 \(fp\) 应该在此时直接退出循环否则复杂度会错误。最后更新 \(p\) 和 \(fp\) 的后缀链接为 \(np\)。
结束前记得更新 \(las\)。容易观察出状态上限为 \(2n-1\),因为一次最多加两个点。复杂度和转移数都不会证。
SAM 有一些比较优秀的性质,这让其可以处理非常多的字符串问题。
一个状态包含的子串数为 \(le_p-le_{lik_p}\)。
根据子串和转移的相互转化,不同子串的个数相当于 SAM 中的路径数,即为经典 DAG 路径计数问题。
一个串的出现次数即为 \(endpos\) 集合大小。令所有增量构造时的结束节点 \(p\) 赋值 \(cnt_p=1\),在 \(lik\) 构成的树上子树求和即可。
定义一个串的第一个出现位置为 \(first\),维护考虑如果这个状态是增量构造时新增的那么 \(first=len\),否则如果是复制出来的那么 \(first_{np}=first_{fp}\)。
广义后缀自动机
普通的 SAM 只能处理一个串,所以加强一下处理多个串的问题。得到广义后缀自动机!
广义 SAM 的做法把这些串挂在 trie 上面然后根据 trie 构造广义 SAM。
据说网上流传非常多的假的广义 SAM,但是板子题要输出广义 SAM 的点数会卡掉很多正确性不对的做法。其他做法可能复杂度会出锅。反正抄的题解区第一篇总不会错太多吧。
离线
相比在线较为简单。但是在某些题目里面不如在线好维护一些东西,具体因题而异。
把 trie 先挂好。插入字符 \(c\) 时的 \(las\) 是它在 trie 树上的父亲在广义 SAM 上的状态。广义 SAM 里面的插入和普通 SAM 的插入是没有区别的。
因为要知道父亲的状态所以插入完之后返回一个值表示这个字符插入完之后广义 SAM 的状态位置。也就是自己子节点插入时需要的 \(las\)。
trie 自动帮忙把 lcp 去掉了,所以感觉正确性是有的。但是其实是不知道正确性的。
在线
相比于离线可以更好地统计单独一个母串的一些贡献(?。
这种做法不需要把 trie 建立出来。每次把新串插入到现在建的 SAM 上,然后一个串插完之后把 \(las\) 重新赋为 \(0\)。
但是这种做法是错误的。已经有很多方法能够卡调掉这种做法(吗?。所以我们需要加一些特判。据题解区说这个做法经过大量对拍也没有出错,于是这是正确的做法。
如果添加 \(c\) 时已经存在了 \(las\) 经过 \(c\) 的转移状态 \(fp\),且 \(le_p+1=le_{fp}\),代表在其他串里面出现了当前串没有必要新建节点。
如果拆节点时拆的是 \(las\) 节点,代表 \(las\) 已经有了 \(c\) 的转移,而我们此时新建的节点也是 \(las\) 加 \(c\) 的转移。拆的点和新建的点是一样的,也就是说我们重复构建了一个点,需要把一个点拆掉。
发现出现问题的地方都是在 \(las\) 有转移的情况,于是可以分 \(las\) 有没有原先的转移来讨论。