0题目原文(本地存了一份)
原题在洛谷上(页头有链接)。别人的网站不归我们管,打不开、改版、题号调整都可能发生。 所以每个解析页都把题面转录一份存在本地,跟着仓库一起进版本库。
转录自洛谷 P3370,日期见页头。两边不一致时信原站。
题目描述
如题,给定 N 个字符串(第 i 个字符串长度为 Mᵢ,字符串内包含数字、大小写字母,大小写敏感),
请求出 N 个字符串中共有多少个不同的字符串。
友情提醒:如果真的想好好练习哈希的话,请自觉。
输入格式
第一行包含一个整数 N,为字符串的个数。
接下来 N 行每行包含一个字符串,为所提供的字符串。
输出格式
输出包含一行,包含一个整数,为不同的字符串个数。
数据范围
- 对于 30% 的数据:
N ≤ 10,Mᵢ ≈ 6,M_max ≤ 15。 - 对于 70% 的数据:
N ≤ 1000,Mᵢ ≈ 100,M_max ≤ 150。 - 对于 100% 的数据:
N ≤ 10000,Mᵢ ≈ 1000,M_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【模板】字符串哈希 —— 正解:双模数字符串哈希,把 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;}点「运行 ▶」看结果
2★★★ 三条路,而两把尺子指向相反的赢家
// 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 个字符。
⇒ 所以下面每张表都有两列:随机串 和 所有串共享一个很长的前缀。
顶格 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 字符」。 ⇒ 「顶格 ≠ 最坏」又一次,而这一次它正好卡在及格线两侧。
3★★★ 同一句话的三种改法:一种是正解,一种是噪声,一种是命门
第 49 章正文的 fast.cpp 里,字符是这么映射的:
long long c = t[i] - 'a' + 1; // ⚠ +1:不许有字符映射成 0而这道题的字符集是「数字 + 大小写字母」(0–9、A–Z、a–z,共 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。
同题单的 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 个串时
撞出了 rnjpnw 和 vwxtxa(base 131、模 10⁹+7 下都是 576565069)。
⚠ 而那一对在这道题上原样可用:两个串等长,换一种字符映射只会给两边加上同一个数
⇒ 差值不变,照样撞。
⇒ 于是生成器的档 2 就是「把这一对塞进输入」,那一列当场 300 / 300。
5★★★ 自然溢出:题面的 M_max = 1500,正好放得下那个反例
第 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」,这个反例就进不了这道题。
⇒ ★★ 读数据范围的时候顺手问一句:已知的那些反例,装不装得进这个上限。
6⚠ 读入那笔账:10 MB,而时限只有 1 秒
// 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;}点「运行 ▶」看结果
顶格是 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★ 对拍:顺手写的那一档连「什么都不做」都打不假
// 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;}点「运行 ▶」看结果
六档 × 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 |
-
★★★ 顺手写的那一档,连「什么都不做」都是满分 —— 「62 个字符里随便抽 5~15 个」造出来的串互相撞上的概率小到不存在 ⇒ 答案本来就等于 N,于是一份直接
cout << n的程序 300 轮全对。 ⇒ 第 49 章正文那条规矩的又一次: 调生成器的第一步不是造 bug,是把「有答案」造出来。 -
★★★ 三个真错法各要一个专门的档,而三个档互不重叠 —— 单模数要「照着模数造的碰撞对」、自然溢出要「Thue–Morse 1024」、映射到 0 要「全
'0'串」。 ⇒ ★★ 这三样随机数据一样都造不出来:它们不是「概率低」,是 「档位到不了那条线」。加一万轮也还是 0。 -
★ 而 「照抄负数」那一列六档全是精确的 0 —— 它配的自检就在同一张表里: 同一批数据上另外三列该抓的一个不少。
-
⚠ 两两比那一列也是六档全 0,可它是第三种 0:答案永远对 (第 20 章 P5019 那条)—— 对拍原理上看不见它,只能数次数。 ⇒ ★★★ 一张表里三种不同性质的 0 同时出现: 档位不够 / 本来就没错 / 答案永远对。看到一列 0,先问它是哪一种。
| ✗ 单模数 | ✗ 自然溢出 | ✗ 映射到 0 | ★ 试金石 | |
|---|---|---|---|---|
| 样例(5 个串,答案 4) | 放过 | 放过 | 放过 | ★ 死(打出 5) |
⇒ 五个短串既撞不出碰撞对、又没有 Thue–Morse、也没有两个全 '0' 的串。
★ 而它挡住了试金石 —— 因为样例里真的有重复(两个 abc)。
⇒ 又一次「这组样例在结构上问不出这个问题」:
样例能问的只有『你会不会去重』,问不到『你的哈希靠不靠得住』。
8★ 哪一版就已经能过了
| 版本 | 结果 | 说明 |
|---|---|---|
★ p3370.cpp(双模数哈希) |
AC | 顶格约 35 ms + 读入 4 ms |
★ p3370Set.cpp(set<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 倍,而两条路都是微秒量级。)