题单 · 习题解析

洛谷 P3370 【模板】字符串哈希

★★★ 题面自己写着「如果真的想好好练习哈希的话,请自觉」—— 而这一页把那句话量成了更狠的结论:**`set<string>` 不但能过,它还比双模数哈希快 5 倍**(顶格 4~7 ms vs 35 ms);★★★ 而同一批数据上**两把尺子指向了相反的赢家** —— 按「摸了多少个字符」哈希赢 set **17.4 倍**(10⁷ vs 1.7×10⁸),按秒表 set 赢哈希 **5.0 倍**(`memcmp` 一次比十几个字节,而哈希每个字符都要两次乘法两次取模)⇒ 本书量到的这类冲突里最极端的一次;★★★ 这一页的主课是**同一句话的三种改法**:本章正解那句 `c - 'a' + 1` 搬到这道题(字符集含数字和大写)会算出**负数** ⇒ **一次都不会错**(哈希只要「确定 + 单射」),可顺手改成 `c - '0'` 就让 `'0'` 映射成 **0** ⇒ 所有全 `0` 串哈希都是 0,**要命** ⇒ ★★ 本章「不许映射成 0」那条规矩的主语因此被钉住了:**取决于你会不会拿两段不等长的东西去比**([同轮的 P1368](/sol/p1368/) 上同一处改动是噪声);★★ 而**题面的 `M_max ≤ 1500` 正好放得下 Thue–Morse 的 1024** —— 自然溢出那个「算出来的反例」在这道题上装得进,还富余 476;★★ 单模数则要拿 collide.cpp 造的 `rnjpnw` / `vwxtxa` 打(等长 ⇒ 换字符映射照样撞);★★★ 而对拍表最值钱的是第一行:**顺手写的那一档连「什么都不做」(恒输出 N)都是满分** —— 62 个字符随机造出来的串本来就互不相同;★★ 一张表里**三种性质不同的 0 同时出现**:档位到不了那条线(三个真错法)/ 本来就没错(照抄负数)/ 答案永远对(两两比);⚠ 而「两两比」又是一次「顶格 ≠ 最坏」,且正好卡在及格线两侧(顶格随机 **132 ms**,共长前缀 **1083 ms**);★ 顺带:顶格输入 10 MB,一句关同步值 **21 倍**(88 → 4.1 ms),可最慢的一种也只吃掉时限的 8.8% ⇒ 四种读法全都够

⚠ 先自己写一遍,再往下看

这一页不是标准答案,是一条阶梯:把大多数人真实会写出的第一版摆出来, 看它错在哪、慢在哪,再一步一步改。所以它对「已经自己动手撞过一次墙」的人最有用。
还没写过的话,先去写 —— 写出来的哪怕是错的,也比直接看这一页收获大。

0题目原文(本地存了一份)

为什么要在这儿抄一份题面

原题在洛谷上(页头有链接)。别人的网站不归我们管,打不开、改版、题号调整都可能发生。 所以每个解析页都把题面转录一份存在本地,跟着仓库一起进版本库。

转录自洛谷 P3370,日期见页头。两边不一致时信原站。

题目描述

如题,给定 N 个字符串(第 i 个字符串长度为 Mᵢ,字符串内包含数字、大小写字母,大小写敏感), 请求出 N 个字符串中共有多少个不同的字符串。

友情提醒:如果真的想好好练习哈希的话,请自觉。

输入格式

第一行包含一个整数 N,为字符串的个数。

接下来 N 行每行包含一个字符串,为所提供的字符串。

输出格式

输出包含一行,包含一个整数,为不同的字符串个数。

数据范围

  • 对于 30% 的数据:N ≤ 10Mᵢ ≈ 6M_max ≤ 15
  • 对于 70% 的数据:N ≤ 1000Mᵢ ≈ 100M_max ≤ 150
  • 对于 100% 的数据:N ≤ 10000Mᵢ ≈ 1000M_max ≤ 1500

时限 1 秒,内存 131072 KB(128 MB)。

样例说明:样例里第一个字符串 abc 和第三个 abc 是一样的, 所提供字符串的集合是 {aaaa, abc, abcc, 12345},故共计 4 个不同的字符串。

输入输出样例

输入

5
abc
aaaa
abc
abcc
12345

输出

4

五个串里 abc 出现了两次 ⇒ 不同的有 4 个。

1★★★ 这道模板题的功课不是「写得快」,是「写得不会错」

★★ 题面自己把这件事说破了

题目描述末尾那一行是出题人写的:

「友情提醒:如果真的想好好练习哈希的话,请自觉。

⇒ 为什么要「自觉」?因为这道题根本拦不住 set<string> —— 把 N 个串原样扔进一个 set 就是答案,一行都不用写哈希。

★★★ 而这一页把那句话量成了一个更狠的结论:在这道题上,set<string> 不但能过, 它还比双模数哈希快 5 倍(第 ② 步那张表)。 ⇒ 所以这一页不是来教你「怎么更快」的,它来兑现第 49 章题单那句注解:

「★ 就是『N 个串里有几个不同的』—— 正好是本章第 8 步那第三笔账(两两比)的现场, 单模数在这道题上是真的会被卡。」

⇒ 全页的重点只有一件事:这是本书第一个「会给出错误答案」的算法, 而这道题正好是它最容易错的那种问法。

p3370.cpp★ 正解:双模数哈希 + 排序去重
// P3370【模板】字符串哈希 —— 正解:双模数字符串哈希,把 N 个串各变成一个「数对」再去重
//
// ★ 题目就一句话:N 个串里有几个不同的。N ≤ 10⁴,每个串约 1000 个字符(最长 1500)。
// ⇒ 输入最大约 15 MB,「不同的有几个」= 去重。
//
// ★★ 这一章的地基只有一句:**把一个串变成一个数**。
// 变完之后「两个串一不一样」就是「两个数一不一样」—— 而数是能排序、能进 set 的定长的东西。
//
// ⚠ 三处这道题特有的、和本章正文不一样的地方:
// ① 字符集是**数字 + 大小写字母**(大小写敏感),不是正文里的纯小写;
// ⇒ 本章 fast.cpp 那句 `c - 'a' + 1` 搬过来会算出负数。**那不要紧**(见 p3370Neg.cpp),
// 但顺手改成 `c - '0'` 就要命(见 p3370Zero.cpp)—— 因为 '0' 会被映射成 **0**。
// ⇒ 这里干脆用**字符本身的 ASCII 码**:62 个字符全在 48..122,一个都不是 0。
// ② 这道题问的是「N 个串**两两**比」,不是「一个模式串扫一遍」
// ⇒ 生日悖论那一笔账(正文第 8 步第三笔)就是这道题:C(10⁴,2) ≈ 5×10⁷ 对。
// 单模数 10⁹+7 的期望碰撞是 0.05 对 —— 看着安全,可**出题人可以照着模数造数据**。
// ③ 串长最长 **1500** ⇒ 正文那个「长度 1024 的 Thue–Morse 反例」**装得进这道题**。
//
// 复杂度:O(Σ|s| + N log N)。
#include <bits/stdc++.h>
using namespace std;
const long long MOD1 = 1000000007, MOD2 = 998244353;
const long long B1 = 131, B2 = 13331;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
if (!(cin >> n)) return 0;
vector<pair<long long, long long>> v;
v.reserve(n);
string s;
for (int i = 0; i < n; i++) {
cin >> s;
long long a = 0, b = 0;
for (char c : s) {
long long x = (unsigned char)c; // ⚠ 直接用 ASCII 码:48..122,没有 0
a = (a * B1 + x) % MOD1;
b = (b * B2 + x) % MOD2;
}
v.push_back(make_pair(a, b));
}
sort(v.begin(), v.end());
cout << (int)(unique(v.begin(), v.end()) - v.begin()) << '\n';
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

2★★★ 三条路,而两把尺子指向相反的赢家

p3370Count.cpp★ 数「每条路要摸多少个字符」+ 秒表
// P3370 —— 把「三条路各要摸多少个字符」和「秒表」量出来,而**两把尺子会打架**。
//
// ★ 这一页真正要回答的问题有两个,而它们的答案方向相反:
// ① 「两两比」到底差在哪儿 —— 它答案是对的,对拍永远不会说它一句话
// ([第 20 章 P5019](/sol/p5019/) 那条),只能数次数 + 掐秒表。
// ② ★★★ **哈希在这道模板题上到底快不快** —— 量完才知道:**它比 set<string> 还慢。**
//
// 三条路,按「要摸多少个字符」排:
// ① 两两比 C(N,2) **对**,每对最坏 M 个字符
// ② 排序 / set N log N **对**,每对最坏 M 个字符
// ③ 哈希 Σ|s| 个字符(**每个字符都得摸一遍**),之后每次比较都是 O(1)
//
// ⚠⚠ 而 ① ② 的「最坏」都要看**数据形状**:`a == b` 先比长度、再 memcmp,
// 第一个不同的字节就返回 —— 随机串上一对只摸 1 个字符。
// ⇒ 掐死它们的形状是「**所有串共享一个很长的前缀**」。这就是下面每张表都有两列的原因。
//
// 用法:
// ./p3370Count cmp 次数表(N = 250/500/1000,M = 200,两种形状)
// ./p3370Count time 顶格(N = 10⁴、M = 1000)的次数 + 秒表
// ./p3370Count table 两张一起
// ./p3370Count csv 只打 `键,值`,给 check:viz 用
#include <bits/stdc++.h>
using namespace std;
// 按显示宽度补空格:ASCII 算 1 格,汉字 / ★ 算 2 格
// ⚠ 含中文的列不能用 printf 的 %-Ns 对齐 —— 那个数的是字节,一个汉字占 3 字节却只显示 2 格。
static string padDisp(const string& s, int width) {
int disp = 0;
for (unsigned char c : s) {
if ((c & 0xC0) == 0x80) continue;
disp += (c < 0x80) ? 1 : 2;
}
return s + string(max(0, width - disp), ' ');
}
static mt19937 rng(20260909u);
static const char* ALPHA =
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
/** shape = 0 随机串;shape = 1 所有串共享长度 m−4 的同一个前缀 */
static vector<string> makeData(int n, int m, int shape) {
vector<string> a(n);
string pre;
if (shape == 1) { pre.resize(m - 4); for (char& c : pre) c = ALPHA[rng() % 62]; }
for (int i = 0; i < n; i++) {
string s;
if (shape == 1) { s = pre; while ((int)s.size() < m) s.push_back(ALPHA[rng() % 62]); }
else { s.resize(m); for (char& c : s) c = ALPHA[rng() % 62]; }
a[i] = s;
}
return a;
}
static long long cmps = 0;
/** 带计数的串比较:逐字符比,第一个不同就停 —— 和 std::string 的 == / < 是同一个行为 */
static int cmpCount(const string& x, const string& y) {
size_t i = 0, n = min(x.size(), y.size());
while (i < n) { cmps++; if (x[i] != y[i]) return x[i] < y[i] ? -1 : 1; i++; }
if (x.size() == y.size()) return 0;
return x.size() < y.size() ? -1 : 1;
}
static long long pairChars(const vector<string>& a) {
cmps = 0;
for (size_t i = 0; i < a.size(); i++)
for (size_t j = 0; j < i; j++) if (cmpCount(a[i], a[j]) == 0) break;
return cmps;
}
static long long sortChars(vector<string> a) {
cmps = 0;
sort(a.begin(), a.end(), [](const string& x, const string& y) { return cmpCount(x, y) < 0; });
a.erase(unique(a.begin(), a.end(),
[](const string& x, const string& y) { return cmpCount(x, y) == 0; }), a.end());
return cmps;
}
static long long hashChars(const vector<string>& a) {
long long t = 0;
for (const string& s : a) t += (long long)s.size();
return t; // 每个字符摸一次,之后全是 O(1) 的数比较
}
/** 同上,但补在左边(数字列的表头要右对齐才和 %Nlld 对得上) */
static string padLeft(const string& s, int width) {
int disp = 0;
for (unsigned char c : s) {
if ((c & 0xC0) == 0x80) continue;
disp += (c < 0x80) ? 1 : 2;
}
return string(max(0, width - disp), ' ') + s;
}
/* ---------- 三条路的「真程序」,给秒表用 ---------- */
static const long long MOD1 = 1000000007, MOD2 = 998244353, B1 = 131, B2 = 13331;
static int byHash(const vector<string>& a) {
vector<pair<long long, long long>> v;
v.reserve(a.size());
for (const string& s : a) {
long long x = 0, y = 0;
for (char c : s) { x = (x * B1 + (unsigned char)c) % MOD1; y = (y * B2 + (unsigned char)c) % MOD2; }
v.push_back(make_pair(x, y));
}
sort(v.begin(), v.end());
return (int)(unique(v.begin(), v.end()) - v.begin());
}
static int bySet(const vector<string>& a) { set<string> st(a.begin(), a.end()); return (int)st.size(); }
static int byPair(const vector<string>& a) {
int ans = 0;
for (size_t i = 0; i < a.size(); i++) {
bool seen = false;
for (size_t j = 0; j < i; j++) if (a[i] == a[j]) { seen = true; break; }
if (!seen) ans++;
}
return ans;
}
#define TIMEIT(expr, out) do { \
auto _t0 = chrono::steady_clock::now(); \
volatile int _r = (expr); (void)_r; \
(out) = chrono::duration<double, milli>(chrono::steady_clock::now() - _t0).count(); \
} while (0)
/** 跑三次取中位数 —— 亚秒级的秒表单次量会被并行的闸门晃翻([第 45 章那一跤](/sol/p1923/)) */
#define TIMEIT3(expr, out) do { \
double _v[3]; \
for (int _k = 0; _k < 3; _k++) TIMEIT(expr, _v[_k]); \
sort(_v, _v + 3); (out) = _v[1]; \
} while (0)
int main(int argc, char** argv) {
string mode = argc > 1 ? argv[1] : "table";
bool csv = (mode == "csv");
/* ---------- ① 小规模次数表:看 N² 的签名 ---------- */
const int NS[3] = { 250, 500, 1000 };
long long tab[2][3][3]; // [shape][n][path]
for (int shape = 0; shape < 2; shape++)
for (int k = 0; k < 3; k++) {
vector<string> a = makeData(NS[k], 200, shape);
tab[shape][k][0] = pairChars(a);
tab[shape][k][1] = sortChars(a);
tab[shape][k][2] = hashChars(a);
}
if (mode == "cmp" || mode == "table") {
printf("★ 每条路要摸多少个字符(M = 200,两种形状)\n\n");
for (int shape = 0; shape < 2; shape++) {
printf("%s\n", shape == 0 ? " 随机串(每对第一个字符就分开)"
: " ★ 共享 196 个字符的前缀(每对都要比到底)");
printf(" %-6s %s %s %s\n", "N", padLeft("两两比", 14).c_str(), padLeft("排序/set", 14).c_str(), padLeft("哈希", 12).c_str());
for (int k = 0; k < 3; k++)
printf(" %-6d %14lld %14lld %12lld\n", NS[k],
tab[shape][k][0], tab[shape][k][1], tab[shape][k][2]);
printf(" N 每翻一倍,「两两比」这一列 ×%.2f、×%.2f ⇐ O(N²) 的签名\n\n",
(double)tab[shape][1][0] / (double)tab[shape][0][0],
(double)tab[shape][2][0] / (double)tab[shape][1][0]);
}
}
/* ---------- ② 顶格:次数 + 秒表 ---------- */
const int BIGN = 10000, BIGM = 1000;
long long hashTouch = (long long)BIGN * BIGM;
long long sortTouchR = 0, sortTouchP = 0, pairTouchR = 0;
long long pairTouchP = (long long)BIGN * (BIGN - 1) / 2 * (BIGM - 3); // 共前缀:每对都比到第 997 位
double tHashR = 0, tSetR = 0, tPairR = 0, tHashP = 0, tSetP = 0, tPairP = 0;
{
vector<string> big = makeData(BIGN, BIGM, 0);
sortTouchR = sortChars(big);
pairTouchR = pairChars(big);
TIMEIT3(byHash(big), tHashR);
TIMEIT3(bySet(big), tSetR);
TIMEIT3(byPair(big), tPairR);
}
{
vector<string> big = makeData(BIGN, BIGM, 1);
sortTouchP = sortChars(big);
TIMEIT3(byHash(big), tHashP);
TIMEIT3(bySet(big), tSetP);
TIMEIT(byPair(big), tPairP); // ⚠ 这一格约一秒,只量一次
}
if (mode == "time" || mode == "table") {
printf("★ 顶格 N = 10⁴、每串 1000 个字符:要摸多少个字符\n\n");
printf(" %s %s %s\n", padDisp("", 22).c_str(), padLeft("随机串", 18).c_str(), padLeft("共长前缀", 18).c_str());
printf(" %s %18lld %18lld\n", padDisp("★ 哈希(双模数)", 22).c_str(), hashTouch, hashTouch);
printf(" %s %18lld %18lld\n", padDisp("★ set<string>", 22).c_str(), sortTouchR, sortTouchP);
printf(" %s %18lld %18lld\n", padDisp("✗ 两两比", 22).c_str(), pairTouchR, pairTouchP);
printf("\n★ 秒表(同一批数据,跑 3 次取中位数;共前缀的两两比只跑 1 次)\n\n");
printf(" %s %s %s\n", padDisp("", 22).c_str(), padLeft("随机串", 13).c_str(), padLeft("共长前缀", 16).c_str());
printf(" %s %10.1f ms %10.1f ms\n", padDisp("★ 哈希(双模数)", 22).c_str(), tHashR, tHashP);
printf(" %s %10.1f ms %10.1f ms\n", padDisp("★ set<string>", 22).c_str(), tSetR, tSetP);
printf(" %s %10.1f ms %10.1f ms\n", padDisp("✗ 两两比", 22).c_str(), tPairR, tPairP);
printf("\n ⚠⚠ 两把尺子打架:按「摸了多少字符」哈希赢 set %.1f 倍,"
"按秒表 set 赢哈希 %.1f 倍(共前缀那一列)\n",
(double)sortTouchP / (double)hashTouch, tHashP / tSetP);
}
if (csv) {
for (int shape = 0; shape < 2; shape++)
for (int k = 0; k < 3; k++)
printf("cmp%d_%d,%lld %lld %lld\n", shape, NS[k],
tab[shape][k][0], tab[shape][k][1], tab[shape][k][2]);
printf("grow0,%.2f %.2f\n", (double)tab[0][1][0] / (double)tab[0][0][0],
(double)tab[0][2][0] / (double)tab[0][1][0]);
printf("grow1,%.2f %.2f\n", (double)tab[1][1][0] / (double)tab[1][0][0],
(double)tab[1][2][0] / (double)tab[1][1][0]);
printf("hashTouch,%lld\nsortTouchR,%lld\nsortTouchP,%lld\npairTouchR,%lld\npairTouchP,%lld\n",
hashTouch, sortTouchR, sortTouchP, pairTouchR, pairTouchP);
printf("tHashR,%.1f\ntSetR,%.1f\ntPairR,%.1f\n", tHashR, tSetR, tPairR);
printf("tHashP,%.1f\ntSetP,%.1f\ntPairP,%.1f\n", tHashP, tSetP, tPairP);
printf("ratioTouch,%.1f\nratioTime,%.1f\n",
(double)sortTouchP / (double)hashTouch, tHashP / tSetP);
}
return 0;
}
点一下即可编辑
输出
点「运行 ▶」看结果
★★ 先把三条路的账算清楚
要摸多少个字符 顶格(N = 10⁴、每串 1000 字符)
✗ 两两比(第一版都这么写) C(N,2) × 每对最坏 M 5×10⁷ 对
★ 排序 / set<string> N log N × 每对最坏 M 约 1.4×10⁵ 对
★ 哈希 所有串的总长度个字符(每个字符摸一遍),之后每次比较 O(1) 10⁷ 个字符

⚠ 而「每对最坏 M」要看数据形状 —— a == b 先比长度、再 memcmp, 第一个不同的字节就返回:随机串上一对只摸 1 个字符。 ⇒ 所以下面每张表都有两列:随机串所有串共享一个很长的前缀

★★★ 两把尺子,两个相反的赢家(本机实测,2026-09-09)

顶格 N = 10⁴、每串 1000 个字符:

摸了多少个字符(随机 / 共长前缀) 秒表(随机 / 共长前缀)
★ 哈希(双模数) 10 000 000 / 10 000 000 35 ms / 36 ms
set<string> 287 196 / 173 819 746 4 ms / 7 ms
✗ 两两比 50 815 963 / 49 845 015 000 132 ms / 1083 ms

⇒ ★★★ 共长前缀那一列: 按「摸了多少字符」哈希赢 set 17.4 倍,按秒表 set 赢哈希 5.0 倍。 两把尺子指向了相反的赢家 —— 这是本书量到的这类冲突里最极端的一次 (第 16 章 P1074 是「次数一样、秒表差 9.5 倍」, 第 29 章 P2853 是「次数差 100 倍、秒表只差 14 倍」, 它们至少还是同一个赢家)。

★ 原因说得清:memcmp 一次比十几个字节(SIMD),而哈希那 10⁷ 个字符每一个 都要两次乘法 + 两次取模。⇒ 「摸的字符少」和「跑得快」从来不是同一件事。

★ 那「两两比」到底什么时候才死 —— 又一次「顶格 ≠ 最坏」

先看它是不是 O(N²)(M = 200,看倍数就够):

N ✗ 两两比(随机) ✗ 两两比(共长前缀)
250 31 611 6 132 141
500 126 712 24 577 813
1000 507 697 98 409 584
N 每翻一倍 ×4.01 / ×4.01 ×4.01 / ×4.00

⇒ 两列都是干净的 O(N²) 签名,可绝对值差 194 倍

⚠⚠ 于是顶格上:随机串 132 毫秒(时限 1 秒,它过了), 共长前缀 1083 毫秒(它挂了) —— 而两者都叫「N = 10⁴、每串 1000 字符」。 ⇒ 「顶格 ≠ 最坏」又一次,而这一次它正好卡在及格线两侧

p3370Set.cpp★ 另一种正确写法:set<string>(对拍的参照物)
p3370Pair.cpp✗ 两两比:答案永远对,只是跑不完

3★★★ 同一句话的三种改法:一种是正解,一种是噪声,一种是命门

⚠ 本章正解那句 `c - 'a' + 1` 搬过来要改,而改法不止一种

第 49 章正文fast.cpp 里,字符是这么映射的:

   long long c = t[i] - 'a' + 1;      // ⚠ +1:不许有字符映射成 0

而这道题的字符集是「数字 + 大小写字母」(09AZaz,共 62 个,ASCII 48~122)。 于是同一句话有三种写法:

写法 数字 '0' 映射成 结果
(unsigned char)c(正解) 48
c - 'a' + 1(照抄本章) −48 一次都不会错
c - '0'(顺手改成以 '0' 起点) 0 要命

★★ 「照抄」那一版为什么没事,两行就说完: 哈希要的只有「同一个串必给同一个数」(这个函数是确定的) +「字符到整数是单射」(加不加常数、正不正都不影响)。 ⇒ 负数只是让哈希值落在 (−MOD, MOD) 里,值域反而大了一倍。

★★★ 而「有字符被映射成 0」是真正的命门:

   h("0")   = 0
   h("00")  = 0 × b + 0 = 0
   h("000") = 0

所有由 '0' 组成的串哈希全是 0,长度不同也一样。 ★ 触发条件精确:输入里有两个长度不同的全 '0'。 ⚠ 而随机造数据一辈子造不出来(62 个字符里连抽 k 个全是 '0') ⇒ 对拍的顺手档上它是结构性的精确的 0

p3370Neg.cpp★ 照抄本章(算出负数)—— 一次都不会错
p3370Zero.cpp✗ 错法:'0' 被映射成 0
★★ 这条规矩的主语,被本轮的两道题一起钉住了

同题单的 P1368 上,同一处改动(值不 +1)一次都不会错 —— 那道题的比较永远等长,而「映射成 0」毁掉的是不等长的比较。

⇒ ★★★ 所以本章那条「不许映射成 0」的规矩,主语不是「哈希」,是: 你会不会拿两段不等长的东西去比。 这道题比的正是长度不同的整串(0 / 00 / 000)⇒ 命门; P1368 永远等长 ⇒ 噪声。

4✗ 单模数:这道题正是「第三笔账」的现场

★★ 同一个模数,问法一换,安全边际差一万倍

第 49 章第 8 步算过三笔账,这道题落在最危险的那一笔上:

问法 撞的期望次数
一个模式串扫一遍(n 次比较) n / M
N 个串两两比(去重、进 map、判有没有重复) C(N,2) / M

这道题 N = 10⁴C(N,2) ≈ 5×10⁷ 对,单模数 10⁹+7 的期望碰撞是 0.05 对 —— 二十次里才碰上一次。 ⇒ ★★ 所以「随机对拍抓不到单模数」是结构性的,加轮数没有用。

★★★ 真正打死它的办法是照着模数造一对:本章 collide.cpp 造到第 36819 个串时 撞出了 rnjpnwvwxtxa(base 131、模 10⁹+7 下都是 576565069)。 ⚠ 而那一对在这道题上原样可用:两个串等长,换一种字符映射只会给两边加上同一个数 ⇒ 差值不变,照样撞。

⇒ 于是生成器的档 2 就是「把这一对塞进输入」,那一列当场 300 / 300

p3370Single.cpp✗ 错法:只用一个模数

5★★★ 自然溢出:题面的 M_max = 1500,正好放得下那个反例

★★ 「2⁶⁴ 那么大,还取什么模」——它有一组算出来的反例

第 49 章thue.cpp 证过:取 Thue–Morse 序列的前 2^k 位当串 A、 逐位取反得串 B,则

   h(A) − h(B) = ± ∏(j = 0..k−1) (1 − b^(2^j))

base 是奇数时,右边 2 的因子有 1 + Σ(j+2) 个 —— k = 10 时正好攒到 64 个。 ⇒ 长度 1024 的那一对,模 2⁶⁴ 必然相等,和 base 取多少无关(实测:512 位不撞,1024 位五个 base 全撞)。

★★★ 而这道题的题面把门开着:M_max ≤ 1500 —— 1024 装得下,还富余 476。 ⚠ 要是题面写的是「串长 ≤ 1000」,这个反例就进不了这道题。 ⇒ ★★ 读数据范围的时候顺手问一句:已知的那些反例,装不装得进这个上限。

p3370Nat.cpp✗ 错法:自然溢出 unsigned long long

6⚠ 读入那笔账:10 MB,而时限只有 1 秒

p3370Read.cpp★ 四种读法各要多少毫秒(从文件读,评测机就是这么干的)
// P3370 —— 四种读法读同一份顶格输入,各自报「读进来花了多少毫秒」。
// ./p3370Read sync <文件> 默认的 cin >> string(什么都不关)
// ./p3370Read nosync <文件> ios::sync_with_stdio(false)
// ./p3370Read scanf <文件> scanf("%s")
// ./p3370Read fread <文件> 一次 fread 吞进来,自己按空白切
//
// ★ 为什么这道题非量一遍不可:题面的 100% 档是 `N ≤ 10⁴`、`M_i ≈ 1000`、`M_max ≤ 1500`
// ⇒ 输入是 **10 MB 量级**,而时限只有 1 秒。
// [第 6 章 P2367](/sol/p2367/) / [第 38 章 P1972](/sol/p1972/) 立过的规矩:
// **四种读法的倍数跨题几乎不变,变的是绝对时间,而分数线在绝对时间上。**
//
// ⚠⚠ 第二个参数不是可有可无的([第 47 章 P1308](/sol/p1308/) 那一跤):
// 给了文件就 `freopen` 到 stdin 上 —— **评测机做的正是这件事**。
// 不给文件读的是**管道**,那时喂数据的那一头会被一起量进来。
//
// ⚠ 输出里带上「读到了几个串、总共多少字节」:四种读法必须给出同一个答案,
// 否则量到的是「读丢了一截」而不是「读得快」([第 12 章 P1923](/sol/p1923/) 那个桶)。
#include <bits/stdc++.h>
using namespace std;
int main(int argc, char** argv) {
string mode = argc > 1 ? argv[1] : "sync";
if (argc > 2 && !freopen(argv[2], "r", stdin)) { fprintf(stderr, "open failed\n"); return 1; }
long long cnt = 0, bytes = 0;
auto t0 = chrono::steady_clock::now();
if (mode == "fread") {
vector<char> buf;
{
const size_t CH = 1 << 20;
size_t used = 0, got;
do { buf.resize(used + CH); got = fread(buf.data() + used, 1, CH, stdin); used += got; } while (got == CH);
buf.resize(used);
}
size_t i = 0, n = buf.size();
while (i < n && !isspace((unsigned char)buf[i])) i++; // 跳过第一行那个 N
while (i < n) {
while (i < n && isspace((unsigned char)buf[i])) i++;
size_t j = i;
while (j < n && !isspace((unsigned char)buf[j])) j++;
if (j > i) { cnt++; bytes += (long long)(j - i); }
i = j;
}
} else if (mode == "scanf") {
static char tmp[4096];
int n = 0;
if (scanf("%d", &n) != 1) n = 0;
while (scanf("%4000s", tmp) == 1) { cnt++; bytes += (long long)strlen(tmp); }
} else {
if (mode == "nosync") { ios::sync_with_stdio(false); cin.tie(nullptr); }
int n = 0;
cin >> n;
string s;
while (cin >> s) { cnt++; bytes += (long long)s.size(); }
}
double ms = chrono::duration<double, milli>(chrono::steady_clock::now() - t0).count();
printf("%s,%.1f,%lld,%lld\n", mode.c_str(), ms, cnt, bytes);
return 0;
}
点一下即可编辑
输出
点「运行 ▶」看结果
★ 顶格 10 MB —— 而这一次「关同步」值 21 倍

顶格是 N = 10⁴Mᵢ ≈ 1000 ⇒ 输入 10 MB 量级M_max = 1500 时能到 15 MB)。 四种读法各跑 3 次取中位数(从文件读,A 机 · WSL2 · 2026-09-09 · 独占):

读法 毫秒
默认 cin >> string 88
ios::sync_with_stdio(false) 4.1
scanf("%s") 14
一次 fread 自己切 16

一句关同步值 21 倍(本书量过的常见倍数是 6~8 倍,这里更大是因为 cin >> string 一个字符一个字符地走 sync 那条慢路)。

⚠ 但结论要按第 6 章 P2367 那条规矩说: 倍数跨题几乎不变,变的是绝对时间,而分数线在绝对时间上 —— 这道题最慢的一种也只吃掉时限的 8.8%,所以四种读法全都够。 ⇒ 这一节真正的用处是:它让你知道「88 毫秒」是从哪儿来的, 而不是等到某道 2×10⁷ 个数的题上再来猜。

7★ 对拍:顺手写的那一档连「什么都不做」都打不假

p3370Gen.cpp★ 生成器:三个反例各占一档
// P3370 数据生成器(对拍用)。用法:./p3370Gen <seed> [档位],不给档位就是**最终档 5**。
//
// ★ 老规矩:先写清楚「每个错法靠什么才现形」,再决定拧哪个旋钮 —— 这一页四个错法要的东西完全不同:
//
// ①单模数 ← ★★ 随机数据**结构性**抓不到(C(N,2)/10⁹ 的期望,N 小的时候是 10⁻⁶ 量级)。
// 只能埋 collide.cpp 造出来的那一对:"rnjpnw" / "vwxtxa"。
// ②自然溢出 ← ★★ 同样只能埋反例:Thue–Morse 长度 **1024** 的 A / B。
// ⚠ 而这道题的 M_max = 1500,**装得下**。
// ③映射到 0 ← 要**两个长度不同的全 '0' 串**("0" / "00" / "000")。随机造不出来。
// ④两两比 ← 答案永远对,对拍看不见 ⇒ 这一页靠 p3370Count.cpp 数次数。
//
// ★ 外加一份试金石 p3370All(恒输出 N):顺手写的随机串本来就互不相同
// ⇒ 连「什么都不做」都是对的。**先让这一档打假它,再谈别的。**
//
// 档位:
// 0 顺手写法:N = 30~60,长度 5~15,62 个字符里随便抽 —— ★ 全不同,试金石在这儿是满分
// 1 ★ 从一个只有 N/2 个原型的池子里抽 —— 真的造出重复
// 2 ★ 档 1 + 埋那一对单模碰撞串
// 3 ★ 档 1 + 埋 Thue–Morse 1024 那一对
// 4 ★ 档 1 + 埋 "0" / "00" / "000"
// 5 ★★ 最终档 = 1 + 2 + 3 + 4 全埋
#include <bits/stdc++.h>
using namespace std;
static mt19937 rng;
static int ri(int lo, int hi) { return lo + (int)(rng() % (unsigned)(hi - lo + 1)); }
static const char* ALPHA =
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; // 62 个
static string randStr(int lo, int hi) {
int len = ri(lo, hi);
string s(len, ' ');
for (int i = 0; i < len; i++) s[i] = ALPHA[ri(0, 61)];
return s;
}
/** Thue–Morse 的前 len 位:t[i] = popcount(i) & 1;flip 把每一位取反 */
static string thueMorse(int len, bool flip) {
string s(len, 'a');
for (int i = 0; i < len; i++) {
int bit = __builtin_popcount((unsigned)i) & 1;
s[i] = (char)('a' + (bit ^ (flip ? 1 : 0)));
}
return s;
}
int main(int argc, char** argv) {
unsigned seed = argc > 1 ? (unsigned)atoi(argv[1]) : 1u;
int level = argc > 2 ? atoi(argv[2]) : 5;
rng.seed(seed * 2654435761u + 12345u);
vector<string> out;
int n = ri(30, 60);
if (level == 0) {
for (int i = 0; i < n; i++) out.push_back(randStr(5, 15));
} else {
int pool = max(3, n / 2);
vector<string> proto;
for (int i = 0; i < pool; i++) proto.push_back(randStr(5, 15));
for (int i = 0; i < n; i++) out.push_back(proto[ri(0, pool - 1)]);
if (level == 2 || level == 5) { // ★ 单模碰撞的那一对
out.push_back("rnjpnw");
out.push_back("vwxtxa");
}
if (level == 3 || level == 5) { // ★ 自然溢出的那一对
out.push_back(thueMorse(1024, false));
out.push_back(thueMorse(1024, true));
}
if (level == 4 || level == 5) { // ★ 全 '0' 串
out.push_back("0");
out.push_back("00");
out.push_back("000");
}
}
shuffle(out.begin(), out.end(), rng);
printf("%d\n", (int)out.size());
for (const string& s : out) printf("%s\n", s.c_str());
return 0;
}
点一下即可编辑
输出
点「运行 ▶」看结果
p3370All.cpp★ 试金石:什么都不做,恒输出 N

六档 × 300 轮(参照物是 set<string>,它和哈希一行代码都不共享):

档位 ✗ 单模数 ✗ 自然溢出 ★ 照抄负数 ✗ 映射到 0 ★ 试金石 ✗ 两两比
0 ★ 顺手写法(62 个字符随机、长 5~15) 0 0 0 0 0 0
1 ★ 从小池子里抽 ⇒ 真的有重复 0 0 0 0 300 0
2 ★ 埋进 rnjpnw / vwxtxa 300 0 0 0 300 0
3 ★ 埋进 Thue–Morse 1024 那一对 0 300 0 0 300 0
4 ★ 埋进 0 / 00 / 000 0 0 0 300 300 0
5 ★★ 最终档 = 2 + 3 + 4 300 300 0 300 300 0
★★★ 这张表里最值钱的是第一行
  1. ★★★ 顺手写的那一档,连「什么都不做」都是满分 —— 「62 个字符里随便抽 5~15 个」造出来的串互相撞上的概率小到不存在答案本来就等于 N,于是一份直接 cout << n 的程序 300 轮全对。 ⇒ 第 49 章正文那条规矩的又一次: 调生成器的第一步不是造 bug,是把「有答案」造出来。

  2. ★★★ 三个真错法各要一个专门的档,而三个档互不重叠 —— 单模数要「照着模数造的碰撞对」、自然溢出要「Thue–Morse 1024」、映射到 0 要「全 '0' 串」。 ⇒ ★★ 这三样随机数据一样都造不出来:它们不是「概率低」,是 「档位到不了那条线」。加一万轮也还是 0。

  3. ★ 而 「照抄负数」那一列六档全是精确的 0 —— 它配的自检就在同一张表里: 同一批数据上另外三列该抓的一个不少。

  4. 两两比那一列也是六档全 0,可它是第三种 0答案永远对第 20 章 P5019 那条)—— 对拍原理上看不见它,只能数次数。 ⇒ ★★★ 一张表里三种不同性质的 0 同时出现: 档位不够 / 本来就没错 / 答案永远对。看到一列 0,先问它是哪一种。

⚠ 官方那唯一一组样例:三个真错法全放过
✗ 单模数 ✗ 自然溢出 ✗ 映射到 0 ★ 试金石
样例(5 个串,答案 4) 放过 放过 放过 (打出 5)

⇒ 五个短串既撞不出碰撞对、又没有 Thue–Morse、也没有两个全 '0' 的串。 ★ 而它挡住了试金石 —— 因为样例里真的有重复(两个 abc)。 ⇒ 又一次「这组样例在结构上问不出这个问题」样例能问的只有『你会不会去重』,问不到『你的哈希靠不靠得住』。

8★ 哪一版就已经能过了

★★ 能过的有三版,而这一页要你记住的是那两版为什么会挂
版本 结果 说明
p3370.cpp(双模数哈希) AC 顶格约 35 ms + 读入 4 ms
p3370Set.cppset<string> AC,而且更快(4~7 ms) ⚠ 题面拜托你「自觉」的就是它
p3370Neg.cpp(照抄本章、算出负数) AC 负数不影响单射
p3370Pair.cpp(两两比) 看数据:随机 132 ms,共长前缀 1083 ms 答案永远对,样例和对拍都看不见
✗ 单模数 期望 0.05 次碰撞 / 出题人可以照着造
✗ 自然溢出 赌,而且是必输的赌 Thue–Morse 1024 ≤ M_max 1500
✗ 映射到 0 WA 只要有两个长度不同的全 '0'

⇒ ★★★ 一句话带走:这道题不是拿来比谁快的,它是拿来把「哈希会错」这件事变具体的。 而具体到最后只有三句: 别让任何字符映射成 0两两比的问法要用双模数自然溢出有现成的反例,别用

⚠ 而第四句在题面之外:set<string> 在这道题上又短又快 —— 选哈希的理由不是性能,是「这一章要练它」。 (同题单的 P2957 把这句话推到了极端:那道题上哈希省了 40.5 倍的次数, 秒表只快 1.5 倍,而两条路都是微秒量级。)