字符串题只有两个基本动作:比较和查找。直接比较两个长 L 的串要 L 次字符比较;一万个长 1000 的串两两比较是 5×1010 次,必然超时。这一节给两种把动作变便宜的工具:哈希把整个串压成一个整数,比较退化成一次整数比较;Trie 把公共前缀折叠成一条路径,插入和查询都只走 L 步。Trie 的一个变体把整数按二进制位建树,就能回答“和 x 异或最大的数是谁”。
| 任务 | 规模 | 工具 | 单次操作 | 主要风险 |
|---|---|---|---|---|
| 判断两个串是否相等 | 长 106,比较 105 次 | 多项式哈希 | O(1) | 哈希冲突 → 判成相等 |
| 求子串 [l, r] 的指纹 | 长 106,询问 105 次 | 前缀哈希 + 幂表 | O(1) | 同上 |
| 插入 / 查询一个串 | 总长 106 | Trie | O(len) | 数组开小 → RE |
| 和 x 异或最大的数 | n ≤ 105,值 < 231 | 01-Trie | O(31) | 位宽取错 → WA |
先把这一节会出现的 4 样东西拆开。它们都很短,但每一件都有一个“不这么写就错”的细节,这四处细节才是真正的考点。
| 组成 | 含义 / 格式要求 |
|---|---|
取值范围 | 0 ~ 264−1 = 18446744073709551615,20 位十进制。 |
自然溢出 | 无符号整数的运算按 264 取模:溢出的高位被硬件直接丢弃,不用写 %。 |
为什么快 | 取模 % 是一条除法指令,10~40 个时钟周期;乘法和丢弃高位只要 1 个。106 次哈希能差 3 倍时间。 |
换成 long long 会怎样 | 有符号整数溢出是未定义行为(UB)。编译器可以假设它不会发生,在 -O2 下把比较优化掉,同一份代码本地对、交上去错。 |
写法 | h = h * B + (ull)c;先把 char 转成无符号再加,字符值 ≥ 128 时才不会被符号扩展成负数。 |
怎么观察它 | cout 直接输出十进制;ull x = 0; --x; 得到 18446744073709551615。 |
#include <bits/stdc++.h>
using namespace std;
typedef unsigned long long ull;
int main() {
ull x = 0;
--x; // 0 - 1 回绕到 2^64 - 1
cout << x << "\n"; // 18446744073709551615
string s = "abc";
ull h = 0;
for (char c : s) h = h * 131 + (ull)c; // 等价于 (h * 131 + c) % 2^64
cout << h << "\n";
// 同一个函数对 "abc" 永远给出同一个数,这就是“指纹”
return 0;
}减法也回绕:ull a = 1, b = 2; a - b 得到 1.8×1019,不是 −1。哈希里大量用到“减法回绕”,这正是子串哈希公式能成立的原因。
把 ull 换成 long long,h * 131 一旦超过 9.2×1018 就是 UB,g++ 可能直接按“不溢出”优化,结果无法预测。
| 组成 | 含义 / 格式要求 |
|---|---|
为什么一个不够 | 模 264 且基数固定时,存在确定的构造性攻击(Thue–Morse 串)。洛谷 P3370 的“拓展阅读”里整排 Hash Killer 就是干这个的。 |
做法 | 对同一个串,用两组 (模数, 基数) 各算一个值,组成 pair 一起比较。 |
模数怎么选 | 两个互不相同的大质数:109+7 与 109+9,或 998244353 与 109+7。 |
基数怎么选 | 大于字符集(62),常用质数 131、13331。两套的基数也要不同,否则等于一个哈希算两遍。 |
比较方式 | pair 自带 operator< 和 operator==(先比 first,再比 second),可以直接 sort、unique、放 set。 |
代价 | 常数 ×2,代码多三行。字符串题里这是最便宜的保险。 |
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
const ll M1 = 1000000007, M2 = 1000000009; // 两个不同的大质数
const ll B1 = 131, B2 = 13331; // 两套基数也要不同
pair<ll, ll> H(const string& s) {
ll h1 = 0, h2 = 0;
for (unsigned char c : s) { // 转无符号,防止负字符
h1 = (h1 * B1 + c) % M1;
h2 = (h2 * B2 + c) % M2;
}
return {h1, h2};
}
int main() {
vector<pair<ll, ll>> v = {H("abc"), H("abc"), H("abd")};
sort(v.begin(), v.end());
cout << unique(v.begin(), v.end()) - v.begin() << "\n"; // 2
return 0;
}两个模数都取 109 量级,乘积约 1018;随机数据下 n = 105 个串的期望冲突数约 5×10−9。
别用 998244353 与 109+7 配同一个基数——基数相同会让两个哈希产生关联,安全性下降。
更狠的一招是运行时随机基数:B = chrono::steady_clock::now().time_since_epoch().count() | 1,出题人无法预先构造。
| 组成 | 含义 / 格式要求 |
|---|---|
ch[u][c] | 节点 u 沿字符 c 这条边走到的节点编号。0 表示这条边不存在;根固定是 0,所以节点编号从 1 开始分配。 |
tot | 已分配的节点数,也是最新节点的编号。新建节点就是 ch[u][x] = ++tot。 |
pass[u] | 有多少个串经过节点 u,也就是“以该节点对应的字符串为前缀的串数”。 |
ended[u] | 有多少个串恰好结束在 u。用来区分“是某个串的前缀”和“自己就是一个串”。 |
空间怎么估 | 节点数 = 所有串的总长度 + 1。ch 占 MAXN×26×4 字节:总长 105 约 10 MB,总长 106 约 104 MB。 |
为什么不用指针 | new 一次约 100 字节开销还慢。数组连续分配,缓存友好,清空只要 memset(ch, 0, sizeof ch),多测方便。 |
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 100005; // 节点上限 = 总字符数 + 1
int ch[MAXN][26], pass[MAXN], ended[MAXN], tot = 0;
void insert(const string& s) {
int u = 0; // 从根出发
for (char c : s) {
int x = c - 'a';
if (!ch[u][x]) ch[u][x] = ++tot; // 边不存在才新建,这是“共享前缀”的全部秘密
u = ch[u][x];
++pass[u];
}
++ended[u]; // 有一个串在这里结束
}
int node(const string& s) { // 返回路径终点;-1 表示走不通
int u = 0;
for (char c : s) {
int x = c - 'a';
if (!ch[u][x]) return -1;
u = ch[u][x];
}
return u;
}
// node(s) != -1 → 以 s 为前缀的串有 pass[node(s)] 个
// ended[node(s)] > 0 → s 本身是一个插入过的串0 号既当根又当“不存在的边”,所以判定必须是 if (!ch[u][x]),而不是 == -1(全局数组默认 0,拿 -1 判永远不成立,会把 0 当合法子节点原地打转)。
节点总数只与总字符数有关,与字符集大小无关——这正是 Trie 比“把所有前缀塞进 set”省的地方。
| 组成 | 含义 / 格式要求 |
|---|---|
节点含义 | 从根往下第 b 层存的是 x 的第 b 位(b 从 30 降到 0)。一条根到叶的路径就是一个 31 位二进制数。 |
ch[u][2] | 只有 0、1 两条边。节点数上限 = 数值个数 × 31。 |
query 的贪心 | 从高位到低位,尽量走与当前位相反的边;走不通才走相同的边。 |
为什么贪心对 | 第 b 位是 1 贡献 2b,而低 b 位全取 1 也只有 2b−1。高位赢一次就赢过所有低位,所以从高到低逐位定。 |
位宽 | 值 < 231 时从第 30 位开始;值 < 109 < 230 时从第 29 位开始也够。多枚举一位不会错,只多一层。 |
const int MAXN = 100005;
int ch[MAXN * 31][2], tot = 0;
void insert(int x) {
int u = 0;
for (int b = 30; b >= 0; --b) {
int c = (x >> b) & 1;
if (!ch[u][c]) ch[u][c] = ++tot;
u = ch[u][c];
}
}
int query(int x) { // 与 x 异或的最大值
int u = 0, r = 0;
for (int b = 30; b >= 0; --b) {
int c = (x >> b) & 1;
if (ch[u][c ^ 1]) { // 反边存在:这一位异或出 1
r |= 1 << b;
u = ch[u][c ^ 1];
} else { // 只能将就,这一位异或出 0
u = ch[u][c];
}
}
return r;
}插入和查询都是 31 步固定开销,与数值大小无关。
ch 占 3.1×106×2×4 ≈ 25 MB,n = 105 时刚好;n 再大就要算清楚内存。
给定 N 个字符串(第 i 个长度为 Mi),字符串内只含数字与大小写字母,大小写敏感。请求出这 N 个字符串中共有多少个不同的字符串。
输入:第一行一个整数 N;接下来 N 行,每行一个字符串。
输出:一行一个整数,表示不同字符串的个数。
数据范围:N ≤ 10000,Mmax ≤ 1500(总字符数可达 1.5×107)。
朴素做法:读进 N 个串,两两比较是否相同。N = 104 时约 5×107 对,每对最多比 1500 个字符,最坏 7×1010 次字符比较,TLE。
换目标:题目只问“有几个不同的串”,不问“谁和谁相同”。于是可以给每个串算一个整数指纹,把“字符串去重”降级成“整数去重”。
指纹怎么算:把串看成一个 B 进制数。h = 0; for (c : s) h = h * B + c;。长度 3 的 abc 展开是 a·B2 + b·B + c,与按位权展开一致。只要 B 大于字符集(62),不同串在不取模时必然得到不同的整数——这就是“指纹”能表示串的原因。
取模是为了把整数压进 64 位。取模会丢信息,于是不同串可能落到同一个余数上,这叫哈希冲突。模 264、随机数据、N = 104 时,期望冲突对数约 N2/(2·264) ≈ 3×10−12,可以忽略。
去重:N 个整数排序,再 unique 一遍。unique 把不重复的元素挪到前面并返回新的逻辑结尾,减去 begin 就是不同元素个数。总复杂度 O(N log N),与串长无关。
读入 n
for 每个串 s:
h = 0
for c in s: h = h * 131 + c # 溢出即 mod 2^64
把 h 存进数组
排序、unique
输出 unique 之后的元素个数两处细节:h 必须无符号(有符号溢出是 UB);unique 之前必须排序(它只消除相邻重复)。
#include <bits/stdc++.h>
using namespace std;
typedef unsigned long long ull;
const ull B = 131; // 基数:大于字符集(62),且是质数
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
cin >> n;
vector<ull> h;
h.reserve(n);
string s;
for (int i = 0; i < n; ++i) {
cin >> s;
ull x = 0;
// 每个串独立从 0 开始累;乘 131 后的溢出位被丢弃,正是 mod 2^64
for (char c : s) x = x * B + (ull)c;
h.push_back(x);
}
sort(h.begin(), h.end()); // 整数排序,比字符串比较便宜得多
// unique 把不重复的元素挪到前面,返回新结尾;减去 begin 即个数
cout << (int)(unique(h.begin(), h.end()) - h.begin()) << "\n";
return 0;
}| 代码片段 | 含义与理由 |
|---|---|
typedef unsigned long long ull; | 64 位无符号。哈希的取模靠它自然溢出,一次 % 都不写。 |
const ull B = 131; | 基数必须大于字符集大小。取 B = 1 时 ab 与 ba 哈希都是 a+b,取 B = 2 时重排也会撞。质数让各次幂分布更散。 |
ios::sync_with_stdio(false); | 总输入 1.5×107 个字符,读入本身是瓶颈之一,关同步省一半时间。 |
ull x = 0; | 写在读串的循环内,保证每个串的累加器是干净的。写在循环外会让所有串首尾相连。 |
for (char c : s) x = x * B + (ull)c; | 核心一行:先乘再加,等价于把串看成 B 进制数。(ull) 转换保证字符值 ≥ 128 时不做符号扩展。 |
x * B 溢出为什么不算错 | 无符号溢出是“按 264 取模”,C++ 标准明确规定;换 long long 就是 UB。同一个式子,类型决定它是算法还是未定义行为。 |
h.push_back(x); | N 个指纹存进 vector,长度只有 N,与串长无关。 |
sort(h.begin(), h.end()); | O(N log N),比较的是 8 字节整数。若直接 sort 字符串,比较代价是 O(串长)。 |
unique(h.begin(), h.end()) - h.begin() | unique 返回去重后区间的新结尾。前提是已经有序,否则只消除相邻重复,答案偏大。 |
(int)(...) | 迭代器相减得到 ptrdiff_t,转 int 后输出,避免某些环境下按长整型打印。 |
哈希真正的威力不在“整串比较”,而在任意子串比较。要做到这一点,先算前缀哈希,再用一次减法把任意一段“抠”出来。
设 h[i] 是前 i 个字符的哈希,则 h[r] = h[l−1]·Br−l+1 + H(l..r):前 r 个字符 = 前 l−1 个字符整体左移 r−l+1 位,再接上第 l..r 个字符。移项得到子串哈希公式。
#include <bits/stdc++.h>
using namespace std;
typedef unsigned long long ull;
const ull B = 131;
const int MAXN = 1000005;
char s[MAXN];
ull h[MAXN], pw[MAXN]; // h:前缀哈希;pw:B 的幂
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> (s + 1); // 1 基下标,s[1] 是第一个字符
int n = strlen(s + 1);
pw[0] = 1;
for (int i = 1; i <= n; ++i) {
h[i] = h[i - 1] * B + (ull)s[i]; // 前 i 个字符的指纹
pw[i] = pw[i - 1] * B; // B^i
}
// 子串 [l, r](1 ≤ l ≤ r ≤ n)的指纹
auto sub = [&](int l, int r) -> ull {
return h[r] - h[l - 1] * pw[r - l + 1];
};
int q;
cin >> q;
while (q--) {
int l1, r1, l2, r2;
cin >> l1 >> r1 >> l2 >> r2;
cout << (sub(l1, r1) == sub(l2, r2) ? "Yes\n" : "No\n");
}
return 0;
}| 代码片段 | 含义与理由 |
|---|---|
cin >> (s + 1); | 把字符串读到 s[1] 起始的位置,这样下标 1 就是第一个字符。括号不能省:cin >> s + 1 会被解析成 (cin >> s) + 1。 |
h[i] = h[i - 1] * B + (ull)s[i]; | 递推:把前 i−1 个字符整体“左移一位”,再在末位接上 s[i]。 |
pw[i] = pw[i - 1] * B; | 幂表。子串公式里要乘 Br−l+1,现算会多一次 O(log n),查表是 O(1)。 |
h[r] | 前 r 个字符的指纹,等于 H(1..l−1) 左移 r−l+1 位后加上 H(l..r)。 |
h[l - 1] * pw[r - l + 1] | 把前缀 1..l−1 移到与 h[r] 相同的位权上,才能相减。少了这一项,减出来的是“两个不同长度的前缀”之差,毫无意义。 |
h[r] - h[l - 1] * pw[...] | 减法在 ull 里回绕,正好等价于模 264 的减法,不需要 +M 再取模。 |
auto sub = [&](int l, int r) -> ull | lambda 捕获外部数组的引用,调用处像函数一样短。C++17 支持显式返回类型写法。 |
这个公式是后面所有子串题的地基:找模式串出现位置(逐个起点比指纹)、最长公共前缀(二分长度 + 判等,O(log n))、回文判断(正串和反串各一套哈希)。代价都是“预处理 O(n) + 每次询问 O(1) 或 O(log n)”。
哈希能判相等,却回答不了“有多少个串以某个前缀开头”——那是集合问题,不是相等问题。Trie 把公共前缀压成一条公共路径,走到某个节点,子树里有多少个词就是这个前缀的出现次数。
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 100005; // 节点上限 = 所有串总长 + 1
int ch[MAXN][26], pass[MAXN], ended[MAXN], tot = 0;
void insert(const string& s) {
int u = 0; // 根
for (char c : s) {
int x = c - 'a';
if (!ch[u][x]) ch[u][x] = ++tot; // 没有这条边才新建:前缀只存一份
u = ch[u][x];
++pass[u]; // 经过 u 的串多了一个
}
++ended[u]; // 有一个串在这里结束
}
int node(const string& s) { // 返回路径终点;-1 表示路断了
int u = 0;
for (char c : s) {
int x = c - 'a';
if (!ch[u][x]) return -1;
u = ch[u][x];
}
return u;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n, q;
cin >> n >> q;
string s;
for (int i = 0; i < n; ++i) { cin >> s; insert(s); }
while (q--) {
cin >> s;
int u = node(s);
if (u == -1) { cout << "0 0\n"; continue; }
cout << pass[u] << " " << (ended[u] > 0 ? 1 : 0) << "\n";
// 第一个数:以 s 为前缀的串数;第二个数:s 自己是不是一个插入过的串
}
return 0;
}| 代码片段 | 含义与理由 |
|---|---|
if (!ch[u][x]) ch[u][x] = ++tot; | 共享前缀的全部秘密。边已存在就直接复用,不新建节点——所以 abc 和 abd 只多花 1 个节点,不是 3 个。 |
u = ch[u][x]; | 沿着边走一步。循环结束时 u 就是整个串对应的节点。 |
++pass[u]; | 放在“走完这一步”之后,统计的是“经过该节点的串数”。若放在走之前,统计的就变成了父节点的信息。 |
++ended[u]; | 区分“是前缀”和“是单词”。ab 是 abc 的前缀,但 ab 自己可能没被插入——这时 ended[ab] = 0。 |
if (u == -1) | 走不通说明这条路径从没被插入过,前缀数一定是 0。提前返回省掉后面的遍历。 |
复杂度 | 单次插入/查询 O(len),与集合里有多少串无关。这是 Trie 相对“线性扫描所有串”的本质优势。 |
把 Trie 的边从 26 条换成 2 条、从字符换成二进制位,就得到 01-Trie。它解决一类问题:给定一堆数,问“与 x 异或最大的数是谁”。
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 100005;
int ch[MAXN * 31][2], tot = 0; // 每个数最多占 31 个节点
void insert(int x) {
int u = 0;
for (int b = 30; b >= 0; --b) { // 从最高位开始,高位决定大小
int c = (x >> b) & 1;
if (!ch[u][c]) ch[u][c] = ++tot;
u = ch[u][c];
}
}
int query(int x) { // 返回与 x 异或能得到的最大值
int u = 0, r = 0;
for (int b = 30; b >= 0; --b) {
int c = (x >> b) & 1;
if (ch[u][c ^ 1]) { // 反边存在 → 这一位异或出 1,必选
r |= 1 << b;
u = ch[u][c ^ 1];
} else {
u = ch[u][c]; // 没得选,这一位异或出 0
}
}
return r;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
cin >> n;
int ans = 0;
for (int i = 1; i <= n; ++i) {
int x;
cin >> x;
if (i > 1) ans = max(ans, query(x)); // 先问已有的数,答案一定来自两个不同的数
insert(x);
}
cout << ans << "\n";
return 0;
}| 代码片段 | 含义与理由 |
|---|---|
for (int b = 30; b >= 0; --b) | 从最高位往下。值 < 231,第 30 位是最高可能出现的位。 |
if (ch[u][c ^ 1]) | c 是 x 当前位,c^1 是相反的位。走反边意味着这一位异或结果是 1。 |
r |= 1 << b; | 这一位确定为 1,把 2b 记进答案。b ≤ 30,1 << b 不会溢出 int。 |
为什么高位优先 | 第 b 位贡献 2b,低 b 位全取 1 也只有 2b−1 < 2b。高位多赢一位就大于所有低位之和,所以逐位贪心与全局最优一致。 |
先 query 再 insert | 若先插后问,可能问到自己(x xor x = 0);虽然不影响最大值,但“重复元素”的题会算错计数。顺序写对一次,以后不用想。 |
ans 用 int 就够 | 两个 < 231 的数异或仍 < 231,不会超 int。若把值域改到 1018,位宽要改成 62,r 必须换 long long。 |
RE / 段错误。—— Trie 的 MAXN 按“串的个数”开成 104。节点数等于总字符数,1 万个长 1500 的串要 1.5×107 个节点,insert 时写 ch[u][x] 越界。
WA,形如 ab 和 ba 被判成同一个串。—— 基数取了 B = 1 或 2,哈希退化成字符求和,任何重排都同值。B 必须大于字符集大小。
WA,本地全对、-O2 下答案诡异。—— 用 long long 做自然溢出。有符号溢出是 UB,编译器可以按“不会溢出”优化。必须 unsigned long long。
TLE。—— 用 set<string> 或 map<string,int> 存 105 个长 103 的串:每次比较 O(L),总代价 O(N·L·log N)。哈希后排序整数是 O(N log N)。
WA,只有个别测试点错。—— 单哈希被卡。长串 + 固定基数 + 模 264 有确定的 Thue–Morse 反例,换双哈希或随机基数。
RE / 死循环。—— 用 -1 表示“边不存在”,但全局数组默认 0。判定写成 if (ch[u][x] == -1) 永远不成立,于是把 0 当成合法子节点原地打转。约定:0 表示不存在,编号从 1 开始。
WA,01-Trie 答案偏小。—— 位宽只写到第 20 位而数据里有 106 以上的数;或者值域到 231 还写 1 << 31(有符号移位溢出),应写 1LL << b。
关掉页面,把 P3370 从空文件敲一遍并 AC。
改一改 ①:把 B 改成 1,输入 5 / ab / ba / abc / cba / aabb,不同字符串数会从 5 变成 3。想清楚哪几对撞了。
改一改 ②:把 ull 全换成 long long,加 -O2 编译,输入 105 个随机串,对比两种写法的答案是否一致。
改一改 ③:把 sort + unique 换成 set<ull>,用 time 命令比较 105 个串的耗时差。
改一改 ④:在 01-Trie 里把位宽从 30 改成 20,输入 106 以内的数,看答案错在哪一位。
改一改 ⑤:给子串哈希那题加一组询问,让 l1、r1、l2、r2 长度不同,验证“长度不同必然不等”是否由公式自动保证。
多项式哈希把长 L 的串映射到 H(s) = Σ si·BL−i mod M。不取模时这是 B 进制表示,B 大于字符集时不同串必得不同整数;取模把 M 以外的信息丢掉了,不同的串就可能落到同一个余数上,这就是冲突。
生日问题:把 n 个串丢进 M 个桶,期望冲突对数 ≈ n(n−1)/(2M)。要让冲突概率低于 10−6,需要 M ≳ 5×1011·n2。
| 模数 | 大小 | n = 10⁵ 时的期望冲突数 | 能否被构造攻击 |
|---|---|---|---|
| 232 | 4.3×109 | ≈ 1.2 | 能 |
| 264 | 1.8×1019 | ≈ 2.7×10−10 | 能(Thue–Morse) |
| 109+7 单哈希 | 109 | ≈ 5 | 能 |
| 双哈希 109+7 × 109+9 | 1018 | ≈ 5×10−9 | 极难 |
为什么模 264 特别脆:264 不是质数,奇数基数 B 的高次幂在模 264 下很快退化,于是可以用“差分”构造出等长且哈希相同的两个串。已知的构造按 Thue–Morse 串取位(第 i 位由 popcount(i) 的奇偶决定),在模 264、基数为奇数时两个串哈希完全相同。这就是 anti-hash 数据的来源——它不是运气,是构造。
双哈希为什么够:两套哈希同时撞上的概率近似为两个概率之积。两个 109 量级的模数给到 1018 的组合空间,且出题人要同时对两个不同模数构造反例,难度远超对 264。代价只是常数 ×2。
子串哈希的恒等式:h[r] = h[l−1]·Br−l+1 + H(l..r),移项即 H(l..r) = h[r] − h[l−1]·Br−l+1。这一步把任意子串的指纹变成两次查表加一次乘法,是所有“子串比较”类题目的地基。
基数怎么选:B 要大于字符集(否则不同字符会进位混叠),且最好与模数互质(取质数最省事)。131、13331 是习惯用法;更稳的是运行时随机取一个 108 附近的质数,让反例无法预先构造。
ch[u][c] 还能这么开吗?替代方案是什么?化学竞赛组有 n 名学生,教练手里有名单。教练会依次报出 m 个名字,你要对每个名字给出判断:
若这个名字在名单里且是第一次被点到,输出 OK;若在名单里但之前已经被点过,输出 REPEAT;若不在名单里,输出 WRONG。
输入:第一行一个整数 n;接下来 n 行每行一个名字(互不相同,只含小写字母,长度 ≤ 50);第 n+2 行一个整数 m;接下来 m 行每行一个教练报的名字(只含小写字母,长度 ≤ 50)。数据范围 n ≤ 104,m ≤ 105。
输出:对教练报的每个名字输出一行,为 OK、REPEAT 或 WRONG。
名字总数 104、长度 ≤ 50,总字符数不超过 5×105。这个数字提示你:可以按字符建结构,不必按名字建。
查询要区分三件事:这条路径存在吗?路径的终点真的有名字结束吗?这个名字之前被点过吗?三件事需要三种不同的信息。
“前缀”和“单词”要分开:名单里只有 ab,教练报 abc,这条路径走不通;名单里只有 abc,教练报 ab,路径走得通但 ab 不结束。
把名单建成 Trie:每个名字从根出发逐字符走,没有边就新建节点。走完整个名字时,把该节点的“结束计数”加一。
查询一个名字也是逐字符走:中途某条边不存在,说明名单里没有以它为前缀的名字,直接 WRONG。
走完整条路径后,还要看终点节点的结束计数是否为 0。为 0 说明它只是某个更长名字的前缀,自己不是名单里的名字——也是 WRONG。
为了区分 OK 和 REPEAT,再给每个节点一个 vis 标记:第一次走到这里输出 OK 并置 true,之后再走到就输出 REPEAT。
为什么用 Trie 而不是 set:set 查一次是 O(len·log n) 次字符串比较,Trie 是 O(len) 次下标访问,而且 m 到 105,差距会放大。
复杂度:插入 O(总字符数) ≤ 5×105,查询 O(m×50) ≤ 5×106,空间 O(总字符数)。
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 500005; // 节点上限 = n × 50
int ch[MAXN][26], ended[MAXN], tot = 0;
bool vis[MAXN]; // 这个节点被点过名没有
void insert(const string& s) {
int u = 0;
for (char c : s) {
int x = c - 'a';
if (!ch[u][x]) ch[u][x] = ++tot; // 前缀只有一份
u = ch[u][x];
}
++ended[u]; // 这个名字真的在名单里
}
int node(const string& s) { // 返回终点编号;-1 表示路径不存在
int u = 0;
for (char c : s) {
int x = c - 'a';
if (!ch[u][x]) return -1;
u = ch[u][x];
}
return u;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
cin >> n;
string s;
for (int i = 0; i < n; ++i) { cin >> s; insert(s); }
int m;
cin >> m;
for (int i = 0; i < m; ++i) {
cin >> s;
int u = node(s);
// 路走不通,或者走通了但这里没有名字结束:都不是名单里的名字
if (u == -1 || ended[u] == 0) { cout << "WRONG\n"; continue; }
if (vis[u]) { cout << "REPEAT\n"; continue; }
vis[u] = true;
cout << "OK\n";
}
return 0;
}| 片段 | 作用 |
|---|---|
const int MAXN = 500005; | n ≤ 104、每个名字长 ≤ 50,总字符数 ≤ 5×105,节点数不会超过总长 + 1。按“名字个数”开数组是这题最常见的 RE。 |
if (!ch[u][x]) ch[u][x] = ++tot; | 共享前缀。名字 abc 与 abd 只多花一个节点,不是三个。 |
++ended[u]; | ★ 结束标记。没有它,“abc 在名单里,报 ab”会被判 OK,而正确答案是 WRONG。 |
if (!ch[u][x]) return -1; | 边走边判。路径断了就没有必要走下去,也顺便区分了“不存在”和“存在但不是名字”。 |
if (u == -1 || ended[u] == 0) | 两个条件缺一不可。u == -1 是路径断了;ended[u] == 0 是路径存在但只是别人的前缀。 |
if (vis[u]) | ★ 用节点编号当访问标记,而不是再存一份字符串。同一个名字必然落在同一个节点上,所以节点编号就是名字的唯一 ID。 |
vis[u] = true; | 只在这一步置真:确保“第一次报”输出 OK,“第二次及以后”输出 REPEAT。 |
ended[u] == 0:名单里只有 abc 时报 ab,会错判成 OK。map<string,int> 记是否点过名:能过,但多了一次哈希,且意图不如“节点编号当 ID”清晰;n、m 更大时会被卡。ended[u]-- 表示“点过”:同一个名字第二次来时 ended[u] 已变成 0,会被判成 WRONG 而不是 REPEAT。