以 map 精确分组差异核心、双 Trie DFS 激活和祖先链求和为主解,并对照 AC、二维扫描线与确定性线性做法。
OJ: luogu
题目 ID: P14363
难度:省选/NOI-
目录
形式化题目
给定
相同的字符串二元组如果编号不同,仍然是不同规则。替换区间外的字符保持不变,不能连续使用多条规则。
交互学习
以下教程均为可离线打开的单文件 HTML,支持单步、回退与回看。解法三按“先看核心图、再演示和预测”的顺序,承接已经理解的双 Trie 匹配条件。
- 解法一 · 双 Trie 交互课:七关走通主解
main.cpp,从差异对齐、前后缀分解、map 核心分组,到双 Trie 反转、DFS 激活与撤销、右祖先链求和。与解法路线的建议一致,先学这一门。 - 解法二 · AC 交互课:八关走通
main2.cpp,从合法替换、字符对匹配,走到 fail 树、DFS 序和离线计数。 - 解法三 · 双 Trie 到矩形扫描线:联动两棵 Trie 的子树区间与二维矩形,先直接数覆盖,再移动扫描线观察纵坐标贡献,最后对应
main3.cpp的事件、差分与树状数组。
解法路线
沿用题面的定义:
题面中的子任务分为小数据、单询问、特殊字符串和一般大数据,适合按下面的顺序学习。
| 层次 | 对应测试点与约束 | 做法 | 时间复杂度 |
|---|---|---|---|
| 第一层 | 枚举规则和起点,逐位检查 | ||
| 第二层 | 用首个差异确定唯一替换起点 | ||
| 第三层 | 性质 A: |
继续使用第二层,逐条规则检查一次 | |
| 第四层 | 性质 B:每个串恰好有一个 b,其余为 a;覆盖 |
按 b 的位移分组,二维偏序计数 |
|
| 第五层,解法一(主解) | 一般数据: |
map 精确分组 + 每组普通双 Trie + DFS 激活与祖先链求和 | |
| 第五层,解法二 | 同上,适用于全部数据 | 字符对 AC 自动机 + fail 树 + 离线树状数组 | |
| 第五层,解法三 | 同上,适用于全部数据 | 差异核心分组 + 左右 Trie + 二维扫描线 | |
| 第五层,解法四 | 同上,适用于全部数据 | 离线分桶建 Trie + DFS 激活 + 祖先链求和 |
表中
性质 A 和性质 B 是从第二层分出的两条支路。第五层有四种完整解法:解法一作为主解,使用 map 精确分组和每组双 Trie 的 DFS 计数,代码为 main.cpp;AC、二维扫描线、线性分桶做法分别作为解法二、三、四,对应 main2.cpp、main3.cpp、main4.cpp。建议先掌握解法一,再按需要学习其他实现。
暴力解法
适用范围
测试点
思路
每次询问枚举规则
; - 区间内替换成
,区间外保持 的原字符,得到的整个字符串是否等于 。
每通过一次检查,答案加
代码
brute.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;
}复杂度与瓶颈
对于长度为
总时间为
这里有两重浪费:枚举了许多根本不可能的起点,又在每个起点重新检查整个目标串。下一层先把起点确定下来。
子任务解法:
适用范围
测试点
思路
先找出询问中真正需要改变的部分。
若
注意,合法区间可以比
再利用规则自己的第一个差异。
对规则
假设从
它必须等于询问的
一条规则只有一个可能的起点,不需要枚举位置。 因此,同一规则在原串中即使出现多次,对于给定的不同目标串也至多贡献一个方案。
确定
且 ,替换区间没有越界; ,覆盖最后一个不同位置; - 区间中的每一位同时满足
、 。
因为
下面用样例 1 的询问 xabcx → xadex 看唯一对齐的结果。这里
| 规则 | 首个差异 |
唯一起点 |
替换区间 | 是否可行 |
|---|---|---|---|---|
xabcx → xadex |
可行,整串替换 | |||
ab → cd |
不可行,原串这里是 bc |
|||
bc → de |
可行 | |||
aa → bb |
不可行,原串这里是 bc |
每行只检查一个起点。整串规则虽然包含两端不变的 x,仍然合法;这也说明只检查最短差异区间会漏算。两条可行规则给出答案
代码
anchor.cpp 预处理每条规则的 first_diff,每次询问只比较该规则唯一对齐的区间。
/**
* 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;
}复杂度与瓶颈
预处理所有规则耗时
总时间为:
空间为
起点枚举已经消失,但每个询问仍要重新扫描所有规则。下一层先看看,题面给出的特殊性质能否消除这项乘积。
子任务解法:特殊性质 A,
适用范围
测试点
思路
无需设计新算法,把第二层的复杂度代入
虽然规则可能很多、字符串可能很长,但所有规则的长度加起来有上界。逐条规则检查唯一候选区间,每个规则字符最多比较一次,总工作量仍然是线性的。
这就是性质 A 的作用:它允许我们花一次
代码
同上一层的 anchor.cpp,直接输入
复杂度与瓶颈
时间
当询问很多时,
子任务解法:特殊性质 B,每个串恰好有一个 b
适用范围
测试点 a,b,且各自恰好有一个 b。
思路
把字符串压缩成长度和 b 的位置。
一条规则用 b 在 b 在 b 在 b 在
若询问长度不同,答案为
b 的位置决定了唯一的替换起点。
若替换区间不包含原串的 b,原来的 b 会留在外面,而规则输出又带有一个 b,不可能得到目标串。因此规则中的原 b 必须对齐询问的原 b,替换起点为:
替换后的 b 位于
记这个位移为
剩余条件只是左右空间够不够。
规则中原 b 左侧有 a,右侧有 a;询问的原 b 两侧分别有 a。区间不越界恰好要求:
位移条件已经保证新的 b 在目标位置;其余所有位置都是 a。所以这些条件满足后,无需再逐字符比较。
于是,一条规则是二维点
排序消掉一维,树状数组处理另一维。
把规则和询问都按
- 遇到规则,把它的第二维在树状数组中加
; - 遇到询问,查询第二维不超过其上界的前缀和;
- 切换
组前,撤销本组加入的规则。
第一维条件由扫描顺序保证,第二维条件由前缀和保证。第二维先离散化,重复规则作为多个点加入,不去重。
例如规则 abaa → aaba 有 aabaaa → aaabaa 有
代码
special_b.cpp 只适用于性质 B。代码用 id=0 表示规则事件,用正数表示询问编号,并在每组结束后撤销树状数组的更新。
/**
* 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 共
总时间:
空间为
当一个串有多个不同字母时,长度和一个位置不再能描述全部内容。一般数据必须同时验证整段替换前串、替换后串,还要让众多询问复用同一份规则索引。
正解:解法一,map 分组与双 Trie DFS(主解)
关键观察
前面的首个差异对齐还可以推进一步:规则的最后一个差异,也必须对齐询问的最后一个差异。
设规则的首尾差异位置为
所以两者的差异跨度相同,且首尾差异之间的双串内容必须逐位一致。我们可以先固定这段差异核心,再分别处理不变前缀、不变后缀的匹配。
思路
第一步:用前后缀分解,写出规则可用的充要条件。
将一条非恒等规则拆成:
将一个等长询问同样拆成:
规则可用,当且仅当:
,差异核心完全相同; 是 的后缀,即不变前缀紧贴核心向左匹配; 是 的前缀,即不变后缀紧贴核心向右匹配。
必要性来自首尾差异对齐与区间内的双串匹配。充分性也很直接:若
在中间使用一次规则,就恰好得到目标串。后缀、前缀条件也保证了不越界,替换起点唯一,为
第二步:用 map 精确分组,先消除核心条件。
使用 map<pair<string,string>,int> 为不同的规则差异核心编号。保存的是两条实际字符串,比较结果确定,不依赖字符串哈希。
询问找不到对应的规则核心,答案就是
同组的每条规则、每个询问都保留为一条记录。内容相同而编号不同的规则仍然保留多条,后面分别加权。
第三步:每组普通插入,建立左右两棵 Trie。
把组内规则的
反转后,
祖先关系包含节点本身。这里规则和询问都插入,因此每条记录都有完整终点;插入询问只会增加路径,不会改变规则与询问之间的前缀关系。
建 Trie 使用普通的逐字符插入:每个节点保存 ch[26],ch[c] 就是字符
第四步:DFS 左 Trie,自动筛出第一个祖先条件。
在右 Trie 每个节点上维护权值
- 进入左节点
,激活所有左终点在这里的规则:它们的右终点权值加 ; - 回答左终点在这里的所有询问;
- 访问孩子子树;
- 离开
,撤销这里的规则:对应右终点权值减 。
回答询问时,当前激活的规则恰好满足「左终点在当前节点的祖先链上」。因此第一个条件已经成立。
同节点上的规则要先全部激活,再回答询问。否则结果会错误地依赖记录存放顺序。离开时的撤销也不可省略,否则规则会泄漏到兄弟分支。
第五步:直接沿右祖先链求和,筛出第二个条件。
设询问右终点为
为什么直接走链不会太慢?询问
输入已经为这些长链付出了字符串长度,不能再用“询问数乘最长链长”估计实际总成本。 这也是本解法可以直接求和、不必再维护树状数组的原因。
下面用样例 1 的询问 xabcx → xadex 看完整计数。它的不变前缀是 xa,差异核心为 (bc,de),不变后缀为 x。同核心规则有整串规则和 bc → de;左 Trie 是「根 → a → ax」,右 Trie 是「根 → x」。
| 左 Trie 的操作 | 右根权值 | 右 x 权值 |
含义 |
|---|---|---|---|
| 进入左根 | 激活 bc → de,它的两侧都为空 |
||
进入左 ax |
激活整串规则 | ||
回答左 ax 上的询问 |
沿右 x → 根求和,答案为 |
||
离开左 ax |
撤销整串规则 | ||
| 离开左根 | 撤销短规则 |
左路径保证不变前缀匹配,右路径保证不变后缀匹配;核心分组保证变化内容相同。三部分合起来,得到原题要求的两个方案。
正确性。
前后缀分解已经证明三个匹配条件充要。核心分组先保证第一个条件;左 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] 是核心组桶,保存属于第 record_ids[u] 是节点桶,保存左终点为 vector<int>,加入时 push_back(record_id),处理时直接遍历。has_query[g] 单独标记第 records[record_id] 保存完整记录,记录中的 query_id 是原询问编号,规则用
/**
* 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;
}复杂度
设 map 查询或插入,需要
每次插入直接访问 ch[c],所有字符转移共
因此总时间为:
固定字母表下写为 Node 只保存 parent、weight 两个整数,通常占 record_ids、核心组桶 group_records 与标记桶 has_query 都独立保存,每条记录分别在自己的核心组和左终点各保存一个编号。空间为
这份实现以常见的 map、普通 Trie 和 DFS 为主线,保留了直接的前后缀分解与计数过程。进一步追求严格线性时,再看解法四如何替换核心分组与建树方式。
正解:解法二,AC 自动机与 fail 树离线计数
关键观察
第二层已经得到两个能用于一般数据的结论:
- 替换区间必须覆盖第一个到最后一个不同位置
; - 区间内必须同时匹配规则的替换前串和替换后串。
我们希望把逐询问、逐规则的检查,改成一次扫描就同时匹配所有规则。
思路
第一步:把两条串合成一条字符对串。
把同一位置上的两个字符
编码为
规则字符对串在询问中出现,等价于替换前子串和替换后子串同时匹配。不过这还不是合法替换,必须再检查出现区间是否覆盖
为什么不能直接统计所有字符对匹配?例如规则 a → a 会匹配到询问 ab → ac 的位置 ab。
第二步:把覆盖条件改成结束位置和长度条件。
设一次匹配结束于位置
整理第二个不等式,得到:
因此,扫描到每个
第三步:AC 自动机给出所有以当前位置结尾的规则。
将规则字符对串全部插入 Trie,再建立 AC 自动机。扫描询问至位置
这里的长度是节点在原 Trie 中的深度 node_depth[u],不是它在 fail 树中的层数。终止权重 terminal_weight[u] 表示有多少个规则编号在这个节点结束。
不能每次遍历整条 fail 链,否则大量后缀匹配仍会让时间膨胀。我们把每个扫描位置记录为离线子问题:
它要求:统计 need 的终止节点权重和。
下面继续看样例 1。字符对串是 (x,x),(a,a),(b,d),(c,e),(x,x),仍有
| 结束位置 |
长度下界 |
以此处结尾的终止规则 | 本位置贡献 |
|---|---|---|---|
| 不生成子问题 | 尚未覆盖最后一个差异 | ||
bc → de,长度 |
|||
xabcx → xadex,长度 |
在 bc → de 对应的终止节点在它的 fail 链上。因此只看当前状态是否终止会漏算。把两个位置的贡献相加,就得到答案
第四步:把祖先查询转成子树加。
把每个非根节点的 fail 指针作为父指针,得到 fail 树。规则终止节点
所以,激活一条规则时,可以给其终止节点
求出 fail 树的 DFS 序后,
- 激活
:区间加 terminal_weight[u]; - 查询
:单点查询 tin[v]。
区间加
第五步:按长度下界从大到小离线。
把终止节点按 Trie 深度降序排序,子问题按 need 降序排序。处理一个子问题前,把所有满足
的终止节点激活。随着 need 变小,只会新增合格规则,不需要删除。
此时 tin[state] 的值,正好是「既为当前状态的 fail 祖先,又满足长度下界」的规则数。将它累加到原询问的答案中。
正确性。
每个合法替换,都对应一个字符对匹配,且区间覆盖
每次出现只归属于一个结束位置;同节点上的多个规则编号通过权重分别计数。所以不会漏算,也不会把同一个方案算两次。
代码
main2.cpp 使用 vector<int> g[maxn] 邻接表保存字符对 Trie 和 fail 树,边字符挂在孩子节点上,避免给每个节点开 find_child() 用二分查找转移;build_ac_automaton() 用 std::queue 做 BFS 建 fail 指针。fail 树分两步:build_fail_tree() 只把每个节点挂到 fail 指针下加边,dfs_fail_tree() 再在树上递归求 DFS 序;由于 fail 树最坏可能深达百万级(一条超长规则就是一条长链),main 开头用 setrlimit 把系统栈上限调到
process_query() 对应前三步,生成 (need,state,id);build_fail_tree()、dfs_fail_tree() 和 answer_offline_asks() 对应后两步,完成子树加与离线查询。
/**
* 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 节点数为
节点数不超过规则单侧总长度加根;每个等长询问位置最多生成一个子问题。不同长度询问不会生成子问题。
字母对种类数
终止节点至多
空间为
实现时还要注意:相同规则不能去重;长度下界必须是
正解:解法三,双 Trie 的二维扫描线计数
关键观察
第二层已经证明首个差异必须对齐;最后一个差异也必须对齐。设规则的首尾差异位置是
所以规则的差异跨度
这使得我们可以先把变化部分固定下来,再分别处理左右两侧,不必在询问的所有位置搜索规则。
思路
第一步:把规则和询问拆成三段。
将一条非恒等规则写成:
同样,将一个等长询问拆成:
其差异核心是
,两段核心逐字符完全相同; 是 的后缀,规则左侧能紧贴核心向左匹配; 是 的前缀,规则右侧能紧贴核心向右匹配。
条件 2、3 已经包含不越界的要求:比
第二步:按差异核心精确分组。
只有核心相同的规则和询问才能配对。用 map<pair<string,string>,int> 给每种规则核心编号,询问通过 find() 查找编号。找不到对应核心就回答
这里保存并比较的是两条实际字符串,不存在哈希碰撞;它们一起描述的内容,与字符对核心完全相同。左右串相同的规则直接忽略,因为它无法得到题目要求的不同目标串。
第三步:两棵普通 Trie 处理两侧匹配。
与解法一保持相同的建树方式:每个核心组单独建立两棵 Trie,规则和询问一起插入。规则的
记询问的两个终点为
祖先关系包含节点本身。这里使用的是普通 Trie 的父子关系,没有 fail 树,也没有 fail 指针。
询问也插入后,每个询问都对应两个完整的终点,不需要额外处理缺边。新建的询问节点不会产生规则贡献;例如规则侧边为 a、ab,询问侧边为 ac,插入后 a 仍是询问终点的祖先,ab 则不是,匹配条件没有改变。
第四步:把两个祖先条件变成矩形覆盖。
分别求两棵 Trie 的 DFS 序。左 Trie 的一个节点
右 Trie 同理。于是每条规则对应一个矩形:
每个询问对应一个点:
答案就是同一核心组内,有多少个规则矩形覆盖询问点。规则编号不同就生成不同矩形,哪怕矩形和核心都相同,也分别计数。
样例 1 的询问 xabcx → xadex 拆成 ax,右侧是 x。下表展示规则如何参与同一个询问。
| 规则 | 核心 | 不变前缀 |
不变后缀 |
对本询问的贡献 |
|---|---|---|---|---|
xabcx → xadex |
(bc,de) |
xa |
x |
|
bc → de |
(bc,de) |
空 | 空 | |
ab → cd |
(ab,cd) |
空 | 空 | |
aa → bb |
(aa,bb) |
空 | 空 |
只有前两行与询问属于同一核心组。两棵 Trie 中,整串规则分别结束在 ax 和 x,短规则结束在两个根;询问点同时位于这两条规则的子树矩形中,所以答案为
这个样例的左 Trie 是「根 → a → x」,DFS 序依次为 x」,DFS 序为
第五步:扫描线与差分树状数组。
对于规则矩形
- 横坐标
:将纵坐标区间 加 ; - 横坐标
:将同一区间减 。
询问点
扫描时,横坐标条件由矩形的进入、退出事件保证;纵坐标条件由差分树状数组的区间加、单点查保证。每个有效询问只查一次,每条非恒等规则只产生两个事件。
各核心组复用节点池、事件容器和树状数组。新组重新建树与编号,清空事件,并把本组右 Trie 会用到的树状数组位置清零。清理量与本组节点数成正比,不必每组清空整个大数组。
正确性。
若一次替换合法,规则的首尾差异必然分别对齐询问的首尾差异,因此核心相同,左右不变部分分别匹配询问的后缀、前缀。反过来,若核心相同且两侧匹配,从
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 沿用本题栈空间充足的评测条件。
/**
* 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;
}复杂度
设
一个核心的查询或插入需要
普通 Trie 的单次转移为
各组事件总数不超过
空间为 Node 为
与解法二相比,解法三每个询问至多生成一个事件,不必为每个可能结束位置生成子问题;代价是用有序字典精确比较核心字符串。两种解法复杂度同阶,解法三可以直接从首尾差异对齐推导出来。
正解:解法四,离线分桶建 Trie 与线性计数
关键观察
沿用解法一的前后缀分解与左右 Trie DFS 计数。计数本来就是线性的:每条规则激活、撤销一次,所有右祖先链长度之和为
剩余开销来自核心分组中的字符串比较,以及普通插入时对孩子的查找。若要得到与字母表大小无乘法关系的严格线性做法,可以将这两部分统一换成离线分桶建 Trie。
思路
第一步:核心按 Trie 终点分组。
将所有有效规则和询问的差异核心编码为字符对串,一起离线建 Trie。两条核心相同,当且仅当在同一个节点结束,该终点上的记录就是一个核心组。
串的结束位置必须记录:短核心与它的更长扩展串虽然路径相交,仍然属于不同组。只有规则或只有询问的组可以跳过侧边建树。
第二步:每个节点一次收集所有串的下一个字符。
一个建树任务为
例如普通串 a、ab、ac、abx 经过前缀 a 时,a 在当前节点结束,ab、abx 进入 b 桶,ac 进入 c 桶。无须逐串搜索当前节点的出边,因为桶编号直接给出了对应字符。
每条串的每个字符只参与一次分桶,终点再处理一次,所以总工作量与所有串的长度和线性相关。
第三步:复用字符桶,而不是每个节点清空整个字母表。
只开一份桶,记录当前节点实际使用的桶编号。先取走所有非空桶的记录并清空它们,再处理任何孩子。这样每条边只付出一次取桶、清桶的成本。
不能每个节点或每个核心组都扫完整个字母表,也不能在父节点还有未取走的桶时开始处理孩子。main4.cpp 用显式任务栈安排处理顺序,桶中的集合用记录编号链表保存,移动一组记录只需保存链表头。
核心字母表大小为
第四步:每个核心组用同样方式建立左右 Trie。
规则和询问的不变前缀反转后建左 Trie,不变后缀正向建右 Trie,直接记录各自的终点。左终点挂载记录的链表使用独立字段,避免右树建树时改坏它。
随后完全复用解法一的计数:左 DFS 进入时先激活所有规则,再回答询问,右侧沿父指针求和,退出时撤销。样例中的激活过程可以直接对照解法一的表格。
这里不需要二维扫描线、DFS 序或树状数组。与解法一的区别集中在“怎样分组和建树”,没有改变匹配条件或计数对象。
正确性。
核心 Trie 的同终点关系准确表示核心相等;离线分桶构造的左右 Trie,与普通逐字符插入构造的前缀关系相同。计数沿用解法一已经证明的左激活、右祖先链求和,因此结果不变。
代码
main4.cpp 保留原串与首尾差异位置,next_char() 按建树模式直接读取核心、反转不变前缀、正向不变后缀,不复制三段子串。
build_trie() 是共用的离线分桶过程;核心分组保存记录链表头,侧边建树时可以安全复用节点池。每组只重置节点分配计数器,实际新建节点由 new_node() 初始化,避免逐组清空大数组。enter_node()、leave_node() 和 dfs_left() 对应与解法一相同的激活计数。
/**
* 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;
}复杂度
设
固定字母表下,总时间为
这里没有逐节点或逐组的
总结
按数据范围推进,本题的思考路线是:
- 小数据枚举规则与位置,把一次替换的条件写清楚。
- 首个差异必须对齐,确定每条规则唯一的替换起点,把单询问降到
; 时直接得到线性做法。 - 性质 B 将完整匹配压缩成
b的位移相等和左右空间上界,转成二维偏序计数。 - 一般数据优先学习解法一:固定差异核心,再将不变前后缀匹配转成双 Trie 祖先条件,左 DFS 激活、右祖先链求和。
- 解法二展示 AC 多模式匹配路线;解法三保留同样的核心分解,但用矩形扫描线处理两个祖先条件。
- 解法四继续优化解法一的核心分组与建树,使用离线分桶得到确定性线性时间。
正式主解采用解法一:分组、建树和计数各自清楚,代码以常见的 map、普通 Trie 和 DFS 为主。它还展示了一个值得迁移的复杂度观察:单次查询可以走长链,只要所有查询链长之和受输入总长度限制。
其他解法速览与对比
洛谷题解区对本题有大量独立解法。调研热度最高的若干篇后可以看到:多数做法可以归入差异核心的前后缀分解与多模式匹配两条主线,另有几条值得了解的替代路线。下表按路线归类,
| 路线 | 核心算法 | 时间复杂度 | 空间复杂度 |
|---|---|---|---|
| 分隔符编码多模匹配 | 把规则编成模式 ? 为分隔符),AC 自动机数出现次数 |
||
| 核心分组 + 双 Trie + 二维数点 | 同解法三的建模,分组与匹配多用字符串哈希 | ||
| 左 DFS 激活 + 右 Trie 树状数组 | 同解法一、四的 DFS 计数框架,右侧用 DFS 序区间加、单点查 | ||
| fail 树主席树(在线) | AC 自动机匹配 |
||
| 字典序栈扫描 | 组内按反转前缀排序,栈维护前缀合法集合,Trie 维护后缀计数 | ||
| 单 Trie + 路径哈希二分 | 核心交错编码进一棵 Trie,路径哈希记根路径规则数,查询二分最长存在前缀 | ||
| 长度种类数枚举 | 不同规则长度至多 |
最坏 |
|
| 后缀数组 + 分块 | 字符对字母表上把子串相等转成排名区间,扫描线 + 三层分块 |
几点对比结论:
- 建模收敛。「差异核心相等 + 左侧后缀 + 右侧前缀」被多篇题解独立推出,对应解法一、三、四的模型;多模式匹配路线则对应解法二。四份实现展示了这些模型下不同的分组、建树与计数选择。
- 确定性是本题解的差异化优势。社区多数做法用字符串哈希做核心分组或匹配,理论上存在碰撞风险;解法一、三用
map精确比较核心,解法四用 Trie 终点分组,全程不依赖哈希。 - 分隔符编码是解法二的简化方向。它把「覆盖全部差异位置」编码进模式串本身,匹配即合法,因而不需要长度阈值离线计数;代价是把字符对字母表换成了带分隔符的单字符字母表。
- 在线与离线的取舍。需要逐询问在线回答时,主席树或单 Trie 二分更自然;解法一、三、四为离线批量,但换来更少的随机化依赖。
- 实现教训。高频调用中按值传
string足以造成超时;前缀和数组与循环分支不同步会静默丢分。各实现的计数撤销、退出事件等对称操作值得对照检查。
参考与致谢
下列题解用于思路对照与正确性交叉验证。本文的叙述、证明与代码均为独立撰写,未复制任何原文;每条列出其核心算法与复杂度声明。
- 题解:P14363 谐音替换(暂无数据) —— Register_int (uid 406941):分隔符编码多模匹配 + AC 自动机;时间
,空间 。 - P14363 谐音替换 / replace 题解 —— tcqwq (uid 532560):核心哈希分组 + 左 Trie + 右侧哈希前缀拆分与 DFS 桶;时间
,空间 。 - 论如何用六级算法爆切 [CSP-S 2025] 谐音替换 —— pumpkin_pie_ (uid 538131):单 Trie 核心交错编码 + 路径哈希 + 二分最长存在前缀;时间
,空间 。 - 谐音替换新做法! —— Moya_Rao (uid 814130):核心分组 + 字典序扫描 + 栈与 Trie;时间
,空间 。 - 题解:P14363 谐音替换(暂无数据) —— nsp (uid 1007758):核心哈希分组 + 双 Trie DFS 序矩形 + BIT 二维数点;时间
,空间 。 - P14363 谐音替换 —— zhlzt (uid 571147):左 DFS 激活 + 右 Trie 树状数组;时间
,空间 。 - 题解:P14363 谐音替换(挑战最短代码) —— kevinZ99 (uid 1117080):长度种类数至多
,枚举起点与长度后哈希校验;最坏时间 ,空间 。 - 题解:P14363 谐音替换 —— cff_0102 (uid 542457):本质分组 + 双 Trie,指出朴素标记合并的退化风险,主张 DFS 序 + BIT 离线;时间
,空间 。 - SA 爬了。 —— lzyqwq (uid 539211):后缀数组排名区间 + 长度扫描线 + 三层分块(未完成实现);理论时间
,空间 。 - 神秘猎奇跑不满做法爆踩 ACAM —— 时之虫 (uid 368204):按前缀哈希分组,枚举左端点 + Trie 哈希表二分最长后缀;时间
到 ,空间 。 - 谐音替换 时空严格线性 题解 —— its_mygo (uid 384596):离线分桶建 Trie + 左 DFS 激活 + 右侧祖先链求和,与解法四同型;时间
,空间 。 - 题解:P14363 谐音替换(暂无数据) —— dayz_break404 (uid 688596):极小差异区间 + 双 Trie 二维数点;时间
,空间 。
图示解析
下面保留的一图流对应第五层的解法二,可用于复盘字符对、覆盖条件和 fail 树计数之间的关系。

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