0题目原文(本地存了一份)
原题在洛谷上(页头有链接)。别人的网站不归我们管,打不开、改版、题号调整都可能发生。 所以每个解析页都把题面转录一份存在本地,跟着仓库一起进版本库。
转录自洛谷 P1381,日期见页头。两边不一致时信原站。
题目描述
灵梦有 n 个单词想要背,但她想通过一篇文章中的一段来记住这些单词。
文章由 m 个单词构成,她想在文章中找出连续的一段,其中包含最多的她想要背的单词
(重复的只算一个)。并且在背诵的单词量尽量多的情况下,还要使选出的文章段落尽量短,
这样她就可以用尽量短的时间学习尽可能多的单词了。
每个单词仅包含小写字母。
输入格式
第 1 行一个数 n,接下来 n 行每行是一个长度不超过 10 的字符串,表示一个要背的单词。
接着是一个数 m,然后是 m 行长度不超过 10 的字符串,每个表示文章中的一个单词。
输出格式
输出共 2 行。第 1 行为文章中最多包含的要背的单词数, 第 2 行表示在文章中包含最多要背单词的最短的连续段的长度。
数据范围
- 对于 30% 的数据,
n ≤ 50,m ≤ 500; - 对于 60% 的数据,
n ≤ 300,m ≤ 5000; - 对于 100% 的数据,
1 ≤ n ≤ 1000,1 ≤ m ≤ 10⁵。
时限 1 秒,内存 128000 KB(125 MB)。
输入输出样例
输入
3 hot dog milk 5 hot dog dog milk hot
输出
3 3
要背 hot / dog / milk,文章是 hot dog dog milk hot。
三个词都出现过 ⇒ 第一问是 3;最短的、含齐三个词的一段是 dog milk hot ⇒ 第二问是 3。
1★★ 第一问根本不用尺取 —— 整篇文章本身就是一个合法的段落
题面问的是「某个连续段里最多能有几种要背的单词」。
★★ 而整篇文章就是一个连续段 ⇒ 那个最大值只能是 「整篇文章里出现过的要背的单词有几种」,一次遍历就数完。
⇒ 所有人第一版都会去枚举窗口来求第一问,那是白做的。
真正需要尺取的只有第二问:最短的、含齐这 ans1 种的段落。
// P1381 单词背诵 —— 正解:把每个单词变成一个**编号**,然后尺取。//// ★★ 第一问其实不用尺取,一句话就完:**整篇文章本身就是一个合法的段落**// ⇒ 「某个窗口里最多能有几个要背的单词」= 「整篇文章里出现过的要背的单词有几个」。// (所有人第一版都会去枚举窗口,那是白做的。)//// ★ 第二问才是尺取:找**最短**的连续段,让它把那 ans1 个单词全含进去。// 右端一路往前,左端在「去掉它也不影响」的时候就往前挪 ——// 「不影响」有两种:这个词根本不用背,或者它在窗口里还有第二个。//// ⚠ 三处会咬人:// ① `ans1 = 0` 时第二问必须输出 **0**(一个词都不用背 ⇒ 空段落就够了),// 而顺手写的初值是 `m` 或者 1。// ② 「重复的只算一个」—— 窗口里要数的是**种类**,不是**个数**。// ③ 达到 ans1 的窗口有很多个,要的是**最短**的那个,不是第一个。//// ★★ 而这道题跟这一章的关系,题单注解写得很清楚:// 「这里用的是 unordered_map 那种哈希,和本章的字符串哈希是**两件事** —— 分清楚它们。」// ⇒ 相同点:两边都是「把一个串变成一个数」;// ★★★ 不同点:**STL 撞了只是慢,我们自己写的哈希撞了是错的**(见 p1381Hash.cpp)。//// 复杂度:O((n + m) × 词长)。#include <bits/stdc++.h>using namespace std;
int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int n; if (!(cin >> n)) return 0; unordered_map<string, int> id; id.reserve(n * 2); string s; for (int i = 0; i < n; i++) { cin >> s; if (!id.count(s)) { int k = (int)id.size(); id[s] = k; } } int tot = (int)id.size();
int m; cin >> m; vector<int> a(m); for (int i = 0; i < m; i++) { cin >> s; unordered_map<string, int>::const_iterator it = id.find(s); a[i] = (it == id.end()) ? -1 : it->second; }
/* 第一问:整篇文章里出现过多少种要背的单词 */ vector<int> cnt(tot, 0); int ans1 = 0; for (int i = 0; i < m; i++) if (a[i] >= 0 && cnt[a[i]]++ == 0) ans1++;
/* 第二问:最短的、含齐这 ans1 种的窗口 */ int ans2 = 0; if (ans1 > 0) { fill(cnt.begin(), cnt.end(), 0); ans2 = INT_MAX; int have = 0, l = 0; for (int r = 0; r < m; r++) { if (a[r] >= 0 && cnt[a[r]]++ == 0) have++; while (a[l] < 0 || cnt[a[l]] > 1) { if (a[l] >= 0) cnt[a[l]]--; l++; } if (have == ans1) ans2 = min(ans2, r - l + 1); } } cout << ans1 << '\n' << ans2 << '\n'; return 0;}点「运行 ▶」看结果
| 题面原话 | 顺手写下去会怎样 | |
|---|---|---|
| ① | 「重复的只算一个」 | 数成了个数 ⇒ 第一问直接错 |
| ② | 「还要使选出的文章段落尽量短」 | 一撞上 have == ans1 就 break ⇒ 取了最早的那个窗口 |
| ③ | (题面没写)ans1 = 0 时第二问该输出什么 |
初值是 m 或 1 ⇒ 打出一个非零数 |
★ 第 ③ 条的正确答案是 0:一个词都不用背,空段落就够了。
2★★★ 这道题和这一章的关系:STL 撞了只是慢,我们的哈希撞了是错的
第 49 章的题单在这道题后面写着:
「哈希表 + 尺取。★ 这里用的是
unordered_map那种哈希, 和本章的字符串哈希是两件事 —— 分清楚它们。」
★ 相同的地方:两边都在干同一件事 —— 把一个串变成一个数。
unordered_map<string, int> 内部调的就是 std::hash<string>,一个货真价实的字符串哈希。
★★★ 不同的地方只有一句:碰撞了以后谁兜底。
| 哈希值当什么用 | 撞了会怎样 | |
|---|---|---|
unordered_map<string,…> |
桶号 —— 同一个桶里再逐个 == 比串 |
★ 只是慢一点,答案永远对 |
| 我们自己写的字符串哈希 | 身份证 | ★★ 直接答错 |
⇒ 这一章从头到尾念叨双模数,就是因为我们没有兜底那一层。
题面写着:单词长度不超过 10、仅包含小写字母。
⚠⚠ 而第 49 章的 collide.cpp 造出来的那一对碰撞串
—— rnjpnw / vwxtxa(长度 6、全小写,base 131 模 10⁹+7 下都是 576565069)——
完完全全落在这道题的题面里。
⇒ 把 rnjpnw 放进要背的单词表、把 vwxtxa 放进文章:
单模数那一版会以为文章里出现了一个它其实没出现的单词,两问一起错
(对拍档 3、档 4 上 300 / 300)。
★ 而 unordered_map<string,…> 在同一份数据上一个字都不会错 ——
它撞了只是多比一次串。
// P1381 —— ✗ 错法四:把单词换成**单模数**哈希值当身份证。//// ★ 它和 p1381Hash.cpp 只差一件事:少了第二个模数。//// ⚠⚠ 而这道题给了它一条最短的死路:单词长度 ≤ 10、全是小写字母 ——// 而本章 collide.cpp 造出来的那一对碰撞串 **"rnjpnw" / "vwxtxa"**(长度 6、全小写、// base 131 模 10⁹+7 下都是 576565069)**完全落在题面允许的范围里**。// ⇒ 把 "rnjpnw" 放进要背的单词表、把 "vwxtxa" 放进文章,// 这一版就会**以为文章里出现了一个它其实没出现的单词**,两问一起错。//// ★★★ 而这正是题单那句注解的分量:// `unordered_map<string,…>` 撞了只是掉进同一个桶、再逐个 `==` 比一遍// ⇒ **慢一点,答案永远对**;// 我们这份是拿哈希值**当身份证**用的 ⇒ **撞了就是答错**。// **这一章没有兜底那一层,所以只能靠双模数把概率压到不可能。**#include <bits/stdc++.h>using namespace std;
const long long MOD1 = 1000000007;const long long B1 = 131;
static long long key(const string& s) { long long a = 0; for (char c : s) a = (a * B1 + (c - 'a' + 1)) % MOD1; return a;}
int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int n; if (!(cin >> n)) return 0; unordered_map<long long, int> id; id.reserve(n * 2); string s; for (int i = 0; i < n; i++) { cin >> s; long long k = key(s); if (!id.count(k)) { int t = (int)id.size(); id[k] = t; } } int tot = (int)id.size(); int m; cin >> m; vector<int> a(m); for (int i = 0; i < m; i++) { cin >> s; unordered_map<long long, int>::const_iterator it = id.find(key(s)); a[i] = (it == id.end()) ? -1 : it->second; } vector<int> cnt(tot, 0); int ans1 = 0; for (int i = 0; i < m; i++) if (a[i] >= 0 && cnt[a[i]]++ == 0) ans1++; int ans2 = 0; if (ans1 > 0) { fill(cnt.begin(), cnt.end(), 0); ans2 = INT_MAX; int have = 0, l = 0; for (int r = 0; r < m; r++) { if (a[r] >= 0 && cnt[a[r]]++ == 0) have++; while (a[l] < 0 || cnt[a[l]] > 1) { if (a[l] >= 0) cnt[a[l]]--; l++; } if (have == ans1) ans2 = min(ans2, r - l + 1); } } cout << ans1 << '\n' << ans2 << '\n'; return 0;}点「运行 ▶」看结果
// P1381 —— 把「三种查表方式」摆在一起量一遍,顺带回答题单那句注解。//// ★ 三种都对,差别在别处:// ① `map<string,int>` 红黑树,O(log n) 次**串比较**// ② `unordered_map<string,int>` ★ 它**也在算字符串哈希**(`std::hash<string>`),// 但哈希值只当**桶号**,同一个桶里再逐个 `==` 比串// ⇒ **撞了只是慢,答案永远对**// ③ 自写双模数哈希 → `unordered_map<unsigned long long,int>`// ⇒ 哈希值当**身份证**用,**撞了就是答错**//// ⇒ ★★★ 这就是题单那句「和本章的字符串哈希是两件事」的全部内容:// **不是算法不一样,是有没有兜底那一层不一样。**//// ⚠ 顺带把「顶格数据上我们这份哈希撞没撞」数出来 —— 撞了就说明双模数也不保险。//// 用法:./p1381Count [table|csv]#include <bits/stdc++.h>using namespace std;
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), ' ');}
const long long MOD1 = 1000000007, MOD2 = 998244353, B1 = 131, B2 = 13331;static unsigned long long key2(const string& s) { long long a = 0, b = 0; for (char c : s) { long long x = c - 'a' + 1; a = (a * B1 + x) % MOD1; b = (b * B2 + x) % MOD2; } return ((unsigned long long)a << 32) ^ (unsigned long long)b;}static long long key1(const string& s) { long long a = 0; for (char c : s) a = (a * B1 + (c - 'a' + 1)) % MOD1; return a;}
#define TIMEIT3(expr, out) do { \ double _v[3]; \ for (int _k = 0; _k < 3; _k++) { \ auto _t0 = chrono::steady_clock::now(); \ volatile long long _r = (expr); (void)_r; \ _v[_k] = chrono::duration<double, milli>(chrono::steady_clock::now() - _t0).count(); \ } \ sort(_v, _v + 3); (out) = _v[1]; \} while (0)
int main(int argc, char** argv) { string mode = argc > 1 ? argv[1] : "table";
/* 顶格:n = 1000 个要背的单词,m = 10⁵ 个文章单词,词长 ≤ 10 */ mt19937 rng(20260909u); const int N = 1000, M = 100000; vector<string> words(N), art(M); for (int i = 0; i < N; i++) { int len = 1 + (int)(rng() % 10); string s(len, 'a'); for (int j = 0; j < len; j++) s[j] = (char)('a' + rng() % 26); words[i] = s; } for (int i = 0; i < M; i++) art[i] = (rng() % 3 == 0) ? words[rng() % N] : words[rng() % N];
double tMap = 0, tUmapS = 0, tOwn = 0; TIMEIT3(([&] { map<string, int> id; for (int i = 0; i < N; i++) id[words[i]] = i; long long hit = 0; for (int i = 0; i < M; i++) if (id.count(art[i])) hit++; return hit; })(), tMap); TIMEIT3(([&] { unordered_map<string, int> id; id.reserve(N * 2); for (int i = 0; i < N; i++) id[words[i]] = i; long long hit = 0; for (int i = 0; i < M; i++) if (id.count(art[i])) hit++; return hit; })(), tUmapS); TIMEIT3(([&] { unordered_map<unsigned long long, int> id; id.reserve(N * 2); for (int i = 0; i < N; i++) id[key2(words[i])] = i; long long hit = 0; for (int i = 0; i < M; i++) if (id.count(key2(art[i]))) hit++; return hit; })(), tOwn);
/* ⚠ 撞没撞:把 N 个不同的单词的键数一数 */ set<string> uniq(words.begin(), words.end()); set<unsigned long long> k2; set<long long> k1; for (set<string>::const_iterator it = uniq.begin(); it != uniq.end(); ++it) { k2.insert(key2(*it)); k1.insert(key1(*it)); }
if (mode == "table") { printf("★ 顶格 n = 1000、m = 10⁵(词长 ≤ 10),三种查表方式(3 次取中位数)\n\n"); printf(" %s %12s\n", padDisp("查表方式", 44).c_str(), "毫秒"); printf(" %s %10.1f\n", padDisp("① map<string,int>(红黑树)", 44).c_str(), tMap); printf(" %s %10.1f\n", padDisp("② unordered_map<string,int>(STL 自己哈希)", 44).c_str(), tUmapS); printf(" %s %10.1f\n", padDisp("③ 自写双模数哈希 → unordered_map<u64>", 44).c_str(), tOwn); printf("\n ⇒ ② 比 ① 快 %.1f 倍;③ 和 ② 差 %.2f 倍 —— **选哪个都能过**,这道题的关卡不在这儿\n", tMap / tUmapS, tUmapS / tOwn); printf("\n⚠ 那 %d 个互不相同的单词,键撞了没有:\n", (int)uniq.size()); printf(" 双模数(64 位键):%d 个不同的键 ⇒ %s\n", (int)k2.size(), k2.size() == uniq.size() ? "没撞" : "★ 撞了!"); printf(" 单模数(10⁹+7) :%d 个不同的键 ⇒ %s\n", (int)k1.size(), k1.size() == uniq.size() ? "没撞(随机数据撞不到,要**造**)" : "★ 撞了!"); printf("\n ⇒ ★★★ 单模数这一行的「没撞」一点都不让人放心:" "collide.cpp 造到第 36819 个串就撞出了一对,\n" " 而那一对(rnjpnw / vwxtxa)**长度 6、全小写,完全落在这道题的题面里**。\n"); } else { printf("tMap,%.1f\ntUmapS,%.1f\ntOwn,%.1f\n", tMap, tUmapS, tOwn); printf("uniq,%d\nk2,%d\nk1,%d\n", (int)uniq.size(), (int)k2.size(), (int)k1.size()); printf("ratio21,%.1f\n", tMap / tUmapS); } return 0;}点「运行 ▶」看结果
顶格 n = 1000、m = 10⁵、词长 ≤ 10,3 次取中位数:
| 查表方式 | 毫秒 |
|---|---|
① map<string,int>(红黑树) |
8.3 |
② unordered_map<string,int>(STL 自己哈希) |
2.0 |
③ 自写双模数哈希 → unordered_map<u64> |
2.1 |
⇒ ② 比 ① 快 4.2 倍,② 和 ③ 打平。这道题的关卡根本不在查表上。
⚠ 而顶格那批数据里,921 个互不相同的单词在单模数下也没撞
—— ★★ 可那个「没撞」一点都不让人放心:
随机数据撞不到,不代表出题人造不出来(collide.cpp 造到第 36819 个串就撞出了一对)。
3★★★ 对拍:照题面随机造,正解 300 轮全部输出 0 0
// P1381 数据生成器(对拍用)。用法:./p1381Gen <seed> [档位],不给档位就是**最终档 4**。//// ★★ 这一页的生成器是整页最难写的一处,理由和[第 47 章 P1071](/sol/p1071/) 一模一样:// 照题面随机造 —— n 个随机小写串当单词表、m 个随机小写串当文章 ——// 两边**撞上的概率是 26⁻¹⁰ 量级** ⇒ **正解每一轮都输出 `0 0`**。// ⇒ 那不是「抓获率低」,是**这批数据根本没在问问题**。// ⇒ 出路是**反着造**:文章里的词**从单词表里抽**(再掺一点表外的词)。//// 每个错法靠什么现形:// ①ans2 初值是 m ← 只要 ans1 = 0 —— ★ 顺手那一档一抓一个准(那一档全是 0 0)// ②数个数不数种类 ← 要「某个要背的词在文章里出现两次以上」// ③取第一个窗口 ← 要「最短的窗口不是最早的那个」// ④单模数当身份证 ← ★★ 只能埋 collide.cpp 那一对:"rnjpnw" 进单词表、"vwxtxa" 进文章//// 档位:// 0 ★ 顺手写法:单词表和文章各自随机 —— **正解恒输出 0 0**,只有 ① 会现形// 1 ★ 反着造:文章里的词大半从单词表里抽 —— ② ③ 这才有得谈// 2 ★ 词表压到很小(2~4 个)+ 文章长 —— 逼出「最短窗口不是第一个」// 3 ★ 档 1 + 埋那一对碰撞串 —— 为 ④ 造的// 4 ★★ 最终档 = 2 + 3#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 string rw(int lo, int hi, int alpha) { int len = ri(lo, hi); string s(len, 'a'); for (int i = 0; i < len; i++) s[i] = (char)('a' + ri(0, alpha - 1)); return s;}
int main(int argc, char** argv) { unsigned seed = argc > 1 ? (unsigned)atoi(argv[1]) : 1u; int level = argc > 2 ? atoi(argv[2]) : 4; rng.seed(seed * 2654435761u + 4242u);
vector<string> words, art; if (level == 0) { int n = ri(3, 8), m = ri(10, 30); for (int i = 0; i < n; i++) words.push_back(rw(4, 10, 26)); for (int i = 0; i < m; i++) art.push_back(rw(4, 10, 26)); } else { int n = (level == 2 || level == 4) ? ri(2, 4) : ri(3, 8); int m = ri(20, 45); for (int i = 0; i < n; i++) words.push_back(rw(2, 4, 3)); // ★ 短词 + 小字母表:容易重复 for (int i = 0; i < m; i++) { if (ri(0, 3) == 0) art.push_back(rw(2, 4, 3)); // 掺一点表外的词 else art.push_back(words[ri(0, (int)words.size() - 1)]); } if (level == 3 || level == 4) { words.push_back("rnjpnw"); // ★ 只进单词表 art.insert(art.begin() + ri(0, (int)art.size()), "vwxtxa"); // ★ 只进文章 } }
printf("%d\n", (int)words.size()); for (const string& w : words) printf("%s\n", w.c_str()); printf("%d\n", (int)art.size()); for (const string& w : art) printf("%s\n", w.c_str()); return 0;}点「运行 ▶」看结果
照题面随机造:n 个随机小写串当单词表、m 个随机小写串当文章。
⚠⚠ 两边撞上的概率是 26⁻¹⁰ 量级 ⇒ 正解每一轮都输出 0 和 0。
⇒ 那不是「抓获率低」,是这批数据根本没在问问题 (第 47 章 P1071 那条,换一道题原样成立)。
★ 出路是反着造:文章里的词从单词表里抽(再掺一点表外的词)。 ⚠ 顺带把词压短(2~4 个字母)、字母表压到 3 个 —— 这样「同一个词出现两次」才成为常态。
五档 × 300 轮(参照物是「枚举所有窗口」,和正解一行代码都不共享; 正解 vs 自写双模数哈希版:1500 轮 0 组不一致):
| 档位 | ✗ ans2 初值是 m |
✗ 数个数不数种类 | ✗ 取最早的窗口 | ✗ 单模数当身份证 |
|---|---|---|---|---|
| 0 ★ 顺手写法:两边各自随机(正解恒输出 0 0) | 300 | ★ 0 | ★ 0 | ★ 0 |
| 1 ★ 反着造:文章的词从表里抽 | ★ 0 | 300 | 145 | 0 |
| 2 ★ 词表压到 2~4 个 + 文章更长 | 0 | 300 | 142 | 0 |
3 ★ 档 1 + 埋进 rnjpnw / vwxtxa |
0 | 300 | 148 | 300 |
| 4 ★★ 最终档 = 2 + 3 | 0 | 300 | 148 | 300 |
-
★★★ 第一行是「对拍在验零」最干净的一次 —— 正解 300 轮全是
0 0, 于是四个错法里只有「ans2初值写错」那一个跟着现形(而且是 300 / 300), 另外三个连触发的机会都没有。 ⇒ ★★ 而这一档最坏的地方在于:它看起来「抓到了一个」,很容易让人以为对拍在工作。 -
★★★ 同一个旋钮(反着造)把两列推向相反方向 —— 档 0 → 档 1:「初值是 m」从 300 掉到 0(
ans1 > 0之后它就对了), 而「数个数」从 0 涨到 300。 ⇒ 同一个旋钮把两个 bug 推向相反方向的又一次。 -
★ 「取最早的窗口」那一列稳在 145 左右,三个档几乎不动 —— 它要的是「最短的窗口不是最早的那个」,而那件事和词表大小、文章长度都关系不大, 大约一半一半。⇒ ⚠ 想把它推上去得换一个完全不同的旋钮 (比如把要背的词故意在开头堆一遍、结尾再密集地堆一遍),这一页没造。
| ✗ 初值是 m | ✗ 数个数 | ✗ 取最早的窗口 | ✗ 单模数 | |
|---|---|---|---|---|
样例(答案 3 / 3) |
放过 | ★ 死(打出 5) | ★ 死(打出 4) | 放过 |
★ 挡住的两个都是「每一组都错」型(只要 ans1 > 0 就必错),
放过的两个一个要 ans1 = 0、一个要碰撞串 —— 都是「偶尔才错」型。
⇒ 本书连着量了二十几道题的那条规律,在这一页又一次正面成立。
4★ 哪一版就已经能过了
| 版本 | 结果 | 说明 |
|---|---|---|
★ p1381.cpp(unordered_map<string>) |
AC | 顶格 2.0 毫秒 |
★ p1381Hash.cpp(自写双模数) |
AC | 顶格 2.1 毫秒 —— 打平 |
★ map<string> |
AC | 8.3 毫秒,也够 |
| ✗ 单模数当身份证 | 赌 | 而这道题的题面正好装得下那一对已知碰撞串 |
| ✗ 数个数不数种类 | WA | 样例就死 |
| ✗ 取最早的窗口 | WA | 样例就死 |
✗ ans2 初值是 m |
WA | ⚠ 样例放过(那组样例 ans1 = 3 > 0) |
⇒ ★★★ 一句话带走:这道题里有两种哈希,而它们的失败方式完全不同 ——
unordered_map 撞了只是慢,自己写的撞了就是 WA。
⇒ 用别人写好的容器时,碰撞是性能问题;自己拿哈希值当身份证时,碰撞是正确性问题。