AcWing 1282. 搜索关键词

\(AcWing\) \(1282\). 搜索关键词

一、题目描述

给定 \(n\) 个长度不超过 \(50\) 的由小写英文字母组成的单词,以及一篇长为 \(m\) 的文章。

请问,其中有多少个单词在文章中出现了。

注意:每个单词不论在文章中出现多少次,仅累计 \(1\) 次。

输入格式
第一行包含整数 \(T\),表示共有 \(T\) 组测试数据。

对于每组数据,第一行一个整数 \(n\),接下去 \(n\) 行表示 \(n\) 个单词,最后一行输入一个字符串,表示文章。

输出格式
对于每组数据,输出一个占一行的整数,表示有多少个单词在文章中出现。

数据范围
\(1≤n≤10^4,1≤m≤10^6\)

输入样例

1
5
she
he
say
shr
her
yasherhs

输出样例

3

二、\(AC\)自动机图解

1、\(AC\)自动机与\(kmp\)的区别

算法 使用场景
\(KMP\) 单文本串,单模式串
\(AC\)自动机 单文本串,多模式串

2、推荐视频

视频讲解

3、算法步骤

\(AC\)自动机:在\(Trie\)树的基础上,增加一个 失配 时用的fail指针,如果当前点匹配失败,则将指针转移到fail指针指向的地方,这样就可以不用重头再来, 尽可能的利用已经知道的信息,能少退就少退 ,fail 指针的构建用 bfs 实现。

  • ① 根据多个模式串建立\(Trie\)
  • ② 构建失配指针
  • ③ 在构建的\(AC\)自动机上跑文本串

举栗子说明

只需要 遍历一遍文本串找出所有单词表中存在的单词:

 先根据模式串集合{she,he,say,shr,her}建立\(Trie\)树,如图,然后拿着yasherhs去匹配:

  • 发现前两个字符无法匹配,跳过
  • 第三个字符开始,she可以匹配,记录下来,ans++,继续往后走发现没有匹配了,结果就是该文本串只存在一个单词she,很显然,答案是错的,因为存在sheheher三个单词!

 可以发现的是使用文本串在字典树上进行匹配的时候,找到了一个单词结点后还应该看看有没有以该单词结点的后缀为 前缀 的其他单词,比如she的后缀he是单词heher的前缀。因此就需要一个 失配指针 在发现失配的时候指向其他存在e的结点,就通过fail指针的指引,跳到\(root->he\)这个位置,此时发现e有黄色标识,就是又找到一个模式串he,继续下一个字符,是\(r\),发现后续可以匹配,匹配her了...

 总的来说,\(AC\)自动机中失配指针和\(KMP\)\(ne\)数组的作用是一致的,就是 要想在只遍历一遍文本串的前提下,找到全部匹配模式串,就必须提前安排好匹配过程中失配后怎么办。如果在在\(Trie\)树上构造 失配指针 ,就是学习如何构造\(AC\)自动机。

4、构建失配指针(构造\(AC\)自动机)

办法如下:

  • 根结点的\(ne\)指针为空(或者它自己)

注释:类比\(kmp\),模式串的第一位,\(ne[1]=0\),退无可退,无法再退

  • 直接和根结点相连的结点,如果这些结点失配,就只能重新开始匹配,故它们的ne指针指向根结点

  • 其它结点,设当前结点为father,其孩子结点为child。要寻找childne指针,需要看fatherne指针指向的结点,假设是tmp,要看tmp的孩子中有没有和child所代表的字符相同的,有则childne指针指向tmp的这个孩子结点,没有则继续沿着tmpne指针往上走,如果找到相同,就指向,如果一直找到了根结点的ne也就是空的时候,childne指针就指向root,表示重新从根结点开始匹配。

考察fatherne指针指向的结点有没有和child相同的结点,否则继续往上,就保证了前缀是相同的,比如刚才寻找右侧h的孩子结点efail指针时,找到右侧hfail指针指向左侧的h结点,他的孩子中有e,就将右侧h的孩子efail指针指向它就保证了前缀h是相同的。

  这样,就用fail指针来安排好每次失配后应该跳到哪里,而fail指针跳到哪里,说明从根结点到这个结点之前的字符串已经匹配过了,从而避免了重复匹配,也就解决了只遍历一次文本串就找出所有单词的问题。

5、匹配过程

匹配就很简单了,这里以 ashw 为例:
先用 ash 匹配,到 h 了发现,ash 是一个完整的模式串,则\(ans++\),然后找下一个 w,可是 ash 后面没字母了呀,我们就跳到 h\(fail\) 指针指向的那个 h 继续找,还是没有 ? 再跳,结果当前的 h 指向的是根节点,又从根节点找,然而还是没有找到 w,匹配结束(假设如果有w的话,那么模式串shw就可以匹配上ashw)。流程图如下:

三、朴素版本【不推荐,学习用】

#include <bits/stdc++.h>
using namespace std;

const int N = 55 * 1e4 + 10; // n个单词
const int M = 1e6 + 10;      // 长度为m的文章

int n;
int tr[N][26], cnt[N], idx; // Trie树,每个结点的结束标识数组cnt,结点号计数器idx,二维代表最多可能有26条不同走向的边
char s[M];                  // 字符串数组
int q[N], ne[N];            // AC自动机构建需要的队列和失配指针

// Trie树构建
void insert(char *s) {
    int p = 0;
    for (int i = 0; s[i]; i++) {
        int t = s[i] - 'a';
        if (!tr[p][t]) tr[p][t] = ++idx;
        p = tr[p][t];
    }
    cnt[p]++;
}

// 构建AC自动机
void build() {
    int hh = 0, tt = -1;
    // 树根和树根下一层的失配边都连到树根上,所以从树根下一层开始bfs
    for (int i = 0; i < 26; i++)
        if (tr[0][i]) q[++tt] = tr[0][i];

    while (hh <= tt) {
        int t = q[hh++];
        for (int i = 0; i < 26; i++) { // u节点有哪些出边
            int p = tr[t][i];          // v是 u通过i这条边到达的子节点
            if (!p) continue;          // 如果v并不存在,就直接返回

            int j = ne[t];                    // u的失配指针指向了j
            while (j && !tr[j][i]) j = ne[j]; // p需要它父亲u的失配指针j=ne[t]出发,一直跳到有i这条边为止,当然,也可能跳到了根也找不到i这条边
            if (tr[j][i]) ne[p] = tr[j][i];   // 如果最终的停止节点j,有i这条边,则进入tr[j][i]这个点,这个点就是节点v失配后的指向节点。当然,ne[j]也可能跳回了根
                                              // 填充儿子节点v的失配指针为它父亲给找的最佳匹配j

            q[++tt] = p; // 将节点v放入队列
        }
    }
}

int query(char *s) {
    int res = 0;
    int j = 0; // 从root=0开始对AC自动机进行查找
    for (int i = 0; s[i]; i++) {
        int t = s[i] - 'a';
        while (j && !tr[j][t]) j = ne[j]; // 一直跳到有t这条边的节点处或者跳到root停止
        if (tr[j][t]) j = tr[j][t];

        // 累加计数
        int p = j;
        while (p && ~cnt[p]) {
            res += cnt[p];
            cnt[p] = -1; // 取消标识
            p = ne[p];
        }
    }
    return res;
}

int main() {
    int T;
    cin >> T;

    while (T--) {
        memset(tr, 0, sizeof tr);
        memset(cnt, 0, sizeof cnt);
        memset(ne, 0, sizeof ne);
        idx = 0;

        // Trie树
        cin >> n;
        for (int i = 1; i <= n; i++) {
            cin >> s;
            insert(s);
        }

        // ac自动机
        build();

        // 查询
        cin >> s;
        printf("%d\n", query(s));
    }
    return 0;
}

 
其实,这是一个不断回溯的过程,在代码实现时,我们采用的是\(while\)循环或者递归来不断的向上进行查找,但这样效率上会差一些。有没有更好的办法呢?这得益于我们采用的\(bfs\)策略,我们通过自顶向下的覆盖方式,将上面的信息继承到下面去,换句话说,就是不用儿子去找父亲问,父亲再找爷爷问,这太麻烦了,而且爷爷将答案准备好,告诉父亲,父亲将答案保存好,儿子可以直接获取到。这时就不再是一个\(Trie\)树了,而是一个\(Trie\)图了,类似于并查集的路径压缩~

四、\(Trie\)图优化版本【需背诵】

#include <bits/stdc++.h>
using namespace std;

const int N = 55 * 1e4 + 10;
const int M = 1e6 + 10;

int n;
int tr[N][26], cnt[N], idx;
char s[M];
int q[N], ne[N];

// 标准的Trie树构建
void insert(char *s) {
    int p = 0;
    for (int i = 0; s[i]; i++) {
        int t = s[i] - 'a';
        if (!tr[p][t]) tr[p][t] = ++idx;
        p = tr[p][t];
    }
    cnt[p]++;
}

// 构建失配指针
void bfs() {
    int hh = 0, tt = -1;
    for (int i = 0; i < 26; i++)
        if (tr[0][i]) q[++tt] = tr[0][i];

    while (hh <= tt) {
        int p = q[hh++];
        for (int i = 0; i < 26; i++) {
            int &c = tr[p][i];        // p:父节点,c:子节点,&:引用,可以向c赋值,等同于向tr[p][i]赋值
            if (c) {                  // 如果点c存在
                ne[c] = tr[ne[p]][i]; // 为点c填充失配数组ne,当点c失配时,跳到它父亲记录好的tr[ne[p]][i]位置上去,而它的父亲对应值,是记录了祖先传递下来的,不用再回溯求递归求解
                q[++tt] = c;          // 入队列,为后续填充做准备
            } else
                c = tr[ne[p]][i]; // 如果不存在,这个位置需要不需进行记录值呢?如果不用的话,那么后面再有指望它来提供信息的,就狒狒了,既然要递推,就要保证数据的完整
                                  // 那怎么办呢?答案就是也依赖于它的真系血亲进行数据传递,说白了,就是自己这不匹配了,那么需要去哪里匹配呢?还不想用while向上递归,那就需要向下传递时记录清楚呗。
            // 这个真系血亲是和c点拥有最长公共前后缀的节点,跳到它那里去
        }
    }
    // ① 遍历完第i-1层时,会求出第i层节点的ne值(可不一定都在i-1层啊);也就是说遍历到第i层的时候第i层的ne值已知。
}

// 跑一下文本串
int query(char *s) {
    int res = 0;
    int j = 0;                   // 在Trie中游走的指针j, 从根开始对AC自动机进行查找
    for (int i = 0; s[i]; i++) { // 枚举文本串每个字符
        int t = s[i] - 'a';      // 字符映射的边序号

        while (j && !tr[j][t]) j = ne[j]; // 如果没有回退到根,并且,当前游标位置没有t这条边,继续利用失配指针回退
        if (tr[j][t]) j = tr[j][t];       // 如果命中,停下来,找到匹配的失配节点

        // 统计在当前失配节点向根游走,有多少个完整的模式串被命中
        int p = j;
        while (p && ~cnt[p]) {
            res += cnt[p]; // 记录个数
            cnt[p] = -1;   // 取消标识,一个模式串就算命中多次,也计数为1。测试用例中有重复的模式串,所以cnt[p]可能大于1
            p = ne[p];     // 不断向上回退
        }
    }
    return res;
}

int main() {
    int T;
    cin >> T;
    while (T--) {
        // 多组测试数组,初始化AC自动机
        memset(tr, 0, sizeof tr);
        memset(cnt, 0, sizeof cnt);
        memset(ne, 0, sizeof ne);
        idx = 0;

        cin >> n;
        for (int i = 0; i < n; i++) {
            cin >> s;
            insert(s); // 构造Trie树
        }
        // 利用bfs构建Trie树的fail指针,也就是AC自动机
        bfs();

        // 输入文本串开始进行查询
        cin >> s;
        printf("%d\n", query(s));
    }
    return 0;
}

五、\(Q\):为什么没有边时也需要进行赋值呢,我看原始版本没有这样的代码啊?

:其实\(AC\)自动机就是一个\(Trie+Fail\)数组构建的过程,\(bfs\)由上到下去构建,还想不用\(while\)循环防止每次都向上查询,就想着从上到下时把信息都填充完整,这样下面用时就不用担心了,(线性\(DP\)的思路啊~),也就是在第\(i\)个节点填充\(ne[i]\)时,它前面的\(i-1\)都得是已经填充完整的状态,否则无法实现转移。

那如果现在\(p\)不存在,那就啥不做的话,如果后续节点中有问它这个问题,想找是不是以\(p\)出发有边\(i\)时,它可以说没有,人家问那我继续找谁问啊,他说我也不知道,供应链条就断开了,这肯定不行。

那怎么办呢?还得在由上到下的填充过程中想办法,使得就算是不存在的位置,也需要需要退回到哪里去!

退到哪里去呢?就是从当前的视角看,就是退回到离它最近的血缘关系最近的那个节点,也就是有最长公共前后缀的节点吧~,我只能转给它来回答,至它有没有知道不知道,那是它的问题!这是采用的办法就是 李代桃僵!直接将此位置的节点号写成上面血亲的号吧!有人问时,直接就不找我了,直接找它了,呵呵,爽!但它血亲也是这么想的,也可能没有i这条边,所以,它也是抄的上面传递过的值,这样,一个抄一个,问到不知道的,其实就直接没经过它走到了它上\(N\)级血亲有\(i\)边的节点上去了,当然,也可能跳到了根。

posted @ 2022-02-03 10:21  糖豆爸爸  阅读(110)  评论(2编辑  收藏  举报
Live2D