AcWing 1282. 搜索关键词
. 搜索关键词
一、题目描述
给定 个长度不超过 的由小写英文字母组成的单词,以及一篇长为 的文章。
请问,其中有多少个单词在文章中出现了。
注意:每个单词不论在文章中出现多少次,仅累计 次。
输入格式
第一行包含整数 ,表示共有 组测试数据。
对于每组数据,第一行一个整数 ,接下去 行表示 个单词,最后一行输入一个字符串,表示文章。
输出格式
对于每组数据,输出一个占一行的整数,表示有多少个单词在文章中出现。
数据范围
输入样例:
1
5
she
he
say
shr
her
yasherhs
输出样例:
3
二、自动机图解
1、自动机与的区别
算法 | 使用场景 |
---|---|
单文本串,单模式串 | |
自动机 | 单文本串,多模式串 |
2、推荐视频
3、算法步骤
自动机:在树的基础上,增加一个 失配 时用的fail
指针,如果当前点匹配失败,则将指针转移到fail
指针指向的地方,这样就可以不用重头再来, 尽可能的利用已经知道的信息,能少退就少退 ,fail
指针的构建用 bfs
实现。
- ① 根据多个模式串建立树
- ② 构建失配指针
- ③ 在构建的自动机上跑文本串
举栗子说明

只需要 遍历一遍文本串 就 找出所有单词表中存在的单词:
先根据模式串集合{she
,he
,say
,shr
,her
}建立树,如图,然后拿着yasherhs
去匹配:
- 发现前两个字符无法匹配,跳过
- 第三个字符开始,
she
可以匹配,记录下来,ans++
,继续往后走发现没有匹配了,结果就是该文本串只存在一个单词she
,很显然,答案是错的,因为存在she
、he
、her
三个单词!
可以发现的是使用文本串在字典树上进行匹配的时候,找到了一个单词结点后还应该看看有没有以该单词结点的后缀为 前缀 的其他单词,比如she
的后缀he
是单词he
和her
的前缀。因此就需要一个 失配指针 在发现失配的时候指向其他存在e
的结点,就通过fail
指针的指引,跳到这个位置,此时发现e
有黄色标识,就是又找到一个模式串he
,继续下一个字符,是,发现后续可以匹配,匹配her
了...
总的来说,自动机中失配指针和中数组的作用是一致的,就是 要想在只遍历一遍文本串的前提下,找到全部匹配模式串,就必须提前安排好匹配过程中失配后怎么办。如果在在树上构造 失配指针 ,就是学习如何构造自动机。
4、构建失配指针(构造自动机)
办法如下:
- 根结点的指针为空(或者它自己)
注释:类比,模式串的第一位,,退无可退,无法再退
-
直接和根结点相连的结点,如果这些结点失配,就只能重新开始匹配,故它们的
ne
指针指向根结点 -
其它结点,设当前结点为
father
,其孩子结点为child
。要寻找child
的ne
指针,需要看father
的ne
指针指向的结点,假设是tmp
,要看tmp
的孩子中有没有和child
所代表的字符相同的,有则child
的ne
指针指向tmp
的这个孩子结点,没有则继续沿着tmp
的ne
指针往上走,如果找到相同,就指向,如果一直找到了根结点的ne
也就是空的时候,child
的ne
指针就指向root
,表示重新从根结点开始匹配。
考察father
的ne
指针指向的结点有没有和child
相同的结点,否则继续往上,就保证了前缀是相同的,比如刚才寻找右侧h
的孩子结点e
的fail
指针时,找到右侧h
的fail
指针指向左侧的h
结点,他的孩子中有e
,就将右侧h
的孩子e
的fail
指针指向它就保证了前缀h
是相同的。
这样,就用fail
指针来安排好每次失配后应该跳到哪里,而fail
指针跳到哪里,说明从根结点到这个结点之前的字符串已经匹配过了,从而避免了重复匹配,也就解决了只遍历一次文本串就找出所有单词的问题。
5、匹配过程
匹配就很简单了,这里以 ashw
为例:
先用 ash
匹配,到 h
了发现,ash
是一个完整的模式串,则,然后找下一个 w
,可是 ash
后面没字母了呀,我们就跳到 h
的 指针指向的那个 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;
}
其实,这是一个不断回溯的过程,在代码实现时,我们采用的是循环或者递归来不断的向上进行查找,但这样效率上会差一些。有没有更好的办法呢?这得益于我们采用的策略,我们通过自顶向下的覆盖方式,将上面的信息继承到下面去,换句话说,就是不用儿子去找父亲问,父亲再找爷爷问,这太麻烦了,而且爷爷将答案准备好,告诉父亲,父亲将答案保存好,儿子可以直接获取到。这时就不再是一个树了,而是一个图了,类似于并查集的路径压缩~
四、图优化版本【需背诵】
#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;
}
五、:为什么没有边时也需要进行赋值呢,我看原始版本没有这样的代码啊?
答:其实自动机就是一个数组构建的过程,由上到下去构建,还想不用循环防止每次都向上查询,就想着从上到下时把信息都填充完整,这样下面用时就不用担心了,(线性的思路啊~),也就是在第个节点填充时,它前面的都得是已经填充完整的状态,否则无法实现转移。
那如果现在不存在,那就啥不做的话,如果后续节点中有问它这个问题,想找是不是以出发有边时,它可以说没有,人家问那我继续找谁问啊,他说我也不知道,供应链条就断开了,这肯定不行。
那怎么办呢?还得在由上到下的填充过程中想办法,使得就算是不存在的位置,也需要需要退回到哪里去!
退到哪里去呢?就是从当前的视角看,就是退回到离它最近的血缘关系最近的那个节点,也就是有最长公共前后缀的节点吧~,我只能转给它来回答,至它有没有知道不知道,那是它的问题!这是采用的办法就是 李代桃僵!直接将此位置的节点号写成上面血亲的号吧!有人问时,直接就不找我了,直接找它了,呵呵,爽!但它血亲也是这么想的,也可能没有i这条边,所以,它也是抄的上面传递过的值,这样,一个抄一个,问到不知道的,其实就直接没经过它走到了它上级血亲有边的节点上去了,当然,也可能跳到了根。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· 没有Manus邀请码?试试免邀请码的MGX或者开源的OpenManus吧
· 【自荐】一款简洁、开源的在线白板工具 Drawnix
· 园子的第一款AI主题卫衣上架——"HELLO! HOW CAN I ASSIST YOU TODAY
· Docker 太简单,K8s 太复杂?w7panel 让容器管理更轻松!
2020-02-03 在用户名及密码的保存中加点“盐”
2018-02-03 试题录入办法
2018-02-03 开始学习MaxCompute
2012-02-03 稳定的NTP时间同步服务器集群:ntp.api.bz
2012-02-03 快速配置 Samba 将 Linux 目录映射为 Windows 驱动器
2012-02-03 找到一款不错的网站压力测试工具webbench[原创]
2012-02-03 谈一谈JVM内存JAVA_OPTS参数