浅谈字符串
1.hash
定义
两种定义方法,基本相同,常用第一种
- \(ha(s)= {\textstyle \sum_{i=1}^{l}}(s[i]*b^{l-i} \text{ % } m)\)
- \(ha(s)= {\textstyle \sum_{i=1}^{l}}(s[i]*b^{i-1} \text{ % } m)\)
碰撞率
当\(m\)选质数时,第\(i\)个字符串的碰撞率为 $\frac{m-i}{m} $所以,进行字符串哈希时,经常会对两个大质数分别取模,这样的话哈希函数的值域就能扩大到两者之积,错误率就非常小了。
所以,进行字符串哈希时,经常会对两个大质数分别取模,这样的话哈希函数的值域就能扩大到两者之积,错误率就非常小了。
应用
1. 允许 \(k\) 次失配的字符串匹配
问题:给定长为 \(n\) 的源串 \(s\) ,以及长度为 \(m\) 的模式串 \(p\) ,要求查找源串中有多少子串与模式串匹配。 \(s'\) 与 \(s\) 匹配,当且仅当 \(s'\) 与 \(s\) 长度相同,且最多有 \(k\) 个位置字符不同。其中 \(1\leq n,m\leq 10^6\) , \(0\leq k\leq 5\) 。
这道题无法使用 KMP 解决,但是可以通过哈希 + 二分来解决。
枚举所有可能匹配的子串,假设现在枚举的子串为 \(s'\) ,通过哈希 + 二分可以快速找到 \(s'\) 与 \(p\) 第一个不同的位置。之后将 \(s'\) 与 \(p\) 在这个失配位置及之前的部分删除掉,继续查找下一个失配位置。这样的过程最多发生 \(k\) 次。
总的时间复杂度为 \(O(m+kn logm)\) 。
2. 最长公共子字符串
问题:给定 \(m\) 个总长不超过 \(n\) 的非空字符串,查找所有字符串的最长公共子字符串,如果有多个,任意输出其中一个。其中 \(1\leq m, n\leq 10^6\) 。
很显然如果存在长度为 \(k\) 的最长公共子字符串,那么 \(k-1\) 的公共子字符串也必定存在。因此我们可以二分最长公共子字符串的长度。假设现在的长度为 \(k\) ,\(check(k)\) 的逻辑为我们将所有所有字符串的长度为 \(k\) 的子串分别进行哈希,将哈希值放入 \(n\) 个哈希表中存储。之后求交集即可。
时间复杂度为 \(O(m+nlog n)\) 。
3. 确定字符串中不同子字符串的数量
问题:给定长为 \(n\) 的字符串,仅由小写英文字母组成,查找该字符串中不同子串的数量。
为了解决这个问题,我们遍历了所有长度为 \(l=1,\cdots ,n\) 的子串。对于每个长度为 \(l\) ,我们将其 \(hash\) 值乘以相同的 \(b\) 的幂次方,并存入一个数组中。数组中不同元素的数量等于字符串中长度不同的子串的数量,并此数字将添加到最终答案中。
2.trie
定义
字典树,英文名 trie。顾名思义,就是一个像字典一样的树。
是AC自动机的载体
struct trie {
int nex[100000][26], cnt;
bool exist[100000]; // 该结点结尾的字符串是否存在
void insert(char *s, int l) { // 插入字符串
int p = 0;
for (int i = 0; i < l; i++) {
int c = s[i] - 'a';
if (!nex[p][c]) nex[p][c] = ++cnt; // 如果没有,就添加结点
p = nex[p][c];
}
exist[p] = 1;
}
bool find(char *s, int l) { // 查找字符串
int p = 0;
for (int i = 0; i < l; i++) {
int c = s[i] - 'a';
if (!nex[p][c]) return 0;
p = nex[p][c];
}
return exist[p];
}
};
01trie
1.维护异或和
01-trie 是指字符集为 \(\{0,1\}\) 的 trie。01-trie 可以用来维护一些数字的异或和,支持修改(删除 + 重新插入),和全局加一(即:让其所维护所有数值递增 1,本质上是一种特殊的修改操作)。
如果要维护异或和,需要按值从低位到高位建立 trie。
一个约定:文中说当前节点 往上 指当前节点到根这条路径,当前节点 往下 指当前结点的子树。
2. 插入 & 删除
如果要维护异或和,我们 只需要 知道某一位上 0 和 1 个数的 奇偶性 即可,也就是对于数字 1 来说,当且仅当这一位上数字 1 的个数为奇数时,这一位上的数字才是 1,请时刻记住这段文字:如果只是维护异或和,我们只需要知道某一位上 1 的数量即可,而不需要知道 trie 到底维护了哪些数字。
对于每一个节点,我们需要记录以下三个量:
- ch[o][0/1] 指节点 o 的两个儿子,ch[o][0] 指下一位是 0,同理 ch[o][1] 指下一位是 1。
- w[o] 指节点 o 到其父亲节点这条边上数值的数量(权值)。每插入一个数字 x,x 二进制拆分后在 trie 上 路径的权值都会 +1。
- xorv[o] 指以 o 为根的子树维护的异或和。
3. 全局加一
所谓全局加一就是指,让这棵 trie 中所有的数值 +1。
我们思考一下二进制意义下 +1 是如何操作的。
我们只需要从低位到高位开始找第一个出现的 0,把它变成 1,然后这个位置后面的 1 都变成 0 即可。
对应 trie 的操作,其实就是交换其左右儿子,顺着 交换后 的 0 边往下递归操作即可。
4.01trie 合并
指的是将上述的两个 01-trie 进行合并,同时合并维护的信息。
其实合并 trie 和合并线段树的思路非常相似
其实合并 trie 非常简单,就是考虑一下我们有一个 int merge(int a, int b) 函数,这个函数传入两个 trie 树位于同一相对位置的结点编号,然后合并完成后返回合并完成的结点编号。
过程
分三种情况:
-
如果 a 没有这个位置上的结点,新合并的结点就是 b
-
如果 b 没有这个位置上的结点,新合并的结点就是 a
-
如果 a,b 都存在,那就把 b 的信息合并到 a 上,新合并的结点就是 a,然后递归操作处理 a 的左右儿子。
如果需要的合并是将 a,b 合并到一棵新树上,这里可以新建结点,然后合并到这个新结点上,这里的代码实现仅仅是将 b 的信息合并到 a 上。
具体的代码如下所示。
struct trie{
int ch[M][2],w[M],xo[M],tot=0;
void pushup(int p){
w[p]=xo[p]=0;
f(i,0,1){
if(!ch[p][i])continue;
w[p]+=w[ch[p][i]];
xo[p]^=xo[ch[p][i]]<<1;
}
xo[p]|=w[ch[p][1]]&1;
}
void ins(int &p,int x,int dep){
if(!p)p=++tot;//p3(p,x,dep);
if(dep>K)return (void)w[p]++;
ins(ch[p][x&1],x>>1,dep+1);
pushup(p);
}
void era(int p,int x,int dep){
if(dep>K)return (void)w[p]--;
ins(ch[p][x&1],x>>1,dep+1);
pushup(p);
}
void add(int p){
swap(ch[p][0],ch[p][1]);
if(ch[p][0])add(ch[p][0]);
pushup(p);
}
int mer(int p1,int p2){
if(!p1)return p2;
if(!p2)return p1;
w[p1]+=w[p2];
xo[p1]^=xo[p2];
f(i,0,1)ch[p1][i]=mer(ch[p1][i],ch[p2][i]);
return p1;
}
}T;
注意,这里要强制让所有串的深度为一定值,否则进位时还要创建新节点
3. kmp
\(fail[i]\):记录前缀\(i\)最长的相等的前后缀的长度
求法(想象一下哪些字符还没比较,哪些已经比较过)
int j=0;
f(i,1,n){
while(j&&s[i]!=s[j])j=fail[j-1];
if(s[i]==s[j])j++;
fail[i]=j;
}
kmp是\(fail\)(前缀函数)的经典应用
//b串的fail, 求出 b 在 a 中所有出现的位置。
int j=0;
f(i,1,n){
while(j&&b[j+1]!=a[i])j=fail[j];
if(b[j+1]==a[i])j++;
if(j==m)wr(i-m+1),j=nxt[j];
}
4. AC
fail(失配指针)
给你一个文本串 \(S\) 和 \(n\) 个模式串 \(T_{1∼n}\),请你分别求出每个模式串 \(T_i\) 在 \(S\) 中出现的次数。
首先对把所有字符串放到trie树里,然后再这个上面建立一个自动机来进行匹配,用的时候把文本串放进去跑一遍就可以了。
考虑如何建立这个自动机。发现kmp的精髓就是不断跳fail。也就是说,我们花了最短的时间选出了下一个可能出现的匹配点。同样地,我们也想在tire上写出一个类似的数组,使得每一次失配之后,都可以快速地跳到可能匹配的地方。
因此设\(fail[i]\)表示当前状态的最长后缀状态即可(相当于kmp中的fail数组搬到了trie上)。
void build(){
queue<int> q;
f(ch,0,M)if(tr[0][ch])q.push(tr[0][ch]);
while(!q.empty()){
u=q.front();q.pop();
f(ch,0,M){
if(tr[u][ch])
fail[tr[u][ch]]=tr[fail[u]][ch],q.push(tr[u][ch]);
else tr[u][ch]=tr[fail[u]][ch];
}
}
}
因为要保证\(tr[fail[u]][ch]\)存在,所以要用\(bfs\)保证深度比\(u\)小的点存在
查询
void query(){
u=0;
f(i,1,strlen(s+1)){
u=tr[u][s[i]-'a'];
while(en[u])ans+=en[u],u=fail[u];
}
}
考虑到,这里跳fail的过程还是有可能退化成\(O(n)\),因此,我们需要进一步优化。
考虑到跳fail的本质是不断考虑自己的后缀有没有可能是模式串,这个过程其实不着急,因为如果在查询的时候处理会浪费很多时间来跳相同的步骤,因此,我们可以将所有的答案统计出来,然后在结束之后对于所有的节点\(u\)是节点\(v\)的最长后缀,把 \(ans[u]+=ans[v]\)。
发现这其实就是一个建立fail树的过程。如果一个节点\(u\)是另一个节点\(v\)的最长后缀,即\(fail[u]=v\),那么建立一条边使得\(v->u\)。于是,我们在trie树上,通过重新连边,建立起一个图,而因为
-
对于一个节点,它的fail是唯一的,入度为一。
-
对于一个节点,它的最长后缀在字典树上的深度一定比它小,它的若干前驱的深度必定递减
综上,这是一颗树,不妨叫做fail树
于是,对于每一个父亲,它的答案就是它在树内子树的所有标记的和。这是因为它是它子树内所有状态的后缀。我们使用拓扑排序或者树形即可,复杂度线性。
tirck们
fail树修改
一个节点在fail树上到根的路径上的节点,表示所有存在于字典树上的它的后缀状态
因此,我们在匹配一个文本串的时候,只需要看它在fail树内的子树上有多少个标记即可。但是这需要离线处理,如果在线,就必须使用树剖,每次花费\(O(logn)\)的时间进行修改它到根节点的路径即可。
用ac自动机反向匹配
也就是说,给定模式串,询问是否存在无限长的文本串使得没有任何一个模式串可以匹配。
但是这个问题要求我们尽量跳,并且,只要存在一个环使得环上不存在任何一个点能够匹配到模式串,那么它就是可以的。此外,如果一个节点\(u\)的fail是模式串,那么它也是不可被匹配的。
离线询问ac的自动机
当我们不要求用文本串匹配所有的模式串时,我们就可以离线ac自动机。例如,我们有询问\((u,v)\)表示在\(u\)中匹配\(v\),那么,这就相当于字典树上遍历到\(u\)时到达根节点的路径上所有的点中,匹配\(v\)的点的数量。也就是说,我们在遍历trie树的时候,只需要在树上对应的节点打上标记,查询的时候让
在对应的点上子树求和即可。
ac自动机上dp
最大匹配个数
给定文本串长度\(k\)以及模式串,求文本串能够匹配的最大数量。
设\(dp[i][u]\)表示到文本串第\(i\)个地方,且匹配到trie树上\(u\)点的地方的最大答案。如果下一个节点可以匹配一些模式串,那么就把答案加上。其中匹配的具体个数可以再处理完fail之后使用拓扑排序求出。这样的复杂度是\(O(k\Sigma s_i)\)。
至少匹配一个模式串的方案数
给定文本串长度\(k\)以及模式串,求至少匹配一个模式串的文本串数量。
要记录是否有模式串被匹配,我们可以在上一个状态的基础上加一维\(is\)表示是否已经匹配了字符串,于是,对于状态\(dp[i][u][is]\),得出以下转移:
填表即可。
通配符匹配
定义两个通配符:
-
\(*\),可以替代任意个字符。
-
\(?\),可以替代一个字符。
对于一般的文本串字符,我们直接在上找寻对应字符即可。对于 \(?\),遍历任意一个转移状态即可。对于通配符\(*\),我们把它化解长两个:
-
匹配零个字符,即对于文本串\(A*B\),看成\(AB\)继续匹配。
-
匹配一个字符,然后再加上一个\(*\),即$ A?*B $
但这样的复杂度显然不对,因此需要记忆化,即记录\(dp[i][u]\),遍历到相同状态就返回,因此复杂度就是\(O(k\Sigma s_i)\)。