「一本通 2.4 例 1」Keywords Search

GitHub跳转原题关系图返回列表

查询词全部插入 Trie 建 AC 自动机,扫描文章只标记到达状态,再沿 fail 树自底向上合并命中,一次 O(|S|) 扫描统计出现过的查询词个数。

OJ: roj

题目 ID: 1479

难度:普及+/提高-

标签:AC自动机字符串Triepython

创建: 2026-09-30 13:20

更新: 2026-10-07 12:15

形式化题目

给定 nn 个查询词 w1,w2,…,wnw_1, w_2, \dots, w_n(小写字母,长度不超过 5050)和一篇长为 mm 的文章 SS。对每个 wiw_i 只问一个问题:它是否在 SS 中作为连续子串出现过。输出出现过的 wiw_i 的个数——按输入逐个计数,即两个内容相同的查询词只要出现,就各算一次。多组数据,1⩽n⩽1041 \leqslant n \leqslant 10^4,1⩽m⩽1061 \leqslant m \leqslant 10^6。

输入样例:

text
1
5
she
he
say
shr
her
yasherhs

输出样例:

text
3

样例推演:文章 yasherhs 中出现的查询词是 she(位置 3–5)、he(位置 4–5)、her(位置 4–6),共 3 个;say、shr 没出现。

正解

思路

朴素做法。 对每个 wiw_i 在 SS 中单独搜一遍子串。最坏总代价 O(n⋅m⋅∣wi∣)O(n \cdot m \cdot |w_i|),104×10610^4 \times 10^6 完全不可行。瓶颈很明显:每个查询词都要把文章从头扫一遍,而且它们各自独立地重复走同一条扫描路径。

把 nn 次扫描合成一次——Trie。 把所有查询词插入同一棵 Trie,状态含义是:当前已匹配的文本后缀中,是某个查询词前缀的最长者。扫描时逐字符沿 Trie 走,一次遍历就能同时推进所有查询词的匹配进度。但 Trie 只能处理"精确沿边"的情况:在 she 的节点读到 r 时无路可走,而 yasherhs 里 sher 的正确后继是 her 的节点——回退信息缺失。

fail 指针补上回退——AC 自动机。 对每个节点定义 fail:它的最长真后缀中也是某个查询词前缀的那个节点(根为 0)。BFS 按层求 fail:处理节点 uu(父指针 pp、字符 cc)时,从 fail[p]fail[p] 出发沿 fail 链向上找第一个有 cc 边的祖先 ff,则 fail[child]=go[f][c]fail[\text{child}] = go[f][c](找不到则为根)。这样任一状态读入字符时,若 Trie 上没有对应边,就沿 fail 链回退,直到根或找到边——扫描代价被 fail 链的深度变化摊还成均摊 O(1)O(1)。

关键观察:只需标记状态,不必沿 fail 链逐次收集。 查询词 ww 出现,等价于扫描中某个时刻自动机进入了 ww 的结束节点,或者进入了以该节点为 fail 祖先的任何状态(进入 vv 就意味着 vv 的整条 fail 链上的词都以当前文本为后缀出现过)。这恰好是 fail 树上的"后代命中 ⇒ 祖先命中":先扫一遍文章,把到达过的状态标记 hit;再按 BFS 逆序(保证处理 vv 时它的 fail 后代已全部处理完)把命中沿 fail 边向上传播;最后统计所有词尾节点的 hit 之和。每个词只在其结束节点计一次,天然处理了"一个词出现多次只算一次"和"重复查询词各算一次"(cnt 在插入时累加)。

下图是样例 5 个查询词建成的自动机(实线为 Trie 边,虚线为 fail 边,橙色为词尾节点),注意 she 的 fail 指向 he、her 的 fail 也指向 he:

flowchart LR
    R((root)) --> S["s"]
    S --> SH["sh"]
    SH --> SHE["she<br/>词尾 cnt=1"]
    SH --> SHR["shr<br/>词尾 cnt=1"]
    R --> H["h"]
    H --> HE["he<br/>词尾 cnt=1"]
    HE --> HER["her<br/>词尾 cnt=1"]
    S --> SA["sa"]
    SA --> SAY["say<br/>词尾 cnt=1"]
    SHE -. fail .-> HE
    SHR -. fail .-> R
    HER -. fail .-> R
    SAY -. fail .-> R
    style SHE fill:#ffe0b2
    style HE fill:#ffe0b2
    style HER fill:#ffe0b2
    style SHR fill:#ffe0b2
    style SAY fill:#ffe0b2

用样例走一遍扫描与传播,下表列出每个字符后的状态与当步命中的词:

已读入 当前状态 当步经 fail 收集到的词 累计命中
yash sh — —
yashe she she、he(fail 链 she → he) she, he
yasher her her she, he, her
yasherh h — 同上
yasherhs sh — 同上

最终在 she、he、her 三个词尾节点上 hit=1,say、shr 未被传播覆盖,答案 3。注意"当步经 fail 链收集"只是为理解服务的等价说法,实现里不做这一步——它被第二阶段的 fail 树逆序传播替代,这正是本解法把逐字符的链上行走压成线性总代价的关键。

代码

Python 版:

python
#!/usr/bin/env python3
# Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
# rbook: -> https://rbook.roj.ac.cn  https://rbook2.roj.ac.cn
# rainboy的学习导航网站: https://idx.roj.ac.cn
# create_at: 2026-09-30 13:35
# update_at: 2026-09-30 13:35

import sys
from collections import deque


def build_automaton(words: list[bytes]) -> tuple[list[dict[int, int]], list[int], list[int], list[int]]:
    """插入查询词建 trie 并 BFS 求 fail,返回转移表 go、fail 数组、词尾计数 cnt 与 BFS 序。"""
    go: list[dict[int, int]] = [{}]   # 根是 0;每节点一个 dict,只存真实存在的边
    cnt: list[int] = [0]              # cnt[v]:以 v 结尾的查询词个数(重复词各计一次)

    for w in words:
        node = 0
        for c in w:
            nxt = go[node].get(c)
            if nxt is None:
                go[node][c] = len(go)
                go.append({})
                cnt.append(0)
                nxt = len(go) - 1
            node = nxt
        cnt[node] += 1

    fail: list[int] = [0] * len(go)
    order: list[int] = []             # BFS 出队序:深度不减,任何节点都排在自己 fail 之后
    queue = deque(go[0].values())     # 根的孩子 fail 默认就是根
    while queue:
        node = queue.popleft()
        order.append(node)
        for c, nxt in go[node].items():
            f = fail[node]            # 沿 fail 链找能接下字符 c 的祖先
            while f and c not in go[f]:
                f = fail[f]
            fail[nxt] = go[f].get(c, 0)
            queue.append(nxt)
    return go, fail, cnt, order


def count_hits(article: bytes, go: list[dict[int, int]], fail: list[int], cnt: list[int], order: list[int]) -> int:
    """扫描文章标出到达过的状态,再沿 fail 树自底向上合并命中,返回出现过的查询词个数。"""
    hit = bytearray(len(go))
    node = 0
    for c in article:
        while node and c not in go[node]:   # 没有对应边就沿 fail 链回退
            node = fail[node]
        node = go[node].get(c, 0)
        hit[node] = 1

    # 状态 v 到达 ⇒ v 的整个 fail 祖先链上的词都出现过;逆 BFS 序保证祖先后处理
    for v in reversed(order):
        if hit[v]:
            hit[fail[v]] = 1
    return sum(c for c, h in zip(cnt, hit) if h)


def solve() -> None:
    data = iter(sys.stdin.buffer.read().split())
    T = int(next(data))
    out: list[str] = []
    for _ in range(T):
        n = int(next(data))
        words = [next(data) for _ in range(n)]
        article = next(data)
        go, fail, cnt, order = build_automaton(words)
        out.append(str(count_hits(article, go, fail, cnt, order)))
    print('\n'.join(out))


if __name__ == "__main__":
    solve()

C++ 版(同一算法):

cpp
/**
 * Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
 * rbook: -> https://rbook.roj.ac.cn  https://rbook2.roj.ac.cn
 * rainboy的学习导航网站: https://idx.roj.ac.cn
 * create_at: 2026-10-05 03:32
 * update_at: 2026-10-05 03:32
 */
#include <cstdio>
#include <cstring>

typedef long long ll;

// 单组数据最多 1e4 个查询词、每词不超过 50 字符,Trie 节点数不超过 5e5+1
const int MAXNODE = 500005;
// 文章长度上限
const int MAXM = 1000005;

// 链式前向星存 Trie 边:只保存真实存在的边,不建 26 叉的完整转移表
int head[MAXNODE];      // head[u]:节点 u 的第一条出边编号,-1 表示无出边
int edge_to[MAXNODE];   // edge_to[e]:第 e 条边指向的子节点
char edge_ch[MAXNODE];  // edge_ch[e]:第 e 条边上的字符
int edge_next[MAXNODE]; // edge_next[e]:同一起点的下一条出边编号
int edge_cnt;           // 已分配的边数

int fail_arr[MAXNODE];  // fail_arr[v]:v 的最长真后缀且同时是某个查询词前缀的节点
int word_cnt[MAXNODE];  // word_cnt[v]:以 v 结尾的查询词个数,重复词各计一次
int bfs_order[MAXNODE]; // bfs_order[]:BFS 出队序列,深度不减,用于逆序传播命中
char hit[MAXNODE];      // hit[v]:扫描文章时是否到达过状态 v,取值只有 0/1,用 char 省内存
int node_cnt;           // 当前 Trie 的节点数

char article[MAXM]; // 文章
char word[64];      // 单个查询词,长度不超过 50

// 在节点 u 的出边中查找字符 c 指向的子节点,找不到返回 -1
int find_child(int u, int c) {
    for (int e = head[u]; e != -1; e = edge_next[e]) {
        if (edge_ch[e] == c) {
            return edge_to[e];
        }
    }
    return -1;
}

// 新建一条从 u 到 v、边上字符为 c 的 Trie 边
void add_edge(int u, int v, int c) {
    edge_to[edge_cnt] = v;
    edge_ch[edge_cnt] = c;
    edge_next[edge_cnt] = head[u];
    head[u] = edge_cnt;
    edge_cnt++;
}

// 初始化一个空节点并返回它的编号
int new_node() {
    int v = node_cnt;
    head[v] = -1;
    word_cnt[v] = 0;
    fail_arr[v] = 0;
    node_cnt++;
    return v;
}

// 把所有查询词插入 Trie:状态含义是“当前文本后缀中,是某个查询词前缀的最长者”
void insert_word() {
    int u = 0;
    for (int i = 0; word[i] != '\0'; i++) {
        int c = word[i];
        int v = find_child(u, c);
        if (v == -1) {
            v = new_node();
            add_edge(u, v, c);
        }
        u = v;
    }
    word_cnt[u] += 1; // 内容相同的查询词要各算一次,所以累加而不是置 1
}

// BFS 按层求 fail:处理节点 u 的孩子 v(字符 c)时,
// 从 fail_arr[u] 出发沿 fail 链找第一个有 c 边的祖先 f,则 fail_arr[v] 就是 f 的 c 边指向的节点
void build_fail() {
    int qhead = 0;
    int qtail = 0;
    // 根的孩子没有更短的真后缀,fail 直接指向根
    for (int e = head[0]; e != -1; e = edge_next[e]) {
        int v = edge_to[e];
        fail_arr[v] = 0;
        bfs_order[qtail] = v;
        qtail++;
    }
    while (qhead < qtail) {
        int u = bfs_order[qhead];
        qhead++;
        for (int e = head[u]; e != -1; e = edge_next[e]) {
            int v = edge_to[e];
            int c = edge_ch[e];
            int f = fail_arr[u];
            int found = find_child(f, c);
            while (f != 0 && found == -1) {
                f = fail_arr[f];
                found = find_child(f, c);
            }
            fail_arr[v] = (found == -1 ? 0 : found);
            bfs_order[qtail] = v;
            qtail++;
        }
    }
}

// 扫描文章标出到达过的状态,再按 BFS 逆序把命中沿 fail 边向上传播,最后统计词尾节点的命中
int scan_article() {
    memset(hit, 0, node_cnt); // 只清理本组数据实际用到的节点范围
    int u = 0;
    for (int i = 0; article[i] != '\0'; i++) {
        int c = article[i];
        int found = find_child(u, c);
        while (u != 0 && found == -1) { // Trie 上没有对应边就沿 fail 链回退
            u = fail_arr[u];
            found = find_child(u, c);
        }
        if (found != -1) {
            u = found;
        }
        hit[u] = 1;
    }

    // 到达状态 v 说明 v 的整条 fail 祖先链上的查询词都以当前文本为后缀出现过;
    // 逆 BFS 序保证处理 v 时它的 fail 祖先还没被传播,可以把命中继续往上传
    for (int i = node_cnt - 2; i >= 0; i--) {
        int v = bfs_order[i];
        if (hit[v]) {
            hit[fail_arr[v]] = 1;
        }
    }

    int ans = 0;
    for (int v = 1; v < node_cnt; v++) {
        if (hit[v]) {
            ans += word_cnt[v];
        }
    }
    return ans;
}

int main() {
    ll T;
    scanf("%lld", &T);
    while (T--) {
        ll n;
        scanf("%lld", &n);
        edge_cnt = 0;
        node_cnt = 0;
        new_node(); // 根节点编号为 0
        for (ll i = 0; i < n; i++) {
            scanf("%s", word);
            insert_word();
        }
        scanf("%s", article);
        build_fail();
        printf("%d\n", scan_article());
    }
    return 0;
}

复杂度

  • 时间复杂度:建自动机 O(∑i∣wi∣)O(\sum_i |w_i|)(每节点只对自己的真实出边求 fail),扫描 O(m)O(m)(fail 链回退均摊 O(1)O(1)),fail 树传播 O(节点数)=O(∑i∣wi∣)O(\text{节点数}) = O(\sum_i |w_i|),总计 O(∑i∣wi∣+m)O\left(\sum_i |w_i| + m\right)。
  • 空间复杂度:O(∑i∣wi∣)O\left(\sum_i |w_i|\right),每节点一个只存真实边的 dict,加 fail/cnt/hit 三个线性数组。

总结

  • 朴素做法逐词搜文章,O(nm)O(nm);合并扫描的关键是把所有查询词放进同一棵 Trie,一次遍历同时推进全部匹配。
  • Trie 缺回退信息,AC 自动机用 fail 指针补上:BFS 按"父的 fail 链上找同字符边"求 fail,扫描时无边即回退,均摊 O(1)O(1)。
  • 统计不必逐字符走 fail 链收集:查询词出现   ⟺  \iff 某个到达状态位于其结束节点的 fail 子树中,于是先标记到达状态,再按 BFS 逆序沿 fail 向上传播,最后读词尾节点的 hit。
  • 每词只在自己的结束节点计一次,重复查询词用 cnt 累加,天然满足"出现多次算一次、内容相同的查询词各算一次"。