字符串哈希与 AC 自动机

字符串哈希

众所周知,字符串比较是 \(O(|S|)\) 的。这样就很难接受,所以需要字符串哈希。

哈希的基本思想即构造某个易于计算的函数,使得相同的两个状态函数值相同,不同的两个状态函数值大概率不同

不妨将字符串视作模质数 \(p\) 意义下 \(b\) 进制数,字符集的每一个字符向 \([1,b-1]\) 做映射:\(x\rightarrow a_x\)(需满足 \(b\) 大于字符集),那么字符串 \(s\) 的哈希值可以表达为 \(f_s=\sum\limits_{i=0}^{|s|-1}b^i\times a_{s_i}\mod p\)。将 \(p\) 称作模数,\(b\) 称作底数。这是最常见的字符串哈希的实现方式。

将不同的两个串哈希值相同的情况称作哈希冲突,减小这样的概率的方法一般有两种:

1.对字符集做随机映射而非有序映射。

2.使用多组不同的底数和模数做哈希。认为两个串相同当且仅当在每一组下面哈希值都相同。

给定串 \(s\),如何快速求出其任意一子串的哈希值。

预处理串 \(s\) 的每一个前缀的哈希值,记作 \(h\),有 \(h_i=h_{i-1}+b^i\times a_{s_i}\mod p\)

要查询子串 \([l,r]\),那么:

\(f_{[l,r]}=\sum\limits_{i=l}^rb^{i-l}\times a_{s_i}\)

\(=\frac{1}{b^l}\sum\limits_{i=l}^rb^{i}\times a_{s_i}=\frac{1}{b^l}(f_r-f_{l-1})\)

预处理 \(b\) 的逆元及其乘方,就可以 \(O(1)\) 知晓一个子串的哈希值。

字符串哈希的应用

1.判断两个字符串是否相等:直接看哈希值就好。

2.字符串匹配:求出文本串每个长度为模式串长度的子串的哈希。

3.字符串比较/最长公共前缀:二分并通过哈希判断一个前缀是某相同,如果要比较字符串大小看下一位就好。

4.最长公共子串:二分长度,判断两个串这个长度的子串哈希值是否有交集。

5.字符串是否回文:正着做一遍哈希和反着做一遍哈希值相同。

例子很多,但是似乎没什么很有意思的例题。

AC自动机

kmp 自己学去,讲了 AC自动机想必 kmp 很好懂,真懂不了我也不是没见过有人考场上不会 kmp 去写单串 AC自动机。

问题一般形如给定若干模式串,要对这些所有的模式串做匹配。

前置知识是 trie 树,我们先把所有的模式串拉出来建一个 trie 树。让文本串在 trie 树上做匹配,对于一个状态,即 trie 树上的一个结点,代表可以匹配到这个节点代表的字符串的子串。

现在考虑如何实现这个匹配。如果当前位仍有字符与之匹配,让指针向这个方向移动即可。现在考虑失配的情况。

引入 fail 指针,trie 树上的每一个节点 \(x\)\(fail_x\) 的意义是如果在 \(x\) 这个位置失配,不得不回退到的状态。

例如两个字符串 \(\text{abcd},\text{bcd}\)\(\text{abcd}\) 的最后位置失配,应当回到 \(\text{bcd} 的最后\) 不难发现这里 \(fail\) 的意义与 kmp 里 \(jump\) 的定义类似。因此说 kmp 和 AC自动机本质相同,不过后者可以处理多串问题。

到现在只是把模式串塞进了 trie,实际上并未建立出一个自动机。如果我们现在已经处理出了 \(fail\),考虑一个串 \(T\) 的匹配:

1.未失配:\(x\rightarrow s_{x,T_i}\)

2.失配:先 \(x\rightarrow fail_x\),然后 \(x\rightarrow s_{x,T_i}\),如果仍失配继续做。

保留 trie 上的祖先关系,同时对于一个点 \(x\),如不存在 \(s_{x,i}\),向 \(s_{fail_x,i}\) 递归地连边,这是一个自动机。

考虑这个东西地构建。使用队列来实现,类似拓扑,队列里是即将处理的节点。那么:

1.存在 \(s_{x,i}\),那么 \(fail_{s_{x,i}}=s_{fail_x,i}\),同时将 \(s_{x,i}\) 加入队列。

2.否则,连边 \(x\rightarrow s_{fail_x,i}\)

至此,我们建出了这样的一个自动机结构,匹配一个字符串只要在上面跑就好了,一个状态满足:一直向上跳 fail,可以到达所有能被它匹配的状态。

#include<iostream>
#include<cstring>
#include<queue>
int n, t, s[1000005][26], o[1000005], f[1000005];
inline void ins(std::string &st){
    int x = 0; for(auto c:st){
        if(!s[x][c-'a']) s[x][c-'a'] = ++t;
        x = s[x][c-'a'];
    } o[x]++;
}
inline void gf(){
    std::queue<int> q;
    for(int i = 0; i < 26; i++)
        if(s[0][i]) q.push(s[0][i]);
    while(q.size()){
        auto x = q.front(); q.pop();
        for(int i = 0; i < 26; i++)
            if(s[x][i]) f[s[x][i]] = s[f[x]][i], q.push(s[x][i]);
            else s[x][i] = s[f[x]][i];
    }
}
inline int fd(std::string &st){
    int x = 0, r = 0;
    for(auto c:st){
        x = s[x][c-'a'];
        for(int p = x; p && ~o[p]; p = f[p])
            r += o[p], o[p] = -1;
    } return r;
}
int main(){
    std::cin >> n; std::string s;
    for(int i = 1; i <= n; i++)
        std::cin >> s, ins(s);
    gf(); std::cin >> s;
    std::cout << fd(s);
}

发现在这里的每一次匹配都要暴力地向上跳 fail,这样最劣可以被卡成 \(O(nm)\),我们需要一个更简洁的结构。

对于每一个结点,连边 \(fail_x\rightarrow x\),最终会形成一个树结构,称为 fail 树。一个结点可以被它所有的子节点匹配,一个节点可以匹配他所有的祖先。

例题:【模板】AC自动机

给定若干模式串和一个文本串,求出每个模式串出现的次数。

建出 fail 树,在匹配时对到的每一个节点加一。一个点被匹配的次数是他的子树和。

注意 AC自动机的构建是离线的,似乎不能支持快速修改。

模板题:P3121

在匹配过程中记录中间状态即可。

模板题:P3966

【模板】AC自动机。不知道出题人是不是没学过语文。

fail 树上 dp:CF1575H

建出 b 的 kmp自动机/AC自动机,设 \(f_{i,j,k}\) 为匹配到 a 串的 \(i\),在自动机上的 \(j\) 位置,已经匹配的 \(b\) 的个数。枚举下一位是什么转移即可。

fail 树上 dp:P3041

\(f_{i,x}\) 为走了 \(i\) 步,在树上结点 \(x\) 的最多组合技数。预处理一个节点 \(x\) 可以匹配多少次模式串 \(g_x\),那么 \(f_{i+1,s_{x,o}}\leftarrow f_{i,x}+g_{s_{x,o}}\)

fail 树上 dp:P3311

\(f_{i,j,0/1}\) 为填到第 \(i\) 位,树上节点为 \(j\),前面的数位是否达到上界,枚举下一位填什么转移即可。

fail 树上 dp:P4052

先容斥。计算不包含任意模式串的方案数。然后同 P4052。

发现 AC自动机用到的最多的性质是 fail 树的结构,毕竟他是一棵树。所以可以结合很多东西,最主要是数据结构。

例题:CF1437G

问题即查询 fail 树上到根的一条链上的最大值。树剖即可。

例题:CF710F

注意到 AC自动机是离线构建的,而这题强制在线。所以需要根号重构。

例题:P7852

狗屎题。每次查询一个区间,又修改一个区间的权值。不难想到每根号个字符串建一个自动机。以上的都可以简单实现整块和散块修改。

例题:CF590E

将互相包含的串连有向边,即这个图(实际为 DAG)的最大独立集。用 AC自动机来实现连边即可。

例题:CF1483F

这是个好题。

枚举长串,取每个后缀最长的不是原串且是一个单词的前缀,这部分可以预处理后直接在 AC自动机上匹配。答案一定在这些串的集合里。

考虑什么样的字符串是合法的。挨个判断每个串是否被别的串覆盖是困难的。这个东西等价于这个串在长串的出现次数等于不被覆盖的次数。前者直接建出 ACAM 后树状数组即可。

对于后者:称后缀 \(p\) 的前缀为 \(t_p\)。对于所有的最长的串 \([p-|t_p|+1,p]\)。从后往前枚举,维护变量 \(last\)。若 \(p-|t_p|+1<last\),那么 \(t_p\) 不被覆盖地出现过,并让 \(last\leftarrow p-|t_p|+1\)

我真的没做过什么字符串题。所以讲的比较笼统。多的题也推荐不来,自己上网搜题单吧。

posted @ 2024-07-24 21:30  xlpg0713  阅读(7)  评论(0编辑  收藏  举报