形式化题目

给定 nn 条替换规则 (si,1,si,2)(s_{i,1},s_{i,2}),每条规则的两个字符串等长。每次询问给出两个不同的字符串 (t1,t2)(t_1,t_2),统计有多少个「规则编号、替换起点」,使得在 t1t_1 中使用该规则恰好替换一次后得到 t2t_2。

相同的字符串二元组如果编号不同,仍然是不同规则。替换区间外的字符保持不变,不能连续使用多条规则。

交互学习

以下教程均为可离线打开的单文件 HTML,支持单步、回退与回看。解法三按“先看核心图、再演示和预测”的顺序,承接已经理解的双 Trie 匹配条件。

  • 解法一 · 双 Trie 交互课:七关走通主解 main.cpp,从差异对齐、前后缀分解、map 核心分组,到双 Trie 反转、DFS 激活与撤销、右祖先链求和。与解法路线的建议一致,先学这一门。
  • 解法二 · AC 交互课:八关走通 main2.cpp,从合法替换、字符对匹配,走到 fail 树、DFS 序和离线计数。
  • 解法三 · 双 Trie 到矩形扫描线:联动两棵 Trie 的子树区间与二维矩形,先直接数覆盖,再移动扫描线观察纵坐标贡献,最后对应 main3.cpp 的事件、差分与树状数组。

解法路线

沿用题面的定义:L1L_1 是所有规则的左右串总长度,L2L_2 是所有询问的两串总长度。它们都包含二元组中的两个字符串。下文的位置统一从 00 开始。

题面中的子任务分为小数据、单询问、特殊字符串和一般大数据,适合按下面的顺序学习。

层次 对应测试点与约束 做法 时间复杂度
第一层 1,21,2:n,q⩽100n,q\leqslant100,L1,L2⩽200L_1,L_2\leqslant200 枚举规则和起点,逐位检查 O(L1+L2+n∑jmj2)O(L_1+L_2+n\sum_j m_j^2)
第二层 3∼53\sim5:n,q⩽1000n,q\leqslant1000,L1,L2⩽2000L_1,L_2\leqslant2000 用首个差异确定唯一替换起点 O(L1+L2+qL1)O(L_1+L_2+qL_1)
第三层 性质 A:q=1q=1;覆盖 6,7,8,13,146,7,8,13,14 继续使用第二层,逐条规则检查一次 O(L1+L2)O(L_1+L_2)
第四层 性质 B:每个串恰好有一个 b,其余为 a;覆盖 6,9,10,15,166,9,10,15,16 按 b 的位移分组,二维偏序计数 O(L1+L2+(n+q)log⁡(n+q))O(L_1+L_2+(n+q)\log(n+q))
第五层,解法一(主解) 一般数据:n,q⩽2×105n,q\leqslant2\times10^5,L1,L2⩽5×106L_1,L_2\leqslant5\times10^6 map 精确分组 + 每组普通双 Trie + DFS 激活与祖先链求和 O((L1+L2)log⁡(n+1))O((L_1+L_2)\log(n+1)),字母表固定
第五层,解法二 同上,适用于全部数据 字符对 AC 自动机 + fail 树 + 离线树状数组 O((L1+L2)log⁡(L1+L2))O((L_1+L_2)\log(L_1+L_2))
第五层,解法三 同上,适用于全部数据 差异核心分组 + 左右 Trie + 二维扫描线 O((L1+L2)log⁡(L1+L2))O((L_1+L_2)\log(L_1+L_2))
第五层,解法四 同上,适用于全部数据 离线分桶建 Trie + DFS 激活 + 祖先链求和 O(L1+L2)O(L_1+L_2),字母表固定

表中 mj=∣tj,1∣m_j=|t_{j,1}|;长度不等的询问直接回答 00。测试点 11,1211,12 已经没有特殊性质,虽然总长度上限只有 2×1062\times10^6,仍需要一般数据的做法。

性质 A 和性质 B 是从第二层分出的两条支路。第五层有四种完整解法:解法一作为主解,使用 map 精确分组和每组双 Trie 的 DFS 计数,代码为 main.cpp;AC、二维扫描线、线性分桶做法分别作为解法二、三、四,对应 main2.cpp、main3.cpp、main4.cpp。建议先掌握解法一,再按需要学习其他实现。

暴力解法

适用范围

测试点 1,21,2,字符串总长度很小。

思路

每次询问枚举规则 ii,再枚举替换起点 pp。设规则长度为 kk,检查:

  1. t1[p…p+k−1]=si,1t_1[p\ldots p+k-1]=s_{i,1};
  2. 区间内替换成 si,2s_{i,2},区间外保持 t1t_1 的原字符,得到的整个字符串是否等于 t2t_2。

每通过一次检查,答案加 11。这个做法直接对应题目定义,可以作为后面所有算法的小数据验证基准。

代码

brute.cpp 逐位检查替换后的字符,不需要真正构造新字符串。

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-06-22 19:52
 * update_at: 2026-10-15 11:26
 */
// brute.cpp:小数据暴力解,用来帮助理解题意并辅助对拍。
// 枚举每条规则和每个起点,检查替换后是否等于目标串,只适合很小的数据。
//
// ==================== 教学导读 ====================
// 【题目在问什么】给定 n 条规则 (s1, s2)(等长),每次询问给两个不等的等长串
// t1、t2,问有多少种「方案」把 t1 变成 t2。一个方案 = (规则编号 id, 替换起点 start),
// 即把 t1[start .. start+|s1|-1] 换成 s2。不同 id 或不同 start 都算不同方案。
//
// 【暴力思路】不放过任何可能性:双重循环枚举所有 (id, start) 对,对每一对
// 模拟这次替换,看结果是否恰好等于 t2。因为题目要求“恰好一次替换”,
// 所以 (id, start) 就是方案的完整描述,枚举它们就等于枚举了所有方案。
//
// 【为什么正确】can_replace_at 做了两件完整的事:
//   ① 确认 t1 的 [start, start+len) 真的等于规则左串(否则这次替换根本不合法);
//   ② 把区间换成规则右串后,整个结果串逐位和 t2 比较。
// 只要有一对 (id, start) 通过检查,答案就 +1,不多不少。
//
// 【复杂度】O(q * n * m * len),m 是询问串长。只够测试点 1、2 的小数据,
// 但它是最容易看懂的版本,也是后面所有优化的正确性参照(对拍用)。
#include <bits/stdc++.h>
using namespace std;

typedef long long ll;

const int MAXN = 105; // 只服务测试点 1、2 的小数据

int n, q;
string left_part[MAXN];  // 规则 i 的替换前串 s1
string right_part[MAXN]; // 规则 i 的替换后串 s2

// 检查把 a 的 [start, start+len) 子串用规则 id 替换后是否恰好等于 b。
// 先验证被替换区间与规则左串逐位相同,再验证替换后的整串与 b 逐位相同。
// 返回 true 就代表 (id, start) 是一个合法方案。
bool can_replace_at(const string &a, const string &b, int id, int start) {
    int len = left_part[id].size();
    int alen = a.size();
    if (start + len > alen) {
        return false; // 替换区间越出串尾,不合法
    }

    // 被替换区间必须与规则的替换前串完全相同
    for (int i = 0; i < len; i++) {
        if (a[start + i] != left_part[id][i]) {
            return false;
        }
    }

    // 替换区间外保持原样,区间内换成替换后串,逐位和 b 比较
    for (int i = 0; i < alen; i++) {
        char after_char = a[i];
        if (start <= i && i < start + len) {
            after_char = right_part[id][i - start];
        }
        if (after_char != b[i]) {
            return false;
        }
    }

    return true;
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    cin >> n >> q;
    // 规则编号从 1 开始,和题面一致
    for (int i = 1; i <= n; i++) {
        cin >> left_part[i] >> right_part[i];
    }

    // 每个询问独立枚举「规则编号 + 替换起点」这一对,复杂度 O(q * n * len^2)。
    // 这一层枚举规则,里层枚举起点:两层合起来就是枚举所有可能的方案。
    while (q--) {
        string a, b;
        cin >> a >> b;

        ll ans = 0;
        // 替换不改变长度,长度不同必然无解
        if (a.size() == b.size()) {
            int alen = a.size();
            for (int id = 1; id <= n; id++) {
                int plen = left_part[id].size();
                for (int start = 0; start + plen <= alen; start++) {
                    if (can_replace_at(a, b, id, start)) {
                        ans++;
                    }
                }
            }
        }
        cout << ans << '\n';
    }

    return 0;
}

复杂度与瓶颈

对于长度为 mm 的询问,每条规则最多枚举 mm 个起点,每个起点还要花 O(m)O(m) 检查,所以单次询问最坏为 O(nm2)O(nm^2)。

总时间为 O(L1+L2+n∑jmj2)O(L_1+L_2+n\sum_j m_j^2),可粗略上界为 O(L1+L2+nL22)O(L_1+L_2+nL_2^2);空间为 O(L1+max⁡jmj)O(L_1+\max_j m_j)。

这里有两重浪费:枚举了许多根本不可能的起点,又在每个起点重新检查整个目标串。下一层先把起点确定下来。

子任务解法:L1,L2⩽2000L_1,L_2\leqslant2000

适用范围

测试点 3∼53\sim5。这个算法本身对任意合法输入都正确,只是在大数据下可能超时。

思路

先找出询问中真正需要改变的部分。

若 ∣t1∣≠∣t2∣|t_1|\neq|t_2|,答案为 00。否则,设 LL 是第一个不同位置,RR 是最后一个不同位置。任何合法替换区间都必须覆盖 [L,R][L,R]:区间外不能改变,因此不能把某个不同位置留在外面。

注意,合法区间可以比 [L,R][L,R] 更长。规则中允许出现左右字符相同的位置,所以不能只匹配恰好等于 [L,R][L,R] 的规则。

再利用规则自己的第一个差异。

对规则 ii,预处理它左右串的第一个不同位置 did_i。若左右串完全相同,这条规则不能把 t1t_1 变成不同的 t2t_2,直接忽略。

假设从 pp 开始替换。规则在 did_i 之前不改变任何字符,在 did_i 处第一次改变字符;区间外也不改变。因此,替换后整个字符串的第一个不同位置一定是 p+dip+d_i。

它必须等于询问的 LL,于是:

p=L−di. p=L-d_i.

一条规则只有一个可能的起点,不需要枚举位置。 因此,同一规则在原串中即使出现多次,对于给定的不同目标串也至多贡献一个方案。

确定 pp 后,设规则长度为 kk,依次检查:

  • p⩾0p\geqslant0 且 p+k⩽∣t1∣p+k\leqslant|t_1|,替换区间没有越界;
  • p+k−1⩾Rp+k-1\geqslant R,覆盖最后一个不同位置;
  • 区间中的每一位同时满足 t1[p+h]=si,1[h]t_1[p+h]=s_{i,1}[h]、t2[p+h]=si,2[h]t_2[p+h]=s_{i,2}[h]。

因为 di⩾0d_i\geqslant0,已经有 p⩽Lp\leqslant L。再结合覆盖 RR,区间外必然没有不同字符;区间内的双串比较保证替换完全吻合。因此上述条件既必要又充分,不必再次检查整串。

下面用样例 1 的询问 xabcx → xadex 看唯一对齐的结果。这里 L=2,R=3L=2,R=3。

规则 首个差异 did_i 唯一起点 p=L−dip=L-d_i 替换区间 是否可行
xabcx → xadex 22 00 [0,4][0,4] 可行,整串替换
ab → cd 00 22 [2,3][2,3] 不可行,原串这里是 bc
bc → de 00 22 [2,3][2,3] 可行
aa → bb 00 22 [2,3][2,3] 不可行,原串这里是 bc

每行只检查一个起点。整串规则虽然包含两端不变的 x,仍然合法;这也说明只检查最短差异区间会漏算。两条可行规则给出答案 22。

代码

anchor.cpp 预处理每条规则的 first_diff,每次询问只比较该规则唯一对齐的区间。

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-14 23:06
 * update_at: 2026-10-15 11:26
 */
// anchor.cpp:首个差异对齐,每条规则只检查一个起点。
// 适用于中等数据及所有 q=1 的数据,一般数据下仍需逐询问扫描所有规则。
//
// ==================== 教学导读 ====================
// 【题目在问什么】给定 n 条规则 (s1, s2)(等长),每次询问给两个不等的等长串
// t1、t2,问有多少种「方案」把 t1 变成 t2。一个方案 = (规则编号 i, 替换起点 p),
// 即把 t1[p..p+|s1|-1] 这个子串换成 s2。不同规则或不同起点都算不同方案。
//
// 【关键观察:首差对齐】t1 和 t2 只有在替换区间内才会不同,所以区间必须覆盖
// 所有不同位置。设规则左右串第一个不同位置是 d(0-based),询问两串第一个不同
// 位置是 L。替换起点是 p 时,规则的第 d 个字符恰好落在 t1 的 L 位置上,即
//         p + d = L   =>   p = L - d      (起点被唯一确定!)
// 于是每条规则最多只需要检查一个起点,不必枚举所有起点。
//
// 【怎么检查】起点 p 确定后要求:
//   ① 区间 [p, p+len-1] 不越界,且覆盖最后一个不同位置 R;
//   ② 区间内 t1 逐位等于规则左串、t2 逐位等于规则右串(区间外自动相同)。
// 两条都满足,这条规则就贡献一个方案。
//
// 【复杂度】预处理 O(L1);每个询问 O(m + n * len),总计 O(L1 + L2 + q * L1)。
// q = 1 时是线性的,但一般数据下 q 很大,需要后面的 Trie 分组做法。
#include <bits/stdc++.h>
using namespace std;

typedef long long ll;

struct Rule {
    string a, b;   // 规则的替换前串、替换后串
    ll first_diff; // 规则左右串第一个不同位置,0-based;-1 表示不改变字符
};

ll n, q;
vector<Rule> rules; // 所有规则,编号从 0 开始

// 判断规则 rule 能否在 t1=a 中恰好替换一次得到 t2=b。
// [L, R] 是 a、b 的首尾不同位置;规则若生效,替换起点只能由 L - first_diff 唯一确定。
// 随后要求整个替换区间落在串内、覆盖 R,并且区间内 a 匹配左串、b 匹配右串。
bool check_rule(const Rule &rule, const string &a, const string &b, ll L, ll R) {
    if (rule.first_diff == -1) return false;

    ll len = rule.a.size();
    ll m = a.size();
    ll start = L - rule.first_diff;
    // 起点唯一,且替换区间必须在串内并覆盖最后一个不同位置。
    if (start < 0 || start + len > m || start + len - 1 < R) return false;

    for (ll i = 0; i < len; i++) {
        if (a[start + i] != rule.a[i] || b[start + i] != rule.b[i]) return false;
    }
    return true;
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    cin >> n >> q;
    rules.resize(n);
    // 预处理每条规则的第一个差异位置,供 check_rule 反推唯一替换起点
    for (ll i = 0; i < n; i++) {
        cin >> rules[i].a >> rules[i].b;
        rules[i].first_diff = -1;
        ll len = rules[i].a.size();
        for (ll j = 0; j < len; j++) {
            if (rules[i].a[j] != rules[i].b[j]) {
                rules[i].first_diff = j;
                break;
            }
        }
    }

    while (q--) {
        string a, b;
        cin >> a >> b;
        // 替换不改变长度
        if (a.size() != b.size()) {
            cout << 0 << '\n';
            continue;
        }
        // 先求出询问两串的首尾不同位置 [L, R]:
        // 替换区间必须覆盖 [L, R],否则区间外的差异改不掉。
        ll L = -1, R = -1;
        ll m = a.size();
        for (ll i = 0; i < m; i++) {
            if (a[i] != b[i]) {
                if (L == -1) L = i;
                R = i;
            }
        }
        ll answer = 0;
        // L = -1 表示两串完全相同,而题目保证 t1 != t2,答案必为 0。
        // 否则逐条规则检查那个「唯一可能的起点」,每条规则对答案至多贡献 1。
        if (L != -1) {
            for (ll i = 0; i < n; i++) {
                if (check_rule(rules[i], a, b, L, R)) answer++;
            }
        }
        cout << answer << '\n';
    }
    return 0;
}

复杂度与瓶颈

预处理所有规则耗时 O(L1)O(L_1),找出所有询问的 L,RL,R 共耗时 O(L2)O(L_2)。一个询问逐条检查规则,比较次数不超过所有规则的单侧总长度,即 O(L1)O(L_1)。

总时间为:

O(L1+L2+qL1). O(L_1+L_2+qL_1).

空间为 O(L1+max⁡jmj)O(L_1+\max_j m_j)。在 L1⩽2000,q⩽1000L_1\leqslant2000,q\leqslant1000 时,qL1qL_1 只有约 2×1062\times10^6 的量级。

起点枚举已经消失,但每个询问仍要重新扫描所有规则。下一层先看看,题面给出的特殊性质能否消除这项乘积。

子任务解法:特殊性质 A,q=1q=1

适用范围

测试点 6,7,8,13,146,7,8,13,14。其中 66 同时具有性质 B,但仅凭 q=1q=1 就能使用本层做法。

思路

无需设计新算法,把第二层的复杂度代入 q=1q=1:

O(L1+L2+qL1)=O(L1+L2). O(L_1+L_2+qL_1)=O(L_1+L_2).

虽然规则可能很多、字符串可能很长,但所有规则的长度加起来有上界。逐条规则检查唯一候选区间,每个规则字符最多比较一次,总工作量仍然是线性的。

这就是性质 A 的作用:它允许我们花一次 O(L1)O(L_1) 的时间扫描规则。它没有限制规则的内容,也没有限制差异区间长度。

代码

同上一层的 anchor.cpp,直接输入 q=1q=1 即可,不单独给出重复代码。

复杂度与瓶颈

时间 O(L1+L2)O(L_1+L_2),空间 O(L1+L2)O(L_1+L_2),可以处理总长度达到 5×1065\times10^6 的性质 A 数据。

当询问很多时,qL1qL_1 又会出现。因此,「总长度很大」本身不是瓶颈,「同一批规则被反复扫描」才是后面需要解决的问题。

子任务解法:特殊性质 B,每个串恰好有一个 b

适用范围

测试点 6,9,10,15,166,9,10,15,16,规则串和询问串都只含 a,b,且各自恰好有一个 b。

思路

把字符串压缩成长度和 b 的位置。

一条规则用 (k,x,y)(k,x,y) 表示:长度为 kk,替换前的 b 在 xx,替换后的 b 在 yy。一个等长询问用 (m,p,r)(m,p,r) 表示:长度为 mm,原串的 b 在 pp,目标串的 b 在 rr。

若询问长度不同,答案为 00。等长且两串不同,必有 p≠rp\neq r;x=yx=y 的规则不改变字符串,可以忽略。

b 的位置决定了唯一的替换起点。

若替换区间不包含原串的 b,原来的 b 会留在外面,而规则输出又带有一个 b,不可能得到目标串。因此规则中的原 b 必须对齐询问的原 b,替换起点为:

a=p−x. a=p-x.

替换后的 b 位于 a+ya+y,所以还必须满足:

a+y=r⟺y−x=r−p. a+y=r\quad\Longleftrightarrow\quad y-x=r-p.

记这个位移为 Δ\Delta。只有位移相同的规则和询问才能配对,先按 Δ\Delta 分组。

剩余条件只是左右空间够不够。

规则中原 b 左侧有 xx 个 a,右侧有 k−1−xk-1-x 个 a;询问的原 b 两侧分别有 pp 和 m−1−pm-1-p 个 a。区间不越界恰好要求:

x⩽p,k−1−x⩽m−1−p. x\leqslant p,\qquad k-1-x\leqslant m-1-p.

位移条件已经保证新的 b 在目标位置;其余所有位置都是 a。所以这些条件满足后,无需再逐字符比较。

于是,一条规则是二维点 (x,k−1−x)(x,k-1-x),一个询问是上界 (p,m−1−p)(p,m-1-p)。在同一个 Δ\Delta 组内,统计有多少个规则点在询问的左下矩形中。这就是二维偏序计数。

排序消掉一维,树状数组处理另一维。

把规则和询问都按 Δ\Delta、第一维从小到大排序,同一第一维上先处理规则。扫描时:

  1. 遇到规则,把它的第二维在树状数组中加 11;
  2. 遇到询问,查询第二维不超过其上界的前缀和;
  3. 切换 Δ\Delta 组前,撤销本组加入的规则。

第一维条件由扫描顺序保证,第二维条件由前缀和保证。第二维先离散化,重复规则作为多个点加入,不去重。

例如规则 abaa → aaba 有 k=4,x=1,y=2k=4,x=1,y=2,位移为 11,对应点 (1,2)(1,2)。询问 aabaaa → aaabaa 有 m=6,p=2,r=3m=6,p=2,r=3,同样位移为 11,对应上界 (2,3)(2,3)。因为 1⩽21\leqslant2 且 2⩽32\leqslant3,规则可以从 a=2−1=1a=2-1=1 开始替换。

代码

special_b.cpp 只适用于性质 B。代码用 id=0 表示规则事件,用正数表示询问编号,并在每组结束后撤销树状数组的更新。

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-14 23:06
 * update_at: 2026-10-15 11:26
 */
// special_b.cpp:仅适用于特殊性质 B:每个字符串恰好有一个 b,其余全为 a。
// 按 b 的位移分组,二维偏序用排序扫描和树状数组计数。
//
// ==================== 教学导读 ====================
// 【性质 B 的字符串长什么样】形如 aaabaaaa:只有一个 'b',其余全是 'a'。
// 一条规则 (s1, s2) 能用,当且仅当把 s1 里的 b 挪到新位置(其余字符不变)
// 恰好得到 s2。所以规则可以压缩成三个数:
//   delta = b 的位移(目标位置 - 原位置)、left = b 左边的 a 个数、right = 右边的 a 个数。
//
// 【规则何时能回答一个询问】设询问里 b 从位置 p 挪到 r(同样记 delta、left、right):
//   ① delta 必须相同:整串的 b 要挪同样的距离才能对上;
//   ② 规则的 left <= 询问的 left:替换区间左边不能越过 t1 的开头(P 是 X 的后缀);
//   ③ 规则的 right <= 询问的 right:替换区间右边不能越过 t1 的结尾(S 是 Y 的前缀)。
// 三个条件合起来:同 delta 组内是一个二维偏序计数(left、right 都要 <=)。
//
// 【怎么数】把所有规则和询问都变成事件,按 delta 分组;组内按 left 升序扫描,
// 扫到规则就把它的 right 加进树状数组,扫到询问就查 right <= 询问 right 的规则数。
// left 相等时规则排在询问前面,保证统计的是 "<=" 而不是 "<"。
//
// 【复杂度】O(L1 + L2 + (n+q) log(n+q)),只在性质 B 的数据上使用。
#include <bits/stdc++.h>
using namespace std;

typedef long long ll;

struct Event {
    ll delta; // b 的位移:目标位置 - 原位置(规则和询问必须相同才能互相匹配)
    ll left;  // 规则中 b 左侧的 a 数,或询问允许的上界(规则 left <= 询问 left)
    ll right; // 规则中 b 右侧的 a 数,或询问允许的上界(规则 right <= 询问 right)
    ll id;    // 0 表示规则,正数表示询问编号
};

ll n, q;
vector<Event> events;    // 规则事件 + 询问事件,按 delta 分组扫描
vector<ll> coordinates;  // 所有规则的 right,用于离散化
vector<ll> bit, answer;  // bit 是树状数组;answer[id] 是第 id 个询问的答案

// 事件排序:先按 delta 分组,组内按 left 升序,left 相同时规则排在询问前面,
// 从而保证统计的是 left_rule <= left_query。
bool cmp_event(const Event &a, const Event &b) {
    if (a.delta != b.delta) return a.delta < b.delta;
    if (a.left != b.left) return a.left < b.left;
    return a.id < b.id; // left 相等时先加入规则,保证统计的是 <=
}

// 性质 B 保证每个串恰好有一个 'b',返回它的下标;找不到返回 -1
ll find_b(const string &s) {
    ll len = s.size();
    for (ll i = 0; i < len; i++) {
        if (s[i] == 'b') return i;
    }
    return -1;
}

// 树状数组:按离散化后的 right 值单点加、前缀查
void bit_add(ll pos, ll value) {
    ll size = coordinates.size();
    for (; pos <= size; pos += pos & -pos) bit[pos] += value;
}

ll bit_query(ll pos) {
    ll result = 0;
    for (; pos > 0; pos -= pos & -pos) result += bit[pos];
    return result;
}

// 把规则事件的 right 离散化后计入树状数组;value=1 加入,value=-1 撤销
void add_rule(const Event &event, ll value) {
    ll pos = lower_bound(coordinates.begin(), coordinates.end(), event.right)
             - coordinates.begin() + 1;
    bit_add(pos, value);
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    cin >> n >> q;
    // 规则:记录 b 的位移 delta、左侧 a 数 left、右侧 a 数 right。
    // x == y 说明规则不改变字符串,不可能把 t1 变成不同的 t2,直接丢掉。
    for (ll i = 1; i <= n; i++) {
        string a, b;
        cin >> a >> b;
        ll x = find_b(a), y = find_b(b);
        if (x == y) continue; // 不改变字符串的规则不能回答 t1 != t2
        ll len = a.size();
        Event event;
        event.delta = y - x;
        event.left = x;
        event.right = len - 1 - x;
        event.id = 0;
        events.push_back(event);
        coordinates.push_back(event.right);
    }

    answer.assign(q + 1, 0);
    // 询问:把询问也转成同构事件,左、右均以 t1 中 b 的位置为准。
    // 同样丢掉 delta = 0(两串相同)或长度不同的询问,答案都是 0。
    for (ll i = 1; i <= q; i++) {
        string a, b;
        cin >> a >> b;
        if (a.size() != b.size()) continue;
        ll p = find_b(a), r = find_b(b);
        if (p == r) continue;
        ll len = a.size();
        Event event;
        event.delta = r - p;
        event.left = p;
        event.right = len - 1 - p;
        event.id = i;
        events.push_back(event);
    }

    sort(coordinates.begin(), coordinates.end());
    coordinates.erase(unique(coordinates.begin(), coordinates.end()), coordinates.end());
    bit.assign(coordinates.size() + 1, 0);
    sort(events.begin(), events.end(), cmp_event);

    // 按 delta 分组处理:组内二维偏序,处理完一组就撤销,避免不同 delta 互相干扰。
    // 扫描顺序保证:同 delta 里 left 小的规则先入树状数组,能回答 left 更大的询问。
    ll size = events.size();
    for (ll begin = 0; begin < size; ) {
        ll end = begin;
        while (end < size && events[end].delta == events[begin].delta) end++;

        for (ll i = begin; i < end; i++) {
            if (events[i].id == 0) {
                add_rule(events[i], 1);
            } else {
                // 查询统计 right_rule <= right_query 的规则数
                ll pos = upper_bound(coordinates.begin(), coordinates.end(), events[i].right)
                         - coordinates.begin();
                answer[events[i].id] = bit_query(pos);
            }
        }
        // 撤销本组规则,避免不同 delta 之间相互贡献。
        for (ll i = begin; i < end; i++) {
            if (events[i].id == 0) add_rule(events[i], -1);
        }
        begin = end;
    }

    for (ll i = 1; i <= q; i++) cout << answer[i] << '\n';
    return 0;
}

复杂度与瓶颈

读入并定位 b 共 O(L1+L2)O(L_1+L_2);规则和询问各生成至多一个事件,排序、离散化、树状数组处理共 O((n+q)log⁡(n+q))O((n+q)\log(n+q))。

总时间:

O(L1+L2+(n+q)log⁡(n+q)). O(L_1+L_2+(n+q)\log(n+q)).

空间为 O(n+q+max⁡∣s∣)O(n+q+\max|s|),其中 max⁡∣s∣\max|s| 表示读入时最长的单个字符串。

当一个串有多个不同字母时,长度和一个位置不再能描述全部内容。一般数据必须同时验证整段替换前串、替换后串,还要让众多询问复用同一份规则索引。

正解:解法一,map 分组与双 Trie DFS(主解)

关键观察

前面的首个差异对齐还可以推进一步:规则的最后一个差异,也必须对齐询问的最后一个差异。

设规则的首尾差异位置为 l,rl,r,询问的首尾差异位置为 L,RL,R,合法替换起点为 pp,则:

p+l=L,p+r=R. p+l=L,\qquad p+r=R.

所以两者的差异跨度相同,且首尾差异之间的双串内容必须逐位一致。我们可以先固定这段差异核心,再分别处理不变前缀、不变后缀的匹配。

思路

第一步:用前后缀分解,写出规则可用的充要条件。

将一条非恒等规则拆成:

s1=P+A+S,s2=P+B+S. s_1=P+A+S,\qquad s_2=P+B+S.

(A,B)(A,B) 是从首个差异到最后一个差异的子串对,即差异核心;PP 为不变前缀,SS 为不变后缀。核心中间即使有相同字符,也必须保留,否则会丢失字符内容与差异之间的距离。

将一个等长询问同样拆成:

t1=X+C+Y,t2=X+D+Y. t_1=X+C+Y,\qquad t_2=X+D+Y.

规则可用,当且仅当:

  1. (A,B)=(C,D)(A,B)=(C,D),差异核心完全相同;
  2. PP 是 XX 的后缀,即不变前缀紧贴核心向左匹配;
  3. SS 是 YY 的前缀,即不变后缀紧贴核心向右匹配。

必要性来自首尾差异对齐与区间内的双串匹配。充分性也很直接:若 X=X0+PX=X_0+P、Y=S+Y0Y=S+Y_0,则询问可以写成:

t1=X0+(P+A+S)+Y0,t2=X0+(P+B+S)+Y0. t_1=X_0+(P+A+S)+Y_0,\qquad t_2=X_0+(P+B+S)+Y_0.

在中间使用一次规则,就恰好得到目标串。后缀、前缀条件也保证了不越界,替换起点唯一,为 ∣X∣−∣P∣|X|-|P|。

第二步:用 map 精确分组,先消除核心条件。

使用 map<pair<string,string>,int> 为不同的规则差异核心编号。保存的是两条实际字符串,比较结果确定,不依赖字符串哈希。

询问找不到对应的规则核心,答案就是 00;找到后,只与同组规则比较。长度不同的询问直接回答 00;规则左右串相同则忽略,因为它不能得到题目要求的不同目标串。

同组的每条规则、每个询问都保留为一条记录。内容相同而编号不同的规则仍然保留多条,后面分别加权。

第三步:每组普通插入,建立左右两棵 Trie。

把组内规则的 PP、询问的 XX 反转后插入左 Trie;把规则的 SS、询问的 YY 正向插入右 Trie。空串对应根。

反转后,PP 是 XX 的后缀,就变成 rev⁡(P)\operatorname{rev}(P) 是 rev⁡(X)\operatorname{rev}(X) 的前缀。于是,设规则的左右终点为 (a,b)(a,b),询问的左右终点为 (u,v)(u,v),两个条件变成:

a 是左 Trie 中 u 的祖先,b 是右 Trie 中 v 的祖先. a\text{ 是左 Trie 中 }u\text{ 的祖先},\qquad b\text{ 是右 Trie 中 }v\text{ 的祖先}.

祖先关系包含节点本身。这里规则和询问都插入,因此每条记录都有完整终点;插入询问只会增加路径,不会改变规则与询问之间的前缀关系。

建 Trie 使用普通的逐字符插入:每个节点保存 ch[26],ch[c] 就是字符 cc 对应的孩子编号,00 表示不存在。有孩子就走过去,没有就新建,不需要遍历孩子链表。

第四步:DFS 左 Trie,自动筛出第一个祖先条件。

在右 Trie 每个节点上维护权值 ww,初始为 00。对左 Trie 做 DFS:

  1. 进入左节点 uu,激活所有左终点在这里的规则:它们的右终点权值加 11;
  2. 回答左终点在这里的所有询问;
  3. 访问孩子子树;
  4. 离开 uu,撤销这里的规则:对应右终点权值减 11。

回答询问时,当前激活的规则恰好满足「左终点在当前节点的祖先链上」。因此第一个条件已经成立。

同节点上的规则要先全部激活,再回答询问。否则结果会错误地依赖记录存放顺序。离开时的撤销也不可省略,否则规则会泄漏到兄弟分支。

第五步:直接沿右祖先链求和,筛出第二个条件。

设询问右终点为 vv。从 vv 出发,沿父指针走到右根,累加:

answer⁡=∑b 为 v 的祖先w[b]. \operatorname{answer}=\sum_{b\text{ 为 }v\text{ 的祖先}} w[b].

w[b]w[b] 只记录右终点恰好为 bb 的已激活规则。沿祖先链求和,就把满足右前缀条件的规则全部计入。根代表空不变后缀,也必须参与求和。

为什么直接走链不会太慢?询问 jj 的右终点深度就是 ∣Yj∣|Y_j|,一次查询访问 ∣Yj∣+1|Y_j|+1 个节点。所有查询的总成本为:

O(∑j∣Yj∣+q)=O(L2). O\left(\sum_j |Y_j|+q\right)=O(L_2).

输入已经为这些长链付出了字符串长度,不能再用“询问数乘最长链长”估计实际总成本。 这也是本解法可以直接求和、不必再维护树状数组的原因。

下面用样例 1 的询问 xabcx → xadex 看完整计数。它的不变前缀是 xa,差异核心为 (bc,de),不变后缀为 x。同核心规则有整串规则和 bc → de;左 Trie 是「根 → a → ax」,右 Trie 是「根 → x」。

左 Trie 的操作 右根权值 右 x 权值 含义
进入左根 11 00 激活 bc → de,它的两侧都为空
进入左 ax 11 11 激活整串规则
回答左 ax 上的询问 11 11 沿右 x → 根求和,答案为 22
离开左 ax 11 00 撤销整串规则
离开左根 00 00 撤销短规则

左路径保证不变前缀匹配,右路径保证不变后缀匹配;核心分组保证变化内容相同。三部分合起来,得到原题要求的两个方案。

正确性。

前后缀分解已经证明三个匹配条件充要。核心分组先保证第一个条件;左 DFS 的激活与撤销保证查询时恰好保留左祖先规则;右祖先链求和再准确筛出右祖先条件。因此统计的每条规则都合法,所有合法规则也都会被统计。

每条规则对一个询问只有一个可能起点,且只在自己的右终点增加一次权重,所以不会重复统计。重复编号的规则分别激活,符合题目计数要求。

代码

main.cpp 是正式主解。read_record() 完成拆分与 map 分组;build_group() 普通插入两棵 Trie;enter_node() 先激活再求和,leave_node() 撤销。

dfs_left(u) 直接递归:先用 enter_node(u) 激活本节点规则并回答询问,再按 a 到 z 递归孩子,最后用 leave_node(u) 撤销本节点规则。核心字典在读入分组完成后释放;各组复用节点池,只初始化实际用到的节点。group_records[g] 是核心组桶,保存属于第 gg 组的记录编号;record_ids[u] 是节点桶,保存左终点为 uu 的记录编号。两者都用 vector<int>,加入时 push_back(record_id),处理时直接遍历。has_query[g] 单独标记第 gg 组是否含询问,没有询问的组不必建树。records[record_id] 保存完整记录,记录中的 query_id 是原询问编号,规则用 00 标识;两种编号各自负责索引记录与填写答案。

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-15 09:53
 * update_at: 2026-10-15 21:26
 */
// main.cpp:map 精确分组差异核心,每组普通插入左右 Trie。
// 左 Trie DFS 激活规则,在右 Trie 沿父指针直接求和。
//
// ==================== 教学导读(主解思路) ====================
// 【前后缀分解】把一条非恒等规则写成 s1 = P + A + S,s2 = P + B + S:
//   (A, B) 是首尾差异之间的双串内容,叫「差异核心」;P 是不变前缀,S 是不变后缀。
// 把等长询问同样拆成 t1 = X + C + Y,t2 = X + D + Y((C, D) 是它的差异核心)。
//
// 【关键观察】首尾差异都要对齐:替换起点 p 必须满足 p + l = L 且 p + r = R
// (l、r 是规则差异位置,L、R 是询问差异位置)。所以规则可用的充要条件是:
//   ① 差异核心相同:(A, B) == (C, D);
//   ② P 是 X 的后缀(不变前缀紧贴核心向左匹配,且不越出串首);
//   ③ S 是 Y 的前缀(不变后缀紧贴核心向右匹配,且不越出串尾)。
// 满足三条时替换起点唯一,答案贡献 1。
//
// 【怎么数】按差异核心分组,每组把规则和询问的两侧插入两棵普通 Trie:
//   左 Trie 插入反转后的不变前缀;右 Trie 插入正向不变后缀。
//   规则左终点是询问左终点的祖先,且规则右终点是询问右终点的祖先,才合法。
// DFS 左 Trie:进入节点时激活恰好挂在这里的规则,离开时撤销。
// 回答询问时,激活集合恰好是当前节点到根路径上全部规则;
// 再沿询问右终点到根求 weight 之和,筛出右侧同样匹配的规则。
//
// 【复杂度】设 T=L1+L2,分组 O(T log(n+1)),建树和左 DFS 为 O(26T),
// 右侧查询总长度受 L2 限制。固定字母表下总时间 O(T log(n+1)),空间 O(T)。
#include <bits/stdc++.h>
using namespace std;

typedef long long ll;

const int MAXGROUP = 200000 + 10; // 差异核心组数不超过规则数 n
const int MAXNODE = 5000000 + 10; // 规则与询问单侧总长度至多 5000000,另有两个根

// 普通 Trie:ch[c] 存字符 c 对应的孩子编号,0 表示没有。
// 节点编号至多 5000002,权重不超过 n,因此用 int。
struct Node {
    int ch[26]; // 'a' 到 'z' 的孩子
    int parent; // 父节点,右树求祖先链之和时使用
    int weight; // 右树:在这里结束的已激活规则数
};

struct Record {
    string a;      // 两侧内容均不改变,只需保存原串(a 是 t1 / s1,b 只用来找差异)
    int l, r;      // 首尾差异位置,0-based;[l, r] 外的字符替换前后不变
    int right;     // 右 Trie 的终点(右端匹配到的最深节点)
    int query_id; // 规则为 0,询问为原始询问编号
};

Node node[MAXNODE];
vector<int> record_ids[MAXNODE]; // 节点桶:左终点为 u 的记录编号,放在 record_ids[u]
vector<int> group_records[MAXGROUP]; // 核心组桶:属于第 g 组的记录编号
bool has_query[MAXGROUP]; // 询问桶:第 g 组是否包含询问
int node_cnt, group_cnt;
ll n, q;
map<pair<string, string>, int> core_id;
vector<Record> records(1); // 完整记录从编号 1 开始保存
vector<ll> answer;

int new_node(int parent) {
    node_cnt++;
    // 组间复用节点池,重新使用的节点必须清空全部孩子。
    for (int c = 0; c < 26; c++) node[node_cnt].ch[c] = 0;
    node[node_cnt].parent = parent;
    record_ids[node_cnt].clear(); // 节点编号复用时,挂载的记录也必须清空
    node[node_cnt].weight = 0;
    return node_cnt;
}

// 字符 ch 对应的孩子不存在时新建,否则直接走过去。
int get_child(int u, int ch) {
    if (node[u].ch[ch] == 0) node[u].ch[ch] = new_node(u);
    return node[u].ch[ch];
}

void read_record(int query_id) {
    string a, b;
    cin >> a >> b;
    if (a.size() != b.size()) return; // 替换不改变长度,不等长直接丢掉(答案为 0)
    // 求差异区间 [l, r],它就是「差异核心」在原串中的位置
    int l = -1, r = -1;
    ll len = a.size();
    for (ll i = 0; i < len; i++) {
        if (a[i] != b[i]) {
            if (l == -1) l = i;
            r = i;
        }
    }
    if (l == -1) return; // 恒等规则不能得到不同的目标串

    // 用差异核心 (A, B) 作为分组键:只有核心相同的规则和询问才可能互相匹配
    pair<string, string> key(a.substr(l, r - l + 1), b.substr(l, r - l + 1));
    auto it = core_id.find(key);
    int g;
    if (it == core_id.end()) {
        if (query_id != 0) return; // 没有这个核心的规则,询问答案为 0
        g = ++group_cnt;
        core_id[key] = g;
    } else {
        g = it->second;
    }
    if (query_id != 0) has_query[g] = true; // 标记本组需要建 Trie 统计

    Record current;
    current.a = move(a);
    current.l = l;
    current.r = r;
    current.right = 0;
    current.query_id = query_id;
    records.push_back(move(current));
    int record_id = records.size() - 1;
    group_records[g].push_back(record_id);
}

// 规则和询问一起插入:左侧反转,右侧正向,空侧边对应根。
// 左 Trie 存「核心左边那段的反转」:P 是 X 的后缀 ⟺ rev(P) 是 rev(X) 的前缀,
// 规则左终点在询问左终点的祖先链上,就满足左侧条件。
// 右 Trie 存「核心右边那段」,同理用前缀关系表达「S 是 Y 的前缀」。
void build_group(int g, int left_root, int right_root) {
    // 建树阶段:依次插入本组记录,挂到左终点,并记住右终点。
    for (auto record_id : group_records[g]) {
        int u = left_root;
        for (ll i = records[record_id].l - 1; i >= 0; i--) {
            u = get_child(u, records[record_id].a[i] - 'a');
        }
        record_ids[u].push_back(record_id); // 把这条记录挂在左终点上

        u = right_root;
        ll len = records[record_id].a.size();
        for (ll i = records[record_id].r + 1; i < len; i++) {
            u = get_child(u, records[record_id].a[i] - 'a');
        }
        records[record_id].right = u;
    }
}

// 计数阶段:左侧的匹配由 DFS 路径保证,右侧沿祖先链求和。
void enter_node(int u) {
    // 同节点上的规则必须全部激活后,再回答这里的询问(规则之间不区分先后)。
    // 「激活」= 在右 Trie 的规则终点上 weight +1,表示这条规则当前可用。
    for (auto record_id : record_ids[u]) {
        if (records[record_id].query_id == 0) node[records[record_id].right].weight++;
    }
    for (auto record_id : record_ids[u]) {
        if (records[record_id].query_id != 0) {
            // 询问:沿右 Trie 父指针把 weight 加起来。
            // 只要规则的右端节点是 v 的祖先(含 v 本身),就满足「S 是 Y 的前缀」,
            // 左端条件已由当前激活集合保证,因此这个和就是答案。
            ll result = 0;
            for (int v = records[record_id].right; v != 0; v = node[v].parent) {
                result += node[v].weight;
            }
            answer[records[record_id].query_id] = result;
        }
    }
}

void leave_node(int u) {
    for (auto record_id : record_ids[u]) {
        if (records[record_id].query_id == 0) node[records[record_id].right].weight--;
    }
}

// 左 Trie 的递归 DFS:进入时激活规则并回答询问,访问完孩子后撤销。
// 激活集合恰好是当前节点到根路径上的全部规则。
void dfs_left(int u) {
    enter_node(u);
    for (int c = 0; c < 26; c++) {
        int v = node[u].ch[c];
        if (v != 0) dfs_left(v);
    }
    leave_node(u);
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    cin >> n >> q;
    records.reserve(n + q + 1);
    answer.assign(q + 1, 0);
    for (ll i = 1; i <= n; i++) read_record(0);
    for (ll i = 1; i <= q; i++) read_record(i);
    core_id.clear(); // 分组完成,释放核心字典,再处理各组的 Trie

    for (int g = 1; g <= group_cnt; g++) {
        if (!has_query[g]) continue;
        node_cnt = 0; // 组间复用节点池,只初始化实际用到的节点
        int left_root = new_node(0);
        int right_root = new_node(0);
        build_group(g, left_root, right_root);
        dfs_left(left_root);
    }
    for (ll i = 1; i <= q; i++) cout << answer[i] << '\n';
    return 0;
}

复杂度

设 T=L1+L2T=L_1+L_2。核心总种类至多 nn。一个长度为 kk 的核心做一次 map 查询或插入,需要 O(log⁡(n+1))O(\log(n+1)) 次比较,每次比较最坏 O(k)O(k)。累加所有输入核心的长度,分组总成本为 O(Tlog⁡(n+1))O(T\log(n+1))。

每次插入直接访问 ch[c],所有字符转移共 O(T)O(T)。每个新节点清空 2626 个孩子,左树 DFS 也枚举 2626 个孩子,合计 O(26T)O(26T)。每条规则只激活、撤销一次,所有右祖先链查询共 O(L2+q)O(L_2+q)。

因此总时间为:

O(Tlog⁡(n+1)+26T). O(T\log(n+1)+26T).

固定字母表下写为 O(Tlog⁡(n+1))O(T\log(n+1))。Node 只保存 2626 个孩子、parent、weight 两个整数,通常占 112112 字节。节点桶 record_ids、核心组桶 group_records 与标记桶 has_query 都独立保存,每条记录分别在自己的核心组和左终点各保存一个编号。空间为 O(26T+n+q)O(26T+n+q),固定字母表下为 O(T)O(T)。

这份实现以常见的 map、普通 Trie 和 DFS 为主线,保留了直接的前后缀分解与计数过程。进一步追求严格线性时,再看解法四如何替换核心分组与建树方式。

正解:解法二,AC 自动机与 fail 树离线计数

关键观察

第二层已经得到两个能用于一般数据的结论:

  • 替换区间必须覆盖第一个到最后一个不同位置 [L,R][L,R];
  • 区间内必须同时匹配规则的替换前串和替换后串。

我们希望把逐询问、逐规则的检查,改成一次扫描就同时匹配所有规则。

思路

第一步:把两条串合成一条字符对串。

把同一位置上的两个字符 (a,b)(a,b) 看作一个新字符,用

code⁡(a,b)=26(a−’a’)+(b−’a’) \operatorname{code}(a,b)=26(a-\texttt{'a'})+(b-\texttt{'a'})

编码为 0∼6750\sim675。规则 (si,1,si,2)(s_{i,1},s_{i,2}) 和询问 (t1,t2)(t_1,t_2) 都按这个方式编码。

规则字符对串在询问中出现,等价于替换前子串和替换后子串同时匹配。不过这还不是合法替换,必须再检查出现区间是否覆盖 [L,R][L,R]。

为什么不能直接统计所有字符对匹配?例如规则 a → a 会匹配到询问 ab → ac 的位置 00,但它没有覆盖位置 11 的差异,替换后仍然是 ab。

第二步:把覆盖条件改成结束位置和长度条件。

设一次匹配结束于位置 ee,长度为 kk,其区间为 [e−k+1,e][e-k+1,e]。覆盖 [L,R][L,R] 等价于:

e⩾R,e−k+1⩽L. e\geqslant R,\qquad e-k+1\leqslant L.

整理第二个不等式,得到:

k⩾e−L+1. k\geqslant e-L+1.

因此,扫描到每个 e⩾Re\geqslant R 的位置,只需统计「以这里结尾、长度至少为 e−L+1e-L+1」的规则匹配。

第三步:AC 自动机给出所有以当前位置结尾的规则。

将规则字符对串全部插入 Trie,再建立 AC 自动机。扫描询问至位置 ee,设当前状态为 vv。从 vv 本身开始沿 fail 指针到根,链上的终止节点恰好对应所有以 ee 结尾的规则。

这里的长度是节点在原 Trie 中的深度 node_depth[u],不是它在 fail 树中的层数。终止权重 terminal_weight[u] 表示有多少个规则编号在这个节点结束。

不能每次遍历整条 fail 链,否则大量后缀匹配仍会让时间膨胀。我们把每个扫描位置记录为离线子问题:

(need=e−L+1, state=v, id=j). (\text{need}=e-L+1,\ \text{state}=v,\ \text{id}=j).

它要求:统计 vv 本身及其 fail 祖先中,Trie 深度至少为 need 的终止节点权重和。

下面继续看样例 1。字符对串是 (x,x),(a,a),(b,d),(c,e),(x,x),仍有 L=2,R=3L=2,R=3。

结束位置 ee 长度下界 e−L+1e-L+1 以此处结尾的终止规则 本位置贡献
0,1,20,1,2 不生成子问题 尚未覆盖最后一个差异 00
33 22 bc → de,长度 22 11
44 33 xabcx → xadex,长度 55 11

在 e=3e=3 时,当前状态可以是整串规则的长度 44 前缀,该前缀本身并非终止节点;bc → de 对应的终止节点在它的 fail 链上。因此只看当前状态是否终止会漏算。把两个位置的贡献相加,就得到答案 22。

第四步:把祖先查询转成子树加。

把每个非根节点的 fail 指针作为父指针,得到 fail 树。规则终止节点 uu 在 vv 的 fail 链上,当且仅当 vv 在 uu 的 fail 子树中,包含 u=vu=v 的情况。

所以,激活一条规则时,可以给其终止节点 uu 的整棵 fail 子树增加权重。查询状态 vv 的值,就得到所有已激活规则中,以该状态结尾的匹配数量。

求出 fail 树的 DFS 序后,uu 的子树是连续区间 [tin⁡[u],tout⁡[u]][\operatorname{tin}[u],\operatorname{tout}[u]]。使用差分树状数组实现:

  • 激活 uu:区间加 terminal_weight[u];
  • 查询 vv:单点查询 tin[v]。

区间加 ww 只需在差分数组的左端加 ww、右端后一位减 ww,单点值就是差分前缀和。

第五步:按长度下界从大到小离线。

把终止节点按 Trie 深度降序排序,子问题按 need 降序排序。处理一个子问题前,把所有满足

node_depth⁡[u]⩾need \operatorname{node\_depth}[u]\geqslant\text{need}

的终止节点激活。随着 need 变小,只会新增合格规则,不需要删除。

此时 tin[state] 的值,正好是「既为当前状态的 fail 祖先,又满足长度下界」的规则数。将它累加到原询问的答案中。

正确性。

每个合法替换,都对应一个字符对匹配,且区间覆盖 [L,R][L,R],所以它必然在结束位置 ee 对应的子问题中被统计。反过来,子问题统计到的终止节点保证区间内双串匹配,长度下界和 e⩾Re\geqslant R 保证区间覆盖所有差异,区间外又本来相同,因此一定是合法替换。

每次出现只归属于一个结束位置;同节点上的多个规则编号通过权重分别计数。所以不会漏算,也不会把同一个方案算两次。

代码

main2.cpp 使用 vector<int> g[maxn] 邻接表保存字符对 Trie 和 fail 树,边字符挂在孩子节点上,避免给每个节点开 676676 个转移。Trie 建完后每个节点的孩子按边字符排序,find_child() 用二分查找转移;build_ac_automaton() 用 std::queue 做 BFS 建 fail 指针。fail 树分两步:build_fail_tree() 只把每个节点挂到 fail 指针下加边,dfs_fail_tree() 再在树上递归求 DFS 序;由于 fail 树最坏可能深达百万级(一条超长规则就是一条长链),main 开头用 setrlimit 把系统栈上限调到 256MB256\mathrm{MB},避免递归爆栈。

process_query() 对应前三步,生成 (need,state,id);build_fail_tree()、dfs_fail_tree() 和 answer_offline_asks() 对应后两步,完成子树加与离线查询。

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-06-22 19:52
 * update_at: 2026-10-15 13:37
 */
// main2.cpp:把规则和询问都转成字符对串,用 AC 自动机匹配,
// 再在 fail 树上按长度阈值离线计数。
//
// ==================== 教学导读 ====================
// 【核心思想:一次扫描同时匹配所有规则】逐询问逐规则检查太慢,
// 换个看法:一条规则在起点 p 可用 ⟺ 对每个 k,t1[p+k] = s1[k] 且 t2[p+k] = s2[k]。
// 把 t1、t2 逐位合成「字符对串」T:T[i] = (a[i], b[i])(编码 0..675),
// 规则也合成字符对串 R:R[k] = (s1[k], s2[k])。那么规则在 p 可用
// ⟺ R 恰好是 T[p .. p+|R|-1] 这段子串 —— 就是普通的模式串匹配!
//
// 【还要覆盖差异区间 [L, R]】规则长度为 k、起点为 p 时,要求 [p, p+k-1] ⊇ [L, R]。
// 固定结束位置 i = p+k-1(i >= R),则 p = i-k+1 >= L,即 k >= i-L+1。
// 于是:以 i 结尾、长度 >= i-L+1 的规则,都恰好贡献一个方案。
//
// 【AC 自动机 + fail 树怎么数】所有规则插入 Trie 建 AC 自动机,扫描 T 时每个位置 i
// 得到一个状态 state(i)。state(i) 的 fail 祖先(含自身)恰好是「所有以 i 结尾的
// 匹配规则」。把 fail 反过来建树,规则终点就是一些节点,问题变成:
//   对每个 (state, 阈值 k0):state 的 fail 祖先里,深度 >= k0 的终止节点有多少个?
// 对每个 i >= R 拆一个这样的子问题(阈值 k0 = i-L+1)。
//
// 【离线回答】把终止节点按深度降序、子问题按阈值降序排序,双指针推进:
// 深度足够的终止节点就把它的 fail 子树整体 +weight(用 DFS 序区间加),
// 查询 state 的 tin 位置单点值。这就是树状数组上的经典离线套路。
//
// 【图怎么存】Trie 和 fail 树都用「vector<int> g[maxn] 邻接表」存孩子:
//   trie_g[u] 是 u 的 Trie 孩子,边字符挂在孩子上(node[v].ch = 到 v 的边字符);
//   fail_g[u] 是 u 在 fail 树上的孩子。
// 建完 Trie 后给每个节点的孩子按字符排序,find_child 用 lower_bound 二分查找,转移 O(log 676)。
// AC 自动机用 std::queue 做 BFS 建 fail 指针;fail 树分两步:先建树(build_fail_tree
// 只加边),再用递归 DFS 求 DFS 序(dfs_fail_tree),每一步都一眼能看懂。
// 注意:fail 树可能深达百万级(一条超长规则就是一条长链),递归需要大栈,
// 所以 main 开头用 raise_stack_limit() 把系统栈上限调到 256MB,防止爆栈。
//
// 【复杂度】O((L1+L2) log(L1+L2)),适用于全部数据。
#include <bits/stdc++.h>
#include <sys/resource.h> // 调大系统栈上限用(Linux 评测环境)
using namespace std;

typedef long long ll;

// Trie 只由规则建成:L1 = 规则左右串总长度 <= 5e6,单侧 <= 2.5e6,
// 所以节点数不超过 2500001。两个 vector<int> 邻接表各占 24 字节/节点。
const int MAXNODE = 2500000 + 10;

// 一个 Trie 节点。深度 = 到根路径上的字符数,即该节点对应字符对串的长度。
struct Node {
    int ch;     // 入边上的字符对编码 0..675;根没有入边,记 -1
    int depth;  // 深度 = 该节点对应字符对串的长度(就是规则长度)
    int fail;   // fail 指针
    int weight; // 终止权重:以该节点结尾的规则条数(相同规则不去重)
    int tin;    // fail 树 DFS 序:进入时间戳
    int tout;   // fail 树 DFS 序:离开时间戳,子树是区间 [tin, tout]
};

// Ask:一个离线子问题
// need:规则长度阈值(只统计深度 >= need 的终止节点);
// state:询问扫描到的 AC 状态(所有以当前位置结尾的匹配规则都在它的 fail 祖先上);
// id:原始询问编号(一个询问会拆出多个子问题,答案累加)
struct Ask {
    int need;
    int state;
    int id;
};

int n, q;

Node node[MAXNODE];         // 节点池,编号 0..node_cnt-1,0 是根
vector<int> trie_g[MAXNODE]; // trie_g[u]:u 的 Trie 孩子(vector<int> g[maxn] 存图)
vector<int> fail_g[MAXNODE]; // fail_g[u]:u 在 fail 树上的孩子(同一套存图方式)
int node_cnt;                // 已创建的节点数,新建节点的编号就是 node_cnt

int dfs_timer; // fail 树 DFS 序的时间戳

vector<int> terminal_nodes; // 所有终止节点
vector<Ask> asks;           // 所有离线子问题
vector<ll> answer;          // 每个询问的答案
vector<ll> bit;             // 树状数组(差分 + 单点查前缀)

// 一对字符 (a, b) 编码成 0..675 的新字符:
// 把「双串逐位相等」变成「单串子串匹配」,是本解法最关键的一步。
int code_pair(char a, char b) {
    return (a - 'a') * 26 + (b - 'a');
}

// 新建一个节点,入边字符为 ch,返回编号
int new_node(int ch) {
    int id = node_cnt;
    node_cnt++;
    node[id].ch = ch;
    node[id].depth = 0;
    node[id].fail = 0;
    node[id].weight = 0;
    node[id].tin = 0;
    node[id].tout = 0;
    return id;
}

// 建树时取 u 的 ch 孩子,不存在就新建。
// 此时孩子还没排序,线性扫一遍;建完 Trie 统一排序后才用 find_child 二分。
int get_or_add_child(int u, int ch) {
    for (size_t i = 0; i < trie_g[u].size(); i++) {
        int v = trie_g[u][i];
        if (node[v].ch == ch) {
            return v;
        }
    }
    int v = new_node(ch);
    node[v].depth = node[u].depth + 1;
    trie_g[u].push_back(v);
    return v;
}

// lower_bound 用的比较函数:元素是节点 id,查找值是字符,
// 比较时把节点 id 映射成它的入边字符再和 ch 比。
bool cmp_node_char(int v, int ch) {
    return node[v].ch < ch;
}

// 在 u 的孩子里二分查找字符 ch 对应的孩子,找不到返回 -1。
// 前提:trie_g[u] 已按 node[v].ch 升序排好(sort_children 做的)。
// lower_bound 返回第一个「入边字符 >= ch」的孩子,再确认它真的等于 ch。
int find_child(int u, int ch) {
    vector<int>::iterator it = lower_bound(trie_g[u].begin(), trie_g[u].end(), ch, cmp_node_char);
    if (it == trie_g[u].end() || node[*it].ch != ch) {
        return -1;
    }
    return *it;
}

// 把规则 (a, b) 的字符对串插入 Trie,终止节点权值 +1
void insert_pair_string(const string &a, const string &b) {
    int u = 0;
    int len = a.size();
    for (int i = 0; i < len; i++) {
        int ch = code_pair(a[i], b[i]);
        u = get_or_add_child(u, ch);
    }
    node[u].weight++;
}

// Trie 孩子按入边字符升序排序(比较函数不用 lambda)
bool cmp_child(int a, int b) {
    return node[a].ch < node[b].ch;
}

// 给每个节点的孩子排序,之后 find_child 可以二分查找
void sort_children() {
    for (int u = 0; u < node_cnt; u++) {
        if (trie_g[u].size() > 1) {
            sort(trie_g[u].begin(), trie_g[u].end(), cmp_child);
        }
    }
}

// 沿 fail 链走一步转移,返回下一个 AC 状态
int move_state(int u, int ch) {
    while (u != 0 && find_child(u, ch) == -1) {
        u = node[u].fail;
    }
    int v = find_child(u, ch);
    if (v == -1) {
        return 0;
    }
    return v;
}

// BFS 建 AC 自动机的 fail 指针,用 std::queue 逐层扩展
void build_ac_automaton() {
    queue<int> que;

    // 根的孩子 fail 指向根
    for (size_t i = 0; i < trie_g[0].size(); i++) {
        int v = trie_g[0][i];
        node[v].fail = 0;
        que.push(v);
    }

    while (!que.empty()) {
        int u = que.front();
        que.pop();
        for (size_t i = 0; i < trie_g[u].size(); i++) {
            int v = trie_g[u][i];
            int ch = node[v].ch;

            // 求 fail[v]:从 fail[u] 出发沿 fail 链找能走字符 ch 的节点,
            // 找不到就落到根。这个节点就是 v 对应串的最长真后缀所在位置。
            int f = node[u].fail;
            while (f != 0 && find_child(f, ch) == -1) {
                f = node[f].fail;
            }
            int go = find_child(f, ch);
            if (go == -1) {
                node[v].fail = 0;
            } else {
                node[v].fail = go;
            }
            que.push(v);
        }
    }
}

// 建 fail 树:把每个非根节点挂到 fail 指针指向的节点下面,只加边,不遍历。
// fail 指针反向后恰好是一棵树(根是 0):u 的 fail 祖先就是 u 到根的路径。
void build_fail_tree() {
    for (int i = 1; i < node_cnt; i++) {
        fail_g[node[i].fail].push_back(i);
    }
}

// 在 fail 树上递归求 DFS 序:u 的子树对应连续区间 [tin[u], tout[u]]。
// 直接写递归 DFS 最好懂;代价是递归深度 = fail 树深度,最坏可达百万级,
// 所以 main 一开始调用 raise_stack_limit() 把系统栈上限调大,防止爆栈。
void dfs_fail_tree(int u) {
    node[u].tin = ++dfs_timer;
    for (size_t i = 0; i < fail_g[u].size(); i++) {
        int v = fail_g[u][i];
        dfs_fail_tree(v);
    }
    node[u].tout = dfs_timer;
}

// 递归 DFS 的深度最坏可达百万级(fail 树可能是一条长链),默认 8MB 系统栈不够。
// 把栈的软上限提高到 256MB(Linux 评测环境有效,普通进程即可调软上限)。
// 硬上限不足 256MB 时只能调到硬上限;若评测环境完全不许调整,需换回显式栈。
void raise_stack_limit() {
    struct rlimit rl;
    getrlimit(RLIMIT_STACK, &rl);
    if (rl.rlim_max == RLIM_INFINITY || rl.rlim_max > 256 * 1024 * 1024) {
        rl.rlim_cur = 256 * 1024 * 1024;
    } else {
        rl.rlim_cur = rl.rlim_max;
    }
    setrlimit(RLIMIT_STACK, &rl);
}

// ---- 树状数组:区间加、单点查(差分实现)----

void bit_add(int pos, ll val) {
    int nbit = bit.size() - 1;
    while (pos <= nbit) {
        bit[pos] += val;
        pos += pos & -pos;
    }
}

void bit_range_add(int l, int r, ll val) {
    bit_add(l, val);
    bit_add(r + 1, -val);
}

ll bit_query(int pos) {
    ll res = 0;
    while (pos > 0) {
        res += bit[pos];
        pos -= pos & -pos;
    }
    return res;
}

// 终止节点按深度从大到小排序
bool cmp_terminal_depth(int a, int b) {
    return node[a].depth > node[b].depth;
}

// 离线子问题按长度阈值从大到小排序
bool cmp_ask_need(const Ask &a, const Ask &b) {
    return a.need > b.need;
}

// 离线回答所有子问题:
// 深度不小于阈值的终止节点,其 fail 子树整体加权后,查状态的 DFS 序位置。
// 为什么查 tin[state] 就够:终止节点 u 加在 [tin[u], tout[u]] 上,
// u 是 state 的 fail 祖先 ⟺ state 在 u 的子树里 ⟺ tin[state] ∈ [tin[u], tout[u]]。
void answer_offline_asks() {
    bit.assign(node_cnt + 2, 0);

    sort(terminal_nodes.begin(), terminal_nodes.end(), cmp_terminal_depth);
    sort(asks.begin(), asks.end(), cmp_ask_need);

    int ptr = 0;
    int term_cnt = terminal_nodes.size();
    int ask_cnt = asks.size();
    for (int i = 0; i < ask_cnt; i++) {
        // 把深度足够的终止节点子树加进树状数组
        while (ptr < term_cnt && node[terminal_nodes[ptr]].depth >= asks[i].need) {
            int u = terminal_nodes[ptr];
            bit_range_add(node[u].tin, node[u].tout, node[u].weight);
            ptr++;
        }
        answer[asks[i].id] += bit_query(node[asks[i].state].tin);
    }
}

// 处理一个询问:找出 [L,R] 后,把每个可能结束位置拆成一个离线子问题。
// 规则长度为 k、替换起点为 p 时,要求区间 [p, p+k-1] 覆盖 [L, R],
// 于是 p = L - (k 的差异前缀长),等价于统计深度阈值满足条件的终止节点个数。
// 换个更直接的说法:结束位置 i = p+k-1 必须 >= R,且 p = i-k+1 >= L,
// 所以 k >= i-L+1;每个 i >= R 对应一个子问题 (state(i), need = i-L+1)。
void process_query(int id, const string &a, const string &b) {
    // 替换不改变长度
    if (a.size() != b.size()) {
        answer[id] = 0;
        return;
    }

    int len = a.size();
    // first_diff / last_diff 即询问两串的差异区间 [L, R]
    int first_diff = -1;
    int last_diff = -1;
    for (int i = 0; i < len; i++) {
        if (a[i] != b[i]) {
            if (first_diff == -1) {
                first_diff = i;
            }
            last_diff = i;
        }
    }

    if (first_diff == -1) {
        answer[id] = 0; // 两串完全相同,题目保证 t1 != t2,必无解
        return;
    }

    // 扫描字符对串,每个结束位置 i >= R 产生一个子问题:
    // 统计 fail 祖先中深度 >= i - L + 1 的终止节点数量(深度 = 规则长度)。
    // state 始终是「T[0..i] 的最长后缀规则前缀」,fail 祖先就是所有匹配的规则。
    int state = 0;
    for (int i = 0; i < len; i++) {
        int ch = code_pair(a[i], b[i]);
        state = move_state(state, ch);
        if (i >= last_diff) {
            Ask ask;
            ask.need = i - first_diff + 1;
            ask.state = state;
            ask.id = id;
            asks.push_back(ask);
        }
    }
}

int main() {
    raise_stack_limit(); // 递归求 DFS 序可能非常深,先把系统栈上限调大
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    cin >> n >> q;
    new_node(-1); // 先把根节点(编号 0)开出来,根没有入边字符

    // 每条规则的左右串逐位合成字符对串,插入 Trie
    string a, b;
    for (int i = 1; i <= n; i++) {
        cin >> a >> b;
        insert_pair_string(a, b);
    }

    sort_children();      // 孩子按边字符排序,find_child 才能二分查找
    build_ac_automaton(); // BFS 求 fail 指针
    build_fail_tree();    // fail 指针反向建树(只加边)
    dfs_timer = 0;
    dfs_fail_tree(0);     // 从根开始递归求 DFS 序

    // 收集所有终止节点(即所有规则串的结尾)
    for (int i = 1; i < node_cnt; i++) {
        if (node[i].weight > 0) {
            terminal_nodes.push_back(i);
        }
    }

    answer.assign(q + 1, 0);
    asks.reserve(1000000);
    // 每个询问在线拆成若干离线子问题,扫完 Trie 后统一回答
    for (int id = 1; id <= q; id++) {
        cin >> a >> b;
        process_query(id, a, b);
    }

    answer_offline_asks();

    for (int id = 1; id <= q; id++) {
        cout << answer[id] << '\n';
    }

    return 0;
}

复杂度

设 Trie 节点数为 NN,离线子问题数为 MM。有:

N⩽1+L1/2,M⩽L2/2. N\leqslant1+L_1/2,\qquad M\leqslant L_2/2.

节点数不超过规则单侧总长度加根;每个等长询问位置最多生成一个子问题。不同长度询问不会生成子问题。

字母对种类数 σ=676\sigma=676 固定。若把这一常数视为固定,建自动机及扫描的总时间为 O(L1+L2)O(L_1+L_2)。具体到当前实现,插入会线性查找孩子,最坏带 σ\sigma 因子;孩子排序后二分转移带 log⁡σ\log\sigma 因子。扫描时的 fail 回跳按深度升降均摊,构建 fail 时也可沿每条输入规则的 Trie 路径均摊,总回跳次数受规则总长度控制。

终止节点至多 nn 个,其排序为 O(nlog⁡n)O(n\log n);子问题排序为 O(Mlog⁡M)O(M\log M);树状数组处理为 O((n+M)log⁡N)O((n+M)\log N),DFS 序为 O(N)O(N)。固定字母表下,总时间为:

O((L1+L2)log⁡(L1+L2)), O((L_1+L_2)\log(L_1+L_2)),

空间为 O(L1+L2+q)O(L_1+L_2+q)。

实现时还要注意:相同规则不能去重;长度下界必须是 e−L+1e-L+1;树状数组应当对子树区间加、对状态单点查。题目保证 t1≠t2t_1\neq t_2,各实现中对相等字符串返回 00 的分支只是题目范围外的保护,不是在求「不改变字符串」的替换数。

正解:解法三,双 Trie 的二维扫描线计数

关键观察

第二层已经证明首个差异必须对齐;最后一个差异也必须对齐。设规则的首尾差异位置是 l,rl,r,询问的是 L,RL,R,替换起点为 pp,则:

p+l=L,p+r=R. p+l=L,\qquad p+r=R.

所以规则的差异跨度 r−l+1r-l+1 必须等于询问的 R−L+1R-L+1。首尾差异之间的整段内容也必须匹配,而两端不变的内容只需要向外延伸匹配。

这使得我们可以先把变化部分固定下来,再分别处理左右两侧,不必在询问的所有位置搜索规则。

思路

第一步:把规则和询问拆成三段。

将一条非恒等规则写成:

s1=P+A+S,s2=P+B+S. s_1=P+A+S,\qquad s_2=P+B+S.

A,BA,B 是从规则第一个不同位置到最后一个不同位置的子串,称二元组 (A,B)(A,B) 为差异核心。PP 是不变前缀,SS 是不变后缀;它们都可以为空。核心中间可以含相同字符,不能把这些位置删掉,否则会丢失差异之间的距离和字符内容。

同样,将一个等长询问拆成:

t1=X+C+Y,t2=X+D+Y. t_1=X+C+Y,\qquad t_2=X+D+Y.

其差异核心是 (C,D)(C,D)。首尾差异对齐后,规则合法当且仅当:

  1. (A,B)=(C,D)(A,B)=(C,D),两段核心逐字符完全相同;
  2. PP 是 XX 的后缀,规则左侧能紧贴核心向左匹配;
  3. SS 是 YY 的前缀,规则右侧能紧贴核心向右匹配。

条件 2、3 已经包含不越界的要求:比 XX 更长的 PP 不可能是其后缀,比 YY 更长的 SS 不可能是其前缀。若条件满足,唯一起点是 p=∣X∣−∣P∣p=|X|-|P|。

第二步:按差异核心精确分组。

只有核心相同的规则和询问才能配对。用 map<pair<string,string>,int> 给每种规则核心编号,询问通过 find() 查找编号。找不到对应核心就回答 00。

这里保存并比较的是两条实际字符串,不存在哈希碰撞;它们一起描述的内容,与字符对核心完全相同。左右串相同的规则直接忽略,因为它无法得到题目要求的不同目标串。

第三步:两棵普通 Trie 处理两侧匹配。

与解法一保持相同的建树方式:每个核心组单独建立两棵 Trie,规则和询问一起插入。规则的 PP、询问的 XX 都反转后插入左 Trie,规则的 SS、询问的 YY 都正向插入右 Trie;空串对应根。

记询问的两个终点为 vL,vRv_L,v_R,规则的两个终点为 uL,uRu_L,u_R,则侧边匹配等价于:

uL 是 vL 的祖先,uR 是 vR 的祖先. u_L\text{ 是 }v_L\text{ 的祖先},\qquad u_R\text{ 是 }v_R\text{ 的祖先}.

祖先关系包含节点本身。这里使用的是普通 Trie 的父子关系,没有 fail 树,也没有 fail 指针。

询问也插入后,每个询问都对应两个完整的终点,不需要额外处理缺边。新建的询问节点不会产生规则贡献;例如规则侧边为 a、ab,询问侧边为 ac,插入后 a 仍是询问终点的祖先,ab 则不是,匹配条件没有改变。

第四步:把两个祖先条件变成矩形覆盖。

分别求两棵 Trie 的 DFS 序。左 Trie 的一个节点 uLu_L 是 vLv_L 的祖先,等价于:

tin⁡L[vL]∈[tin⁡L[uL],tout⁡L[uL]]. \operatorname{tin}_L[v_L]\in [\operatorname{tin}_L[u_L],\operatorname{tout}_L[u_L]].

右 Trie 同理。于是每条规则对应一个矩形:

[tin⁡L[uL],tout⁡L[uL]]×[tin⁡R[uR],tout⁡R[uR]], [\operatorname{tin}_L[u_L],\operatorname{tout}_L[u_L]] \times [\operatorname{tin}_R[u_R],\operatorname{tout}_R[u_R]],

每个询问对应一个点:

(tin⁡L[vL],tin⁡R[vR]). (\operatorname{tin}_L[v_L],\operatorname{tin}_R[v_R]).

答案就是同一核心组内,有多少个规则矩形覆盖询问点。规则编号不同就生成不同矩形,哪怕矩形和核心都相同,也分别计数。

样例 1 的询问 xabcx → xadex 拆成 X=xaX=\texttt{xa}、核心 (bc,de)(\texttt{bc},\texttt{de})、Y=xY=\texttt{x}。左侧反转后是 ax,右侧是 x。下表展示规则如何参与同一个询问。

规则 核心 不变前缀 PP 不变后缀 SS 对本询问的贡献
xabcx → xadex (bc,de) xa x 11
bc → de (bc,de) 空 空 11
ab → cd (ab,cd) 空 空 00
aa → bb (aa,bb) 空 空 00

只有前两行与询问属于同一核心组。两棵 Trie 中,整串规则分别结束在 ax 和 x,短规则结束在两个根;询问点同时位于这两条规则的子树矩形中,所以答案为 22。

这个样例的左 Trie 是「根 → a → x」,DFS 序依次为 1,2,31,2,3;右 Trie 是「根 → x」,DFS 序为 1,21,2。询问点为 (3,2)(3,2),整串规则的矩形是 [3,3]×[2,2][3,3]\times[2,2],短规则的矩形是 [1,3]×[1,2][1,3]\times[1,2]。两个矩形都覆盖这个点,分别贡献 11。

第五步:扫描线与差分树状数组。

对于规则矩形 [x1,x2]×[y1,y2][x_1,x_2]\times[y_1,y_2],生成两个事件:

  • 横坐标 x1x_1:将纵坐标区间 [y1,y2][y_1,y_2] 加 11;
  • 横坐标 x2+1x_2+1:将同一区间减 11。

询问点 (x,y)(x,y) 生成一个横坐标为 xx 的单点查询事件。每组单独生成事件并按横坐标排序,同一横坐标先处理更新,再回答询问。事件只在组内统计,不需要额外保存核心编号。

扫描时,横坐标条件由矩形的进入、退出事件保证;纵坐标条件由差分树状数组的区间加、单点查保证。每个有效询问只查一次,每条非恒等规则只产生两个事件。

各核心组复用节点池、事件容器和树状数组。新组重新建树与编号,清空事件,并把本组右 Trie 会用到的树状数组位置清零。清理量与本组节点数成正比,不必每组清空整个大数组。

正确性。

若一次替换合法,规则的首尾差异必然分别对齐询问的首尾差异,因此核心相同,左右不变部分分别匹配询问的后缀、前缀。反过来,若核心相同且两侧匹配,从 ∣X∣−∣P∣|X|-|P| 开始放置规则,区间内的左右串分别吻合,区间外又本来相同,所以替换必然得到目标串。

Trie 的祖先关系准确表达两侧前缀条件,DFS 序矩形准确表达两个祖先条件,扫描线统计的就是同组内所有覆盖点的规则。每条规则只对应一个候选起点和一个矩形,所以不存在同一方案重复统计。

代码

打开交互教程:双 Trie → 矩形 → 扫描线。先在图上看懂两个区间如何组成矩形,再逐步观察更新与查询,最后对照下方代码。

main3.cpp 与主解采用相同的 Record、group_records[g] 和 has_query[g] 分组方式,Trie 用 ch[26] 直接保存孩子,记录列表使用范围 for 遍历。

read_record() 完成三段拆分和核心分组,build_group() 把本组规则、询问一起插入双 Trie 并保存左右终点。接着 dfs_order() 递归求出两棵树各自的 DFS 序,make_events() 生成规则矩形与询问点,solve_events() 排序并使用差分树状数组计数。

因此,与解法一对照时,可以复用分组、建树和祖先条件的理解,重点看「祖先链求和」如何改成「子树区间矩形覆盖」。递归 DFS 沿用本题栈空间充足的评测条件。

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-14 23:42
 * update_at: 2026-10-16 19:41
 */
// main3.cpp:map 精确分组差异核心,每组普通插入左右 Trie。
// 与 main.cpp 使用相同的分组和建树方式,计数改用 DFS 序矩形与二维扫描线。
//
// 【匹配条件】规则 s1=P+A+S、s2=P+B+S,询问 t1=X+C+Y、t2=X+D+Y。
// 规则可用 ⟺ (A,B)=(C,D),P 是 X 的后缀,S 是 Y 的前缀。
// 组内把反转的 P、X 插入左 Trie,把正向的 S、Y 插入右 Trie。
// 规则左终点是询问左终点的祖先,且规则右终点是询问右终点的祖先,才合法。
//
// 【矩形与点】DFS 序把节点 u 的子树变成区间 [tin[u],tout[u]]。
// 规则的左右终点为 u、v,就生成矩形:
//   [tin[u],tout[u]] × [tin[v],tout[v]]。
// 询问的左右终点为 x、y,就生成点 (tin[x],tin[y])。
// 答案就是本组内覆盖询问点的规则矩形数,重复规则分别计数。
//
// 【扫描线】矩形在 x=tin[u] 时给 y 区间加 1,在 x=tout[u]+1 时减 1。
// 按 x 排序,同坐标先更新后查询;树状数组维护 y 方向的差分,单点查覆盖数。
// 时间 O((L1+L2) log(L1+L2)),空间 O(L1+L2)(固定 26 字母表)。
#include <bits/stdc++.h>
using namespace std;

typedef long long ll;

const int MAXGROUP = 200000 + 10; // 核心组数不超过规则数 n
const int MAXNODE = 5000000 + 10; // 规则与有效询问的单侧总长度至多 5000000,另有两个根

// 普通 Trie;节点编号与 DFS 序至多 5000002,用 int 控制内存。
struct Node {
    int ch[26];
    int tin, tout; // 该节点子树的 DFS 序闭区间
};

struct Record {
    string a; // 两侧不变,只保留原串,目标串只用于找差异核心
    int l, r; // 首尾差异位置,0-based
    int left, right; // 本条记录在左右 Trie 中的终点
    int query_id; // 规则为 0,询问为原始询问编号
};

struct Event {
    int x;
    int y1, y2; // 更新区间 [y1,y2];查询点时 y1==y2
    int value; // 更新为 +1 或 -1,查询为 0
    int query_id; // 更新为 0,查询为原始询问编号
};

Node node[MAXNODE];
vector<int> group_records[MAXGROUP]; // group_records[g]:属于第 g 个核心组的记录编号
bool has_query[MAXGROUP]; // has_query[g]:第 g 组是否包含询问
ll bit[MAXNODE]; // 右 Trie 的 DFS 序上维护差分数组
int node_cnt, group_cnt, dfs_timer, right_size;
ll n, q;
map<pair<string, string>, int> core_id;
vector<Record> records(1); // 完整记录从编号 1 开始保存
vector<Event> events; // 当前核心组的矩形更新与点查询
vector<ll> answer; // answer[id]:第 id 个询问的答案

int new_node() {
    node_cnt++;
    // 组间复用节点池,清空旧孩子和 DFS 序。
    for (int c = 0; c < 26; c++) node[node_cnt].ch[c] = 0;
    node[node_cnt].tin = node[node_cnt].tout = 0;
    return node_cnt;
}

int get_child(int u, int ch) {
    if (node[u].ch[ch] == 0) node[u].ch[ch] = new_node();
    return node[u].ch[ch];
}

// 与主解相同:按差异核心分组,保存规则与有效询问。
void read_record(int query_id) {
    string a, b;
    cin >> a >> b;
    if (a.size() != b.size()) return;
    int l = -1, r = -1;
    ll len = a.size();
    for (ll i = 0; i < len; i++) {
        if (a[i] != b[i]) {
            if (l == -1) l = i;
            r = i;
        }
    }
    if (l == -1) return; // 恒等规则不能得到题目要求的不同目标串

    pair<string, string> key(a.substr(l, r - l + 1), b.substr(l, r - l + 1));
    auto it = core_id.find(key);
    int g;
    if (it == core_id.end()) {
        if (query_id != 0) return; // 规则中没有这个核心,询问答案为 0
        g = ++group_cnt;
        core_id[key] = g;
    } else {
        g = it->second;
    }
    if (query_id != 0) has_query[g] = true;

    Record current;
    current.a = move(a);
    current.l = l;
    current.r = r;
    current.left = current.right = 0;
    current.query_id = query_id;
    records.push_back(move(current));
    int record_id = records.size() - 1;
    group_records[g].push_back(record_id);
}

// 与主解相同:规则和询问一起插入,左侧反转、右侧正向,空侧对应根。
// 矩形计数需要同时记住每条记录的左右终点。
void build_group(int g, int left_root, int right_root) {
    for (auto record_id : group_records[g]) {
        int u = left_root;
        for (ll i = records[record_id].l - 1; i >= 0; i--) {
            u = get_child(u, records[record_id].a[i] - 'a');
        }
        records[record_id].left = u;

        u = right_root;
        ll len = records[record_id].a.size();
        for (ll i = records[record_id].r + 1; i < len; i++) {
            u = get_child(u, records[record_id].a[i] - 'a');
        }
        records[record_id].right = u;
    }
}

// 进入时编号,访问完所有孩子后记录子树右端点。
void dfs_order(int u) {
    node[u].tin = ++dfs_timer;
    for (int c = 0; c < 26; c++) {
        int v = node[u].ch[c];
        if (v != 0) dfs_order(v);
    }
    node[u].tout = dfs_timer;
}

// 每条规则生成矩形的两个更新事件,每个询问生成一个点查询。
void make_events(int g) {
    events.clear();
    for (auto record_id : group_records[g]) {
        int u = records[record_id].left;
        int v = records[record_id].right;
        Event event;
        event.x = node[u].tin;
        event.y1 = node[v].tin;
        event.y2 = node[v].tout;
        event.value = 1;
        event.query_id = records[record_id].query_id;
        if (event.query_id == 0) {
            events.push_back(event); // x=tin[u],矩形开始覆盖
            event.x = node[u].tout + 1;
            event.value = -1;
            events.push_back(event); // x=tout[u]+1,矩形结束覆盖
        } else {
            event.y2 = event.y1;
            event.value = 0;
            events.push_back(event);
        }
    }
}

// 同一横坐标先处理全部更新,再回答询问,保证矩形边界是闭区间。
bool cmp_event(const Event &a, const Event &b) {
    if (a.x != b.x) return a.x < b.x;
    return a.query_id < b.query_id;
}

void bit_add(int pos, ll value) {
    for (; pos <= right_size; pos += pos & -pos) bit[pos] += value;
}

ll bit_query(int pos) {
    ll result = 0;
    for (; pos > 0; pos -= pos & -pos) result += bit[pos];
    return result;
}

// 差分数组在左端加、右端后一位减,前缀和就是该位置的覆盖数。
void bit_range_add(int l, int r, ll value) {
    bit_add(l, value);
    bit_add(r + 1, -value);
}

void solve_events() {
    // 每组只清空本组右 Trie 用到的坐标,互不影响,总清理量受总长度控制。
    fill(bit + 1, bit + right_size + 1, 0);
    sort(events.begin(), events.end(), cmp_event);
    for (const auto &event : events) {
        if (event.query_id == 0) {
            bit_range_add(event.y1, event.y2, event.value);
        } else {
            answer[event.query_id] = bit_query(event.y1);
        }
    }
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    cin >> n >> q;
    records.reserve(n + q + 1);
    events.reserve(2 * n + q);
    answer.assign(q + 1, 0);
    for (ll i = 1; i <= n; i++) read_record(0);
    for (ll i = 1; i <= q; i++) read_record(i);
    core_id.clear();

    for (int g = 1; g <= group_cnt; g++) {
        if (!has_query[g]) continue;
        node_cnt = 0;
        int left_root = new_node();
        int right_root = new_node();
        build_group(g, left_root, right_root);

        // 两棵树分别从 1 编号,构成独立的横、纵坐标。
        dfs_timer = 0;
        dfs_order(left_root);
        dfs_timer = 0;
        dfs_order(right_root);
        right_size = dfs_timer;
        make_events(g);
        solve_events();
    }
    for (ll i = 1; i <= q; i++) cout << answer[i] << '\n';
    return 0;
}

复杂度

设 T=L1+L2T=L_1+L_2,所有实际处理的核心组的 Trie 节点数之和为 NN。规则与有效询问的两侧长度之和不超过 T/2T/2,每组额外建立两个根,因此 N=O(T)N=O(T)。

一个核心的查询或插入需要 O(log⁡(n+1))O(\log(n+1)) 次字符串比较。长度为 kk 的核心,单次比较最坏为 O(k)O(k);累加核心长度后,精确分组总时间为 O(Tlog⁡(n+1))O(T\log(n+1))。

普通 Trie 的单次转移为 O(1)O(1),插入总时间为 O(T)O(T)。节点初始化和递归 DFS 都枚举 2626 个孩子,总时间为 O(26N)O(26N);各组树状数组的清理总量为 O(N)O(N)。

各组事件总数不超过 2n+q2n+q。排序总时间不超过 O((n+q)log⁡(n+q))O((n+q)\log(n+q)),树状数组操作总时间不超过 O((n+q)log⁡(N+1))O((n+q)\log(N+1))。固定字母表下,总时间为:

O(Tlog⁡T). O(T\log T).

空间为 O(26N+T+n+q)O(26N+T+n+q),固定字母表下为 O(T)O(T)。节点池和树状数组按最大组复用,所有有效记录及其原串统一保存;相比只插入规则的实现,这一写法还需要保存询问并为它们建节点。当前 Node 为 112112 字节,数组按 5×106+105\times10^6+10 个节点预留空间。

与解法二相比,解法三每个询问至多生成一个事件,不必为每个可能结束位置生成子问题;代价是用有序字典精确比较核心字符串。两种解法复杂度同阶,解法三可以直接从首尾差异对齐推导出来。

正解:解法四,离线分桶建 Trie 与线性计数

关键观察

沿用解法一的前后缀分解与左右 Trie DFS 计数。计数本来就是线性的:每条规则激活、撤销一次,所有右祖先链长度之和为 O(L2)O(L_2)。

剩余开销来自核心分组中的字符串比较,以及普通插入时对孩子的查找。若要得到与字母表大小无乘法关系的严格线性做法,可以将这两部分统一换成离线分桶建 Trie。

思路

第一步:核心按 Trie 终点分组。

将所有有效规则和询问的差异核心编码为字符对串,一起离线建 Trie。两条核心相同,当且仅当在同一个节点结束,该终点上的记录就是一个核心组。

串的结束位置必须记录:短核心与它的更长扩展串虽然路径相交,仍然属于不同组。只有规则或只有询问的组可以跳过侧边建树。

第二步:每个节点一次收集所有串的下一个字符。

一个建树任务为 (u,H,d)(u,H,d):当前节点 uu、经过这里的记录集合 HH、下一字符下标 dd。遍历 HH,把已经结束的记录挂在 uu,其余按下一个字符分桶;每个非空桶创建一个孩子任务。

例如普通串 a、ab、ac、abx 经过前缀 a 时,a 在当前节点结束,ab、abx 进入 b 桶,ac 进入 c 桶。无须逐串搜索当前节点的出边,因为桶编号直接给出了对应字符。

每条串的每个字符只参与一次分桶,终点再处理一次,所以总工作量与所有串的长度和线性相关。

第三步:复用字符桶,而不是每个节点清空整个字母表。

只开一份桶,记录当前节点实际使用的桶编号。先取走所有非空桶的记录并清空它们,再处理任何孩子。这样每条边只付出一次取桶、清桶的成本。

不能每个节点或每个核心组都扫完整个字母表,也不能在父节点还有未取走的桶时开始处理孩子。main4.cpp 用显式任务栈安排处理顺序,桶中的集合用记录编号链表保存,移动一组记录只需保存链表头。

核心字母表大小为 26226^2,两侧字母表大小为 2626,整份实现共用 676676 个桶。

第四步:每个核心组用同样方式建立左右 Trie。

规则和询问的不变前缀反转后建左 Trie,不变后缀正向建右 Trie,直接记录各自的终点。左终点挂载记录的链表使用独立字段,避免右树建树时改坏它。

随后完全复用解法一的计数:左 DFS 进入时先激活所有规则,再回答询问,右侧沿父指针求和,退出时撤销。样例中的激活过程可以直接对照解法一的表格。

这里不需要二维扫描线、DFS 序或树状数组。与解法一的区别集中在“怎样分组和建树”,没有改变匹配条件或计数对象。

正确性。

核心 Trie 的同终点关系准确表示核心相等;离线分桶构造的左右 Trie,与普通逐字符插入构造的前缀关系相同。计数沿用解法一已经证明的左激活、右祖先链求和,因此结果不变。

代码

main4.cpp 保留原串与首尾差异位置,next_char() 按建树模式直接读取核心、反转不变前缀、正向不变后缀,不复制三段子串。

build_trie() 是共用的离线分桶过程;核心分组保存记录链表头,侧边建树时可以安全复用节点池。每组只重置节点分配计数器,实际新建节点由 new_node() 初始化,避免逐组清空大数组。enter_node()、leave_node() 和 dfs_left() 对应与解法一相同的激活计数。

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-15 00:29
 * update_at: 2026-10-15 11:26
 */
// main4.cpp:差异核心离线分桶 + 每组左右 Trie + DFS 激活与祖先链求和。
// 总时间 O(L1+L2),确定性;核心、左右侧都按下一个字符离线分组建树。
//
// ==================== 教学导读 ====================
// 【和 main.cpp 的关系】计数部分完全一样(前后缀分解 → 左 Trie DFS 激活 →
// 右 Trie 祖先链求和),差别只在「建树和分组」:main.cpp 用 map 比字符串分组、
// 用逐字符查孩子建 Trie,代价里带 log;本解把这两处都换成「离线分桶」,做到严格线性。
//
// 【离线分桶建 Trie】一次性拿到一批要插入的串,不用逐个从根往下找孩子:
// 在每个节点处,把所有经过这里的串按「下一个字符」分桶(26 或 676 个桶),
// 每个非空桶就是一个新孩子。这样每个字符只被读常数次,建树总代价 O(总长)。
// 串走到头(next_char 返回 -1)时,记录就挂在当前节点上——
// 相同的串必然挂在同一个终点,所以「终点上的记录链表」就是精确分组,不需要 map。
//
// 【三种建树模式】同一个 build_trie 用 mode 参数复用:
//   CORE:按差异核心的字符对 (a[pos], b[pos]) 建树,终点链表就是核心组;
//   LEFT:按核心左边那段的反转(从核心向左走)建树,终点链表挂规则/询问;
//   RIGHT:按核心右边那段正向建树,只记终点编号。
// 每个核心组复用全局节点池(node_cnt 归零重建),内存与单组规模无关。
//
// 【计数为什么线性】每条规则只在左 Trie 终点上挂一次、激活撤销各一次;
// 每个询问沿右 Trie 祖先链求和,所有询问的链长之和受右 Trie 总长 L2 限制。
// 总复杂度 O(L1+L2),不依赖字母表大小的乘法因子(桶是 O(1) 分发)。
//
// 【细节坑】全局 bucket 数组只在当前节点用过、且用完立刻清空,
// 所以父节点处理孩子之前必须先把所有桶收走(见 build_trie 末尾)。
#include <bits/stdc++.h>
using namespace std;

typedef long long ll;

const int MAXNODE = 5000000 + 10; // 有效输入单侧总长度至多 5000000,另有两个根
const int SIGMA = 26 * 26;
const int CORE = 0, LEFT = 1, RIGHT = 2;

// 一个有效规则或询问;保留原串,通过 l/r 访问三段,不复制子串。
struct Item {
    string a, b;
    int l, r;
    int next;      // 离线分桶时的临时链表;核心建完后连接同核心的记录
    int left_next; // 挂在左 Trie 终点的记录链表,不受右 Trie 建树影响
    int right;     // 右 Trie 的终点
    int id;        // 规则为 0,询问为原编号
};

// 节点编号、记录数不超过 5000002,用 int 控制大数组内存。
struct Node {
    int child, sibling, parent;
    int items;  // 核心树或左 Trie 的终点记录链表
    int weight; // 右 Trie 上当前激活的规则数量,最多 n
};

struct Bucket {
    int head; // 下一字符相同的记录链表,0 表示空桶
};

struct Work {
    int u, head, depth; // 在 u 处理这一批记录,当前字符下标为 depth
};

Node node[MAXNODE];
Bucket bucket[SIGMA]; // 全过程共用,初始化一次,只清空实际使用的桶
vector<Item> item;
vector<Work> work;
vector<int> used, groups, members;
vector<ll> answer;
ll n, q;
int node_cnt;

// 节点池先用于核心树,然后每个核心组复用;只初始化实际新建的节点。
int new_node(int parent) {
    node_cnt++;
    node[node_cnt].child = node[node_cnt].sibling = 0;
    node[node_cnt].parent = parent;
    node[node_cnt].items = node[node_cnt].weight = 0;
    return node_cnt;
}

// 读入规则或询问,长度不同、无变化的记录不参与建树。
void read_item(int query_id, int &head) {
    Item current;
    cin >> current.a >> current.b;
    if (current.a.size() != current.b.size()) return;
    current.l = current.r = -1;
    ll len = current.a.size();
    for (ll i = 0; i < len; i++) {
        if (current.a[i] != current.b[i]) {
            if (current.l == -1) current.l = i;
            current.r = i;
        }
    }
    if (current.l == -1) return;
    current.next = head;
    current.left_next = current.right = 0;
    current.id = query_id;
    item.push_back(move(current));
    head = item.size() - 1;
}

// 按建树模式读下一个字符;-1 表示串已结束。
// 核心读取字符对(保证 (A,B) 逐位一致),左侧反转读取,右侧正向读取。
// depth 是「已经匹配的字符个数」,三种模式共用同一个分桶循环。
int next_char(int id, int depth, int mode) {
    const Item &s = item[id];
    if (mode == CORE) {
        if (depth > s.r - s.l) return -1;
        int pos = s.l + depth;
        return (s.a[pos] - 'a') * 26 + s.b[pos] - 'a';
    }
    if (mode == LEFT) {
        if (depth >= s.l) return -1;
        return s.a[s.l - 1 - depth] - 'a';
    }
    ll pos = s.r + 1 + depth;
    ll len = s.a.size();
    if (pos >= len) return -1;
    return s.a[pos] - 'a';
}

// 离线建 Trie:每个节点把经过这里的记录按下一字符分桶。
// work 是显式任务栈,串再长也不使用递归;孩子不需要保存边字符(字符就是桶号)。
// 串走到头时:CORE/LEFT 模式把记录挂到当前节点的 items 链表上(终点即分组),
// RIGHT 模式只把终点编号写回 item[id].right。
void build_trie(int root, int head, int mode) {
    work.clear();
    work.push_back({root, head, 0});
    while (!work.empty()) {
        Work current = work.back();
        work.pop_back();
        used.clear();
        for (int id = current.head; id != 0; ) {
            int next = item[id].next; // 改链表前保存原来的下一条记录
            int ch = next_char(id, current.depth, mode);
            if (ch == -1) {
                if (mode == CORE) {
                    item[id].next = node[current.u].items;
                    node[current.u].items = id;
                } else if (mode == LEFT) {
                    item[id].left_next = node[current.u].items;
                    node[current.u].items = id;
                } else {
                    item[id].right = current.u;
                }
            } else {
                if (bucket[ch].head == 0) used.push_back(ch);
                item[id].next = bucket[ch].head;
                bucket[ch].head = id;
            }
            id = next;
        }
        // CORE 模式:终点上挂满记录,就是一个完整的差异核心组,交给 solve_groups。
        if (mode == CORE && node[current.u].items != 0) {
            groups.push_back(node[current.u].items);
        }
        // 必须先取走并清空本节点的所有桶,再处理任何孩子,才能复用全局桶。
        for (size_t i = 0; i < used.size(); i++) {
            int ch = used[i];
            int v = new_node(current.u);
            node[v].sibling = node[current.u].child;
            node[current.u].child = v;
            work.push_back({v, bucket[ch].head, current.depth + 1});
            bucket[ch].head = 0;
        }
    }
}

// 根据本核心组的记录编号,重建临时链表;左右建树分别调用一次。
int make_list() {
    int head = 0;
    for (size_t i = 0; i < members.size(); i++) {
        int id = members[i];
        item[id].next = head;
        head = id;
    }
    return head;
}

// 进入左 Trie 的节点:先激活全部规则,再回答同节点的全部询问。
// 「激活」= 在右 Trie 的规则终点 weight +1,表示这条规则当前可用。
void enter_node(int u) {
    for (int id = node[u].items; id != 0; id = item[id].left_next) {
        if (item[id].id == 0) node[item[id].right].weight++;
    }
    for (int id = node[u].items; id != 0; id = item[id].left_next) {
        if (item[id].id != 0) {
            ll result = 0;
            // 暴力沿右 Trie 的祖先链求和;所有询问的链长之和受 L2 限制。
            // 被统计到的规则:左端已被 DFS 路径保证「P 是 X 的后缀」,
            // 右端是 v 的祖先保证「S 是 Y 的前缀」,两个条件都恰好满足。
            for (int v = item[id].right; v != 0; v = node[v].parent) {
                result += node[v].weight;
            }
            answer[item[id].id] = result;
        }
    }
}

// 离开左 Trie 的节点:撤销这里的规则,避免影响其他分支。
void leave_node(int u) {
    for (int id = node[u].items; id != 0; id = item[id].left_next) {
        if (item[id].id == 0) node[item[id].right].weight--;
    }
}

// 用 child / sibling / parent 模拟 DFS,不开递归栈或额外长链栈。
// 进入 = 激活当前节点上的规则,离开 = 撤销,保证兄弟分支不互相干扰。
void dfs_left(int root) {
    int u = root;
    while (u != 0) {
        enter_node(u);
        if (node[u].child != 0) {
            u = node[u].child;
            continue;
        }
        while (u != 0) {
            leave_node(u);
            if (node[u].sibling != 0) {
                u = node[u].sibling;
                break;
            }
            u = node[u].parent;
        }
    }
}

void solve_groups() {
    for (size_t g = 0; g < groups.size(); g++) {
        members.clear();
        bool has_rule = false, has_query = false;
        for (int id = groups[g]; id != 0; id = item[id].next) {
            members.push_back(id);
            if (item[id].id == 0) has_rule = true;
            else has_query = true;
        }
        if (!has_rule || !has_query) continue;

        // groups 保存的是记录链表头,不是核心树节点,因此可以安全复用节点池。
        node_cnt = 0;
        int left_root = new_node(0);
        int right_root = new_node(0);
        build_trie(left_root, make_list(), LEFT);
        build_trie(right_root, make_list(), RIGHT);
        dfs_left(left_root);
    }
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    cin >> n >> q;
    item.reserve(n + q + 1);
    item.resize(1); // 记录编号从 1 开始,0 是链表结束标记
    answer.assign(q + 1, 0);
    int head = 0;
    for (ll i = 1; i <= n; i++) read_item(0, head);
    for (ll i = 1; i <= q; i++) read_item(i, head);
    int core_root = new_node(0);
    build_trie(core_root, head, CORE);
    solve_groups();
    for (ll i = 1; i <= q; i++) cout << answer[i] << '\n';
    return 0;
}

复杂度

设 L=L1+L2L=L_1+L_2。每条有效记录的「不变前缀 + 差异核心 + 不变后缀」长度恰好等于单侧串长,累加不超过 L/2L/2。核心及两侧的离线分桶、各组节点遍历与记录处理共 O(L)O(L);右祖先链查询共 O(L2+q)O(L_2+q)。

固定字母表下,总时间为 O(L)O(L)。若把 ∣Σ∣|\Sigma| 作为变量,一次桶初始化额外需要 O(∣Σ∣)O(|\Sigma|),严格写为:

O(L+∣Σ∣) 时间,O(L+∣Σ∣) 空间. O(L+|\Sigma|)\text{ 时间},\qquad O(L+|\Sigma|)\text{ 空间}.

这里没有逐节点或逐组的 ∣Σ∣|\Sigma| 因子。待处理任务的记录集合互不相交,任务栈空间也受记录数限制。离线分桶和节点池复用是本实现新增的技巧;学习时可以在掌握解法一后单独理解这部分。

总结

按数据范围推进,本题的思考路线是:

  1. 小数据枚举规则与位置,把一次替换的条件写清楚。
  2. 首个差异必须对齐,确定每条规则唯一的替换起点,把单询问降到 O(L1)O(L_1);q=1q=1 时直接得到线性做法。
  3. 性质 B 将完整匹配压缩成 b 的位移相等和左右空间上界,转成二维偏序计数。
  4. 一般数据优先学习解法一:固定差异核心,再将不变前后缀匹配转成双 Trie 祖先条件,左 DFS 激活、右祖先链求和。
  5. 解法二展示 AC 多模式匹配路线;解法三保留同样的核心分解,但用矩形扫描线处理两个祖先条件。
  6. 解法四继续优化解法一的核心分组与建树,使用离线分桶得到确定性线性时间。

正式主解采用解法一:分组、建树和计数各自清楚,代码以常见的 map、普通 Trie 和 DFS 为主。它还展示了一个值得迁移的复杂度观察:单次查询可以走长链,只要所有查询链长之和受输入总长度限制。

其他解法速览与对比

洛谷题解区对本题有大量独立解法。调研热度最高的若干篇后可以看到:多数做法可以归入差异核心的前后缀分解与多模式匹配两条主线,另有几条值得了解的替代路线。下表按路线归类,L=L1+L2L=L_1+L_2。

路线 核心算法 时间复杂度 空间复杂度
分隔符编码多模匹配 把规则编成模式 A?BD?CA?BD?C、询问编成文本 A′?B′D′?C′A'?B'D'?C'(A,CA,C 为最长公共前后缀,B,DB,D 为核心,? 为分隔符),AC 自动机数出现次数 O(L⋅∣Σ∣)O(L\cdot\lvert\Sigma\rvert) O(L1)O(L_1)
核心分组 + 双 Trie + 二维数点 同解法三的建模,分组与匹配多用字符串哈希 O(Llog⁡L)O(L\log L) O(L+n+q)O(L+n+q)
左 DFS 激活 + 右 Trie 树状数组 同解法一、四的 DFS 计数框架,右侧用 DFS 序区间加、单点查 O(L+(n+q)log⁡L)O(L+(n+q)\log L) O(L+n+q)O(L+n+q)
fail 树主席树(在线) AC 自动机匹配 s1s_1,把左右串的哈希差作为权值,在 fail 树根路径上用主席树计数,右端相等用后缀哈希判断 O(Llog⁡mod)O(L\log \mathrm{mod}) O(Llog⁡mod)O(L\log \mathrm{mod})
字典序栈扫描 组内按反转前缀排序,栈维护前缀合法集合,Trie 维护后缀计数 O(Llog⁡L)O(L\log L) O(L)O(L)
单 Trie + 路径哈希二分 核心交错编码进一棵 Trie,路径哈希记根路径规则数,查询二分最长存在前缀 O(Llog⁡L)O(L\log L) O(L)O(L)
长度种类数枚举 不同规则长度至多 2L1\sqrt{2L_1} 种,枚举起点与长度种类后哈希校验 最坏 O(L2L1)O(L_2\sqrt{L_1}) O(L)O(L)
后缀数组 + 分块 字符对字母表上把子串相等转成排名区间,扫描线 + 三层分块 O(nL1/3+L)O(nL^{1/3}+L) O(L)O(L)

几点对比结论:

  1. 建模收敛。「差异核心相等 + 左侧后缀 + 右侧前缀」被多篇题解独立推出,对应解法一、三、四的模型;多模式匹配路线则对应解法二。四份实现展示了这些模型下不同的分组、建树与计数选择。
  2. 确定性是本题解的差异化优势。社区多数做法用字符串哈希做核心分组或匹配,理论上存在碰撞风险;解法一、三用 map 精确比较核心,解法四用 Trie 终点分组,全程不依赖哈希。
  3. 分隔符编码是解法二的简化方向。它把「覆盖全部差异位置」编码进模式串本身,匹配即合法,因而不需要长度阈值离线计数;代价是把字符对字母表换成了带分隔符的单字符字母表。
  4. 在线与离线的取舍。需要逐询问在线回答时,主席树或单 Trie 二分更自然;解法一、三、四为离线批量,但换来更少的随机化依赖。
  5. 实现教训。高频调用中按值传 string 足以造成超时;前缀和数组与循环分支不同步会静默丢分。各实现的计数撤销、退出事件等对称操作值得对照检查。

参考与致谢

下列题解用于思路对照与正确性交叉验证。本文的叙述、证明与代码均为独立撰写,未复制任何原文;每条列出其核心算法与复杂度声明。

  • 题解:P14363 谐音替换(暂无数据) —— Register_int (uid 406941):分隔符编码多模匹配 + AC 自动机;时间 O(L⋅∣Σ∣)O(L\cdot\lvert\Sigma\rvert),空间 O(L1)O(L_1)。
  • P14363 谐音替换 / replace 题解 —— tcqwq (uid 532560):核心哈希分组 + 左 Trie + 右侧哈希前缀拆分与 DFS 桶;时间 O(Llog⁡L+26L)O(L\log L+26L),空间 O(L)O(L)。
  • 论如何用六级算法爆切 [CSP-S 2025] 谐音替换 —— pumpkin_pie_ (uid 538131):单 Trie 核心交错编码 + 路径哈希 + 二分最长存在前缀;时间 O(Llog⁡L)O(L\log L),空间 O(L)O(L)。
  • 谐音替换新做法! —— Moya_Rao (uid 814130):核心分组 + 字典序扫描 + 栈与 Trie;时间 O(Llog⁡L)O(L\log L),空间 O(L)O(L)。
  • 题解:P14363 谐音替换(暂无数据) —— nsp (uid 1007758):核心哈希分组 + 双 Trie DFS 序矩形 + BIT 二维数点;时间 O(L+(n+q)log⁡(n+q))O(L+(n+q)\log(n+q)),空间 O(L+n+q)O(L+n+q)。
  • P14363 谐音替换 —— zhlzt (uid 571147):左 DFS 激活 + 右 Trie 树状数组;时间 O(L+(n+q)log⁡L)O(L+(n+q)\log L),空间 O(L+n+q)O(L+n+q)。
  • 题解:P14363 谐音替换(挑战最短代码) —— kevinZ99 (uid 1117080):长度种类数至多 2L1\sqrt{2L_1},枚举起点与长度后哈希校验;最坏时间 O(L2L1)O(L_2\sqrt{L_1}),空间 O(L)O(L)。
  • 题解:P14363 谐音替换 —— cff_0102 (uid 542457):本质分组 + 双 Trie,指出朴素标记合并的退化风险,主张 DFS 序 + BIT 离线;时间 O(Llog⁡L)O(L\log L),空间 O(L+n+q)O(L+n+q)。
  • SA 爬了。 —— lzyqwq (uid 539211):后缀数组排名区间 + 长度扫描线 + 三层分块(未完成实现);理论时间 O(nL1/3+L)O(nL^{1/3}+L),空间 O(L)O(L)。
  • 神秘猎奇跑不满做法爆踩 ACAM —— 时之虫 (uid 368204):按前缀哈希分组,枚举左端点 + Trie 哈希表二分最长后缀;时间 O(Llog⁡L)O(L\log L) 到 O(Llog⁡2L)O(L\log^2 L),空间 O(L)O(L)。
  • 谐音替换 时空严格线性 题解 —— its_mygo (uid 384596):离线分桶建 Trie + 左 DFS 激活 + 右侧祖先链求和,与解法四同型;时间 O(L)O(L),空间 O(∣Σ∣+L)O(\lvert\Sigma\rvert+L)。
  • 题解:P14363 谐音替换(暂无数据) —— dayz_break404 (uid 688596):极小差异区间 + 双 Trie 二维数点;时间 O(Llog⁡L)O(L\log L),空间 O(L+n+q)O(L+n+q)。

图示解析

下面保留的一图流对应第五层的解法二,可用于复盘字符对、覆盖条件和 fail 树计数之间的关系。

字符对 AC 自动机与 fail 树离线计数图解

阅读时对照正文中的 ee(图中的 i)、L,RL,R 和 need:长度阈值筛选规则,fail 子树筛选能在当前状态匹配的规则。这张图解释解法二;解法一、四使用 DFS 激活模型,解法三使用矩形覆盖模型,前面各层的适用范围以解法路线表为准。