← 目录 第 51 / 60 节 全部 60 节

51 · 字符串哈希与 Trie

把字符串压成整数、把前缀压成路径:多项式哈希与 ull 自然溢出解决整串比较,前缀哈希 + 幂表解决子串比较,Trie 解决前缀计数,01-Trie 解决最大异或。附带一套防冲突的规矩:基数大于字符集、模数用双哈希、溢出必须无符号。
提高-预计 65 分钟哈希Trie冲突
本节出现的新函数 / 新语法(先看懂再用)

字符串题只有两个基本动作:比较和查找。直接比较两个长 L 的串要 L 次字符比较;一万个长 1000 的串两两比较是 5×1010 次,必然超时。这一节给两种把动作变便宜的工具:哈希把整个串压成一个整数,比较退化成一次整数比较;Trie 把公共前缀折叠成一条路径,插入和查询都只走 L 步。Trie 的一个变体把整数按二进制位建树,就能回答“和 x 异或最大的数是谁”。

哈希回答“这两个串一样吗”,Trie 回答“这个前缀出现过几次”,01-Trie 回答“和 x 异或最大的数是谁”。三个问题,一条共同的思路:把串或数变成可以随手比较、随手走几步的东西。
任务规模工具单次操作主要风险
判断两个串是否相等长 106,比较 105 次多项式哈希O(1)哈希冲突 → 判成相等
求子串 [l, r] 的指纹长 106,询问 105 次前缀哈希 + 幂表O(1)同上
插入 / 查询一个串总长 106TrieO(len)数组开小 → RE
和 x 异或最大的数n ≤ 105,值 < 23101-TrieO(31)位宽取错 → WA

先把这一节会出现的 4 样东西拆开。它们都很短,但每一件都有一个“不这么写就错”的细节,这四处细节才是真正的考点。

新函数拆解 · unsigned long long(无符号 64 位整数)
typedef unsigned long long ull; ull h = 0; for (char c : s) h = h * B + (ull)c; // 溢出即取模
组成含义 / 格式要求
取值范围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++ 可能直接按“不溢出”优化,结果无法预测。

新函数拆解 · 双哈希(两套模数各算一遍)
pair<long long, long long> H(const string& s);
组成含义 / 格式要求
为什么一个不够模 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,出题人无法预先构造。

新函数拆解 · Trie(字典树)的数组实现
int ch[MAXN][26]; int pass[MAXN], ended[MAXN], tot = 0;
组成含义 / 格式要求
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”省的地方。

新函数拆解 · 01-Trie(二进制 Trie)
void insert(int x); int query(int x); // 返回与 x 异或能取到的最大值
组成含义 / 格式要求
节点含义从根往下第 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 再大就要算清楚内存。

例题 · 【模板】字符串哈希洛谷 P3370普及-去洛谷做这题 ↗

给定 N 个字符串(第 i 个长度为 Mi),字符串内只含数字与大小写字母,大小写敏感。请求出这 N 个字符串中共有多少个不同的字符串。

输入:第一行一个整数 N;接下来 N 行,每行一个字符串。

输出:一行一个整数,表示不同字符串的个数。

数据范围:N ≤ 10000,Mmax ≤ 1500(总字符数可达 1.5×107)。

输入: 5 abc aaaa abc abcc 12345 输出: 4
例题怎么想:先把串变成整数,再去重

朴素做法:读进 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 个字符。移项得到子串哈希公式。

H(l..r) = h[r] − h[l−1] · Br−l+1。两段子串相同,这个值一定相同;值相同,则以极大概率两段相同——剩余的小概率就是“哈希冲突”。
子串哈希:O(1) 判断两段是否相同
#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) -> ulllambda 捕获外部数组的引用,调用处像函数一样短。C++17 支持显式返回类型写法。

这个公式是后面所有子串题的地基:找模式串出现位置(逐个起点比指纹)、最长公共前缀(二分长度 + 判等,O(log n))、回文判断(正串和反串各一套哈希)。代价都是“预处理 O(n) + 每次询问 O(1) 或 O(log n)”。

哈希能判相等,却回答不了“有多少个串以某个前缀开头”——那是集合问题,不是相等问题。Trie 把公共前缀压成一条公共路径,走到某个节点,子树里有多少个词就是这个前缀的出现次数。

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;
}
Trie 的三处关键
代码片段含义与理由
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 异或最大的数是谁”。

01-Trie:求最大异或对(n ≤ 10⁵,值 < 2³¹)
#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。
这一节的 7 种错法(都带症状)

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 长度不同,验证“长度不同必然不等”是否由公式自动保证。

数学补给 · 哈希为什么会有冲突:生日问题与 2^64

多项式哈希把长 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⁵ 时的期望冲突数能否被构造攻击
2324.3×109≈ 1.2能
2641.8×1019≈ 2.7×10−10能(Thue–Morse)
109+7 单哈希109≈ 5能
双哈希 109+7 × 109+91018≈ 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 附近的质数,让反例无法预先构造。

举一反三 · 想清楚这些再往下走
  1. 判断“一个串是否在另一个串中出现”,哈希和下一节的 KMP 都能做,都是 O(n+m)。什么情况下哈希会退化成 O(nm),什么情况下必须用 KMP?
  2. 如果题目要求“把 s 的某个位置改成另一个字符,然后判断两个子串是否相等”,前缀哈希要怎么改?提示:h[i] 是一个和,能不能用树状数组维护?
  3. Trie 的空间是 O(总字符数)。那它比“把所有串塞进 set”省在哪?如果字符集是 105 个汉字,ch[u][c] 还能这么开吗?替代方案是什么?
  4. 01-Trie 求“最大异或对”是 O(31n)。如果改问“异或值 ≥ k 的数对有多少个”,怎么在树上做?提示:走边时同时统计子树里的数值个数。
  5. 双哈希一定安全吗?要构造同时卡住两个模数的数据,串至少要多长?提示:两个模数乘积约 1018,而长度 L 的串有 62L 种。
  6. 把 ull 换成 uint32_t(模 232 自然溢出),n = 105 时期望冲突数约 1.2。这个数字说明什么?
  7. 为什么哈希替代不了 Trie 的“前缀计数”?前缀哈希能算出“某个前缀的指纹”,但它答不了哪个问题?
自测:能全部回答再进下一节
挑战题 · 于是他错误的点名开始了
洛谷 P2580普及/提高-去洛谷做这题 ↗

化学竞赛组有 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。
这题最容易错的地方
  • 把 MAXN 按名字个数开成 104:节点数是总字符数,直接 RE。
  • 忘了判 ended[u] == 0:名单里只有 abc 时报 ab,会错判成 OK。
  • 用 map<string,int> 记是否点过名:能过,但多了一次哈希,且意图不如“节点编号当 ID”清晰;n、m 更大时会被卡。
  • 用 ended[u]-- 表示“点过”:同一个名字第二次来时 ended[u] 已变成 0,会被判成 WRONG 而不是 REPEAT。
  • 多测不清空:本题只有一组,但换到多测题时必须把 tot、ch、ended、vis 一起清。
延伸阅读 / 参考