字符串小记 I:基本结构与简单匹配(更新中)
0.一些定义
在开始之前,我们先给出一些关于字符串的定义:
- 记 \(|S|\) 表示字符串 \(S\) 的长度,\(S_i\) 表示该字符串中第 \(i\) 位的字符,\(S_{l,r}\) 表示该字符串中从第 \(l\) 个到第 \(r\) 个字符所构成的子串
- \(S\) 的前缀为一段以第一个字符开头的子串, 记为 \(S_{1,p}\),后缀同理
- \(S\) 的 Border 为一个同时是 \(S\) 前缀和后缀的子串
- 定义 \(S\) 的周期为一个正整数 \(p\) 使得 \(S_i=S_{i+p}\)
- 记 $\sum $ 为构成 \(S\) 的字符集合,比如英文单词的字符集就是 \(\{a,b,c,...,A,B,C,...,Z\}\)
- 记 lcp(longest common prefix)表示两个字符串的最长公共前缀,lcs 同理,为最长公共后缀
1.Border 和周期的奇妙性质
性质 1:任何长度大于 \(\frac{|s|}{2}\) 的 Border,r如果剩下部分的长度是 \(|S|\) 的因子,那么它是一个周期
可以反过来考虑,按位匹配,如下图所示。
性质 2:任意两个周期 \(p\),\(q\),\(\gcd(p,q)\) 也是原字符串的一个周期。
如下图,有边相连的是可以很容易看出相等的,有颜色的块是一个周期长度的字符串,由于黄色和橙色第一个位置和全部都相等(如果字符串同时有这两个颜色的周期),红色的部分也是周期
性质 3:所有长度大于 \(\frac{|s|}{2}\) 的 Border 都构成一个等差数列,一个字符串所有 Border 按长度排序后,构成不超过 \(\log(|S|)\) 个等差数列
设 \(p\) 是任意一个周期,\(q\) 是最短周期
因为 \(p\) 和 \(q\) 的最大公因数也是一个周期(性质 2),而 \(\gcd(p,q)\leq q\),从上述定义可以看出 \(q=\gcd(p,q)\),因此 \(q|p\),当 \(p\) 所对应的 Border 取大于 \(\frac{|s|}{2}\) 的所有 Border 中最小的时,\(p\) 最大,而且可以发现 \(q\) 复制并延伸后只要不超过 \(p\),另一半一定对应一个 Border,而这些 Border 刚好组成一个等差数列。
由此,递归下去性质得证。
I.P4156 [WC2016]论战捆竹竿
把题目转化为每次将原串扣掉一个 Border 的前半部分然后接在新串后面,就可以转化成一个背包问题用同余最短路解决,问题是 Border 数量过多导致图中边数太多,不好直接求解。
考虑利用性质 3,将 Border 分为若干个等差数列,对于每个首项为 \(x\),公差为 \(d\),项数为 \(L\) 的等差数列,我们选取 \(x\) 为模数,问题转化为每次可以在图上跑 \(d\),\(2d\) 类似的步数,可以观察到这将形成 \(\gcd(x,d)\) 个环,分开 dp 即可。
2.哈希及其扩展应用
2.1 字符串哈希的概念和应用
哈希算法,是一种将字符串映射到一个整数从而方便比较和运算的算法,算法的核心在于,将每个字符串看做一个 $|\sum | $ 进制的正数,只不过用字符而非数字表示,但是通常情况下这个数的位数和字符集都不与整数相似,不方便储存和处理。
那怎么办呢?我们将对应进制的数转化为一个十进制的正数,若是可能超过 int 或 long long 的储存范围就将其对一个大质数取余,最后得到数便是映射出的整数,设 \(b\) 为字符集大小,\(m\) 为模数,计算方法即为以下公式:
\(Hash(s) =( \sum_{i=1}^{|s|} s_i \cdot b^{|s|-i} ) \mod m\)
注意这里我们是高位在前,低位在后,通常为了方便写代码,会将哈希值类型设为 unsigned int,它的优点在于溢出时能自动对上限取余。
但是可以看出这个算法的映射方式并不是一个单射,在处理过程中可能会出现冲突,比如当字符串个数超过模数大小时,必然会出现不同字符串对应相同整数的情况,这时我们可以对多个质数取余,最后逐项比较,虽然冲突概率大大降低,但也要注意此方法带来的巨大常数。
以下给出一个哈希的代码,其他状况类似:
ull makehash(string s,ull mod){
ull ans=0,len=s.length();
for(ull i=0;i<len;i++)ans=(ans*131+s[i])%mod;
return ans;
}
字符串哈希有一个奇妙的性质,由于它是进制转换后表示成一个整数,支持区间合并,于是我们可以预处理处理完每一个前缀的哈希值,然后以 \(O(1)\) 的复杂度求得每一个子串的哈希值,具体求法如下:
\(Hash(s_{l,r})=Hash(s_{1,r})-Hash(s_{1,l-1}) \cdot b^{r-l+1}\)
有了此操作,我们可以方便地处理以下问题:
- 求最长 Border:注意到这个答案具有单调性,我们二分一个答案用哈希检验即可,时间复杂度为 \(O(n \log n)\)
- 检验是否是回文串:求一边正反哈希值后比较,时间复杂度为 \(O(n)\)
- 求 lcp,同样具有单调性,跟求 Border一样
- 求最小周期,注意到上文提过的性质:如果该字符串有一个长度为 \(p\) 的周期,那么它肯定有一个长度为 \(|S|-p\) 的 Border,这样对于自己求一个最长的 Border 即可,时间复杂度 \(O(n \log n)\):
2.2 字符串哈希的动态维护
由于上文说到字符串哈希支持区间合并操作,我们就可以把它放到线段树或者平衡树这样支持区间合并的数据结构上维护,由于平衡树支持增加和删除,会更常用一些,来个例题体会一下:
I.P4036 [JSOI2008]火星人
给定一个字符串,要求支持三个操作:增删字符,求 Border
比较板的题,主要是看一下题目,把哈希放到平衡树上维护再套用刚才的做法就行,时间复杂度 \(O(n \log^2 n)\)
3.trie
3.1 字符 trie
当我们要解决多个字符串的问题时,哈希这种仅仅支持单个字符串的算法就显得比较乏力,而 trie 树就是一种很好的维护多个字符串前缀的数据结构。
trie 树翻译过来就是字典树,顾名思义,算法的核心就在于我们将所有字符串构建出一棵树,比如我们需要处理的字符串有 \(abbac\)、\(ababc\) 和 \(bcacd\),构造出的字典树就如下图所示:
可以看出,整颗树是一颗外向树,由一个根节点和一些其他节点构成,边权值集合就是给出字符串的字符集,如果从根节点出发,任意向下走,走到任意一个节点所形成的路径代表的字符串肯定是给出的某一字符串的前缀。
而 trie 树稍稍做了一个小优化,它把前缀相同的字符串的前缀那部分的路径合并了,这样更方便维护前缀数量等信息,还能有效减少节点数量,不过最坏情况下节点数仍然是 \(O(\sum|S|)\) 的。
下面给出一段构建 trie 树的代码
void insert(string s){
int p=0,len=s.length();
for(int i=0;i<len;i++){
int num=s[i]-'a'+1;
if(!tr[p][num]) tr[p][num]=++tot;
p=tr[p][num];
}
e[p]++;
}
这段代码的逻辑就是先将根节点编号设为 0,每次向下走,如果下面还没有节点就新建一个,然后同样地走到对应节点,注意到这段代码中还维护了一个 \(e\) 数组,这个数组是一个常用技巧,意义在于记录有多少个字符串最后走到当前节点,有时候会对做题有所帮助。
I.P2580 于是他错误的点名开始了
给定若干个字符串作为模板串,每次询问给出一个字符串问当前字符串是否出现过,如果出现过的话是多少字符串的前缀
trie 树模板,把询问串放到用模板串构建出的 trie 树中匹配,如果最后还有没匹配的字符就没路走就是没出现过,如果走到头就判断一下 \(e\) 数组的大小检验出现过多少次(也有可能没出现过)。
3.2 01 trie
3.2.1 简单应用
在一般的 trie 树中,我们将字符串中的字符当做路径上的边权,而在 01 trie 中,边权的集合就是 \(\lbrace 0,1 \rbrace\),01 trie 可以处理一些关于数字比较和异或有关的问题,我们把读入的每个数二进制拆分,从高位到低位排列,形成一个字符串,接着把它们构建出一棵 trie 树,01 trie 就构建完了,此时我们通常规定右子节点为 1 的转移,左子节点为 0 的转移。
01 trie 可以解决一类“找到集合中与给定数异或起来值最大的数”或是类似的与位运算有关的问题,对于这个问题我们可以贪心地选择,由于异或的特性是相同为 0,不同为 1,我们走边的时候尽量走与当前字符相反的边,由于二进制满足 \(2^i > \sum_{j=0}^{i-1} 2^j\) 的性质,且我们是从高位到地位匹配,这个贪心显然成立。而由于其良好的树形结构和较低的树高,01 trie 还可以支持一些可转化为子树操作的操作,具体维护方式则类似线段树。
在部分场景中,相较于平衡树,好写常数小的 01 trie 也许是一个更好的选择,它不仅支持插入删除,查找第 \(k\) 大(需要记录每个节点被多少个字符串经过),还支持查找前驱后继等一系列一般需要用平衡树维护的操作,只要不是需要维护一棵笛卡尔树,01 trie 在大部分情况下都能替代平衡树。
II.P6623 [省选联考 2020 A 卷] 树
给定一棵有根树,定义节点 \(x\) 的价值为:
\( val(x)=(v_{c_1}+d(c_1,x)) \oplus (v_{c_2}+d(c_2,x)) \oplus \cdots \oplus (v_{c_k}+d(c_k, x)) \)
其中 \(d\) 函数表示的是两点之间的最短距离,请你求出 \(\sum val(x)\)
观察题目,由于到本题维护的是子树信息,我们考虑父节点的答案能不能从子节点的答案继承加修改后得来。
因此,题目转化为我们要对每个节点维护一个集合,并支持集合合并,全局加 1 与查询全局异或值,异或值一般的数据结构不太好处理,01 trie 就成了我们有限考虑的目标。
首先考察合并,与线段树合并相似,01 trie 也可以合并,并且在本题中,与新增加点数有关,合并的复杂度也是复杂度 \(O(n \log n)\) 级别的。
全局异或值呢?同样考虑继承加修改儿子节点的权值,从高到低不好确定各个位数,我们另辟蹊径,从低到高建立 01 trie,可以发现此时若只对于每个节点维护两个子节点子树的异或值(注意如果 trie 中有一个数为 10000111,此时遍历到并且只遍历到了 10000 这部分,那么我们把这部分的权值按照 10000 考虑),那么 \(val[x]\) 则为 \((2val[ls])\oplus(2val[rs])\)。
注意如果右子节点(代表下面的转移为 1)子树内共有奇数个字符串,那么上式右括号内还要加一,如果这段看不懂的可以关注一下我们对节点维护的权值的定义,我们这么定义的原因也是因为方便在根节点处直接统计权值(根节点为空节点,供转移使用,因此我们不考虑当前节点权值)。
最后就是全局加 1 了,考虑对于根节点只将左右两个子树加 1,那么左子节点因为最小部分加 1 后为 1,右子节点加 1 后为 0,因此我们交换两个节点。考虑右子节点加 1 后有可能进位,进位代表对其子树节点加 1,因此我们可以递归此操作下去。
还要注意的是,如果有数字以当前节点为结尾,那么我们还需将当前节点的 \(e\) 数组(统计有多少以当前节点为结尾的数字)累加到右子节点上,接着清零当前节点的 \(e\) 数组,如果没有右子节点还需新建一个。
只要每次修改完类似平衡树更新一下当前节点的各个数组即可做到 \(O(n \log n)\)。
III.P4551 最长异或路径
给定一棵带权树,求最长的异或路径,异或路径指的是指两个结点之间唯一路径上的所有边权的异或值。
注意到如果一个数被异或了两次那么它对答案没有贡献,可以视作不存在,于是我们把每个数到根节点的路径的异或值都存到 trie 中,枚举节点查找即可,如果把位数看做常数,01 trie 的时间复杂度就是 \(O(n)\) 的。
3.2.2 可持久化 trie
跟大部分数据结构一样,trie 也是可以可持久化的,由于插入字符串时总新建节点数是 \(O(n)\) 的,时间复杂度也有了保证,只要模仿通常可持久化线段树的操作,每次新建根节点和遍历过的每个节点即可(插一句题外话,trie 也是可以合并的,模仿线段树来就行)。
下面给出一段可持久化 trie 树的代码:
void insert(string s,int i){
int p=++tot;rt[i]=p;tr[p]=tr[rt[i-1]];
int len=s.length();
for(int j=0;j<len;j++){
int num=s[j]-'a'+1;
int tmp=++tot;tr[tmp]=tr[tr[p][num]];
p=tmp;
}
}
这里不多赘述具体逻辑,如果还没有学过可持久化数据结构的朋友可以看看我数据结构的博客(目前还没开始动工已经在新建文件夹了)
IIII.P4735 最大异或和
需要支持两个操作,在序列后面添加一个数或求出一个数,满足数在给定的区间里且后缀异或和最大
考虑到跟上一题一样的性质,处理一下前缀异或和,问题转化为找到一个在区间中的位置使他异或给定的数最大,为了处理区间我们构建出可持久化 trie,每次跳的时候综合两颗 trie 查看到底有没有对应数即可。
*V.P5283 [十二省联考 2019] 异或粽子
给出一个序列,求出异或和前 \(k\) 大的子区间的异或值
有点考验转化能力的题,跟上一题一样转化为前缀异或,先排重,把每个数的目标范围定为从 1 到当前节点前一个节点的所有前缀异或值,求一遍每个节点对应的最大值然后放到堆里,接着每次取出一个值的时候累加到答案,然后以当前选择的节点为端点把范围区间分裂,求一遍最大值后再加到堆里面就行,时间复杂度是 \(O(n\log(n))\) 的(还是把位数看做常数)。