0题目原文(本地存了一份)
原题在洛谷上(页头有链接)。别人的网站不归我们管,打不开、改版、题号调整都可能发生。 所以每个解析页都把题面转录一份存在本地,跟着仓库一起进版本库。
转录自洛谷 P2922,日期见页头。两边不一致时信原站。
题目描述
贝茜正在领导奶牛们逃跑。为了联络,奶牛们互相发送秘密信息。
信息是二进制的,共有 M(1 ≤ M ≤ 50000)条,反间谍能力很强的约翰已经部分拦截了这些信息,
知道了第 i 条二进制信息的前 bᵢ(1 ≤ bᵢ ≤ 10000)位,他同时知道,
奶牛使用 N(1 ≤ N ≤ 50000)条暗号.但是,他仅仅知道第 j 条暗号的前 cⱼ(1 ≤ cⱼ ≤ 10000)位。
对于每条暗号 j,他想知道有多少截得的信息能够和它匹配。也就是说,有多少信息和这条暗号有着
相同的前缀。当然,这个前缀长度必须等于暗号和那条信息长度的较小者。
在输入文件中,位的总数(即 Σbᵢ + Σcᵢ)不会超过 5 × 10⁵。
输入格式
第 1 行:两个整数 M 和 N。
接下来 M 行,其中的第 i 行:一个整数 bᵢ,后跟 bᵢ 个空格分隔的「0」和「1」,描述截取的消息 i。
接下来 N 行,其中的第 j 行:一个整数 cⱼ,后跟 cⱼ 个空格分隔的「0」和「1」,描述暗号 j。
输出格式
N 行,第 i 行表示第 i 个暗号可以匹配的消息数。
说明
四条消息;五条暗号。截获的消息以 010、1、100 和 110 开头。
可能的暗号以 0、1、01、01001 和 11 开头。
0:匹配 010:1 个匹配。
1:匹配 1、100 和 110,3 个匹配。
01:匹配 010,1 个匹配。
01001:匹配 010,1 个匹配。
11:匹配 1 和 110,2 个匹配。
时限 1 秒,内存 128000 KB(125 MiB)。
输入输出样例
输入
4 5 3 0 1 0 1 1 3 1 0 0 3 1 1 0 1 0 1 1 2 0 1 5 0 1 0 0 1 2 1 1
输出
1 3 1 1 2
★ 盯着说明里那五行看 —— 它把「较小者」这三个字的两种情形都演了一遍:
问 1 时,1 比 100 短(信息更长);问 01001 时,01001 比 010 长(信息更短)。
1★★★ 本章那两个计数,第一次同时登场 —— 而且还要相减
「有多少信息和这条暗号有着相同的前缀。这个前缀长度必须等于暗号和那条信息长度的较小者。」
拆开就是两种情形,而它们在 Trie 上落在两个完全不同的地方:
① 信息比暗号短(或一样长) ⇒ 信息是暗号的前缀
⇒ 它在暗号那条路上的**某个节点**结束 ⇒ 沿途把 cntEnd 加起来
② 信息比暗号长 ⇒ 暗号是信息的前缀
⇒ 它**路过**暗号的终点 ⇒ 那就是 cntPass[终点]⚠⚠ 而 cntPass[终点] 里也含「正好在终点结束」的那些信息 —— 它们在 ① 里已经数过一遍。
ans = Σ(沿途每个节点的 cntEnd) + cntPass[终点] − cntEnd[终点]
↑ 这个减号是这道题的全部⇒ ★★★ 这一章题单走到最后一道,本章第 4 步那两个计数第一次同时用上:
P8306 只要 cntPass、P2580 只要「cntEnd 是不是 0」、
P10471 一个都不要、P3879 挂的是别的东西 ——
只有这道题两个都要,而且还要把它们相减。
// P2922 [USACO08DEC] Secret Message G —— 正解:Trie,**本章那两个计数第一次同时登场**//// ★ 题面那句「前缀长度必须等于暗号和那条信息长度的**较小者**」,拆开就是两种情形://// ① 信息比暗号**短**(或一样长)⇒ 信息是暗号的前缀// ⇒ 它在暗号那条路上的**某个节点上结束** ⇒ 沿途把 cntEnd 加起来。// ② 信息比暗号**长** ⇒ 暗号是信息的前缀// ⇒ 它**路过**暗号的终点 ⇒ 那就是 cntPass[终点]。//// ⚠⚠ 而 cntPass[终点] 里**也包含**「正好在终点结束」的那些信息 ——// 它们在第 ① 步已经数过一遍了。⇒ **必须减掉 cntEnd[终点]**,否则重复计数。//// ⇒ ans = Σ(沿途每个节点的 cntEnd) + cntPass[终点] − cntEnd[终点]//// ⚠ 还有一处:暗号走到一半**没路了**,说明没有任何信息以它为前缀// ⇒ 情形 ② 一个都没有,只能拿已经累加的那部分 —— **不能再加 cntPass**。
#include <bits/stdc++.h>using namespace std;
/* 节点数上限 = 所有信息的总位数 + 1;题面:Σb + Σc ≤ 5×10⁵ */const int MAXN = 500005;
int ch[MAXN][2];int cntPass[MAXN]; // 有多少条信息**路过**这个节点int cntEnd[MAXN]; // 有多少条信息正好在这个节点**结束**int tot;
void insertMsg(const vector<int>& b) { int u = 0; for (size_t i = 0; i < b.size(); i++) { int k = b[i]; if (!ch[u][k]) ch[u][k] = ++tot; u = ch[u][k]; cntPass[u]++; } cntEnd[u]++;}
int askCode(const vector<int>& c) { int u = 0; long long ans = 0; bool full = true; for (size_t i = 0; i < c.size(); i++) { int k = c[i]; if (!ch[u][k]) { full = false; break; } // ⚠ 走不下去 ⇒ 没有更长的信息了 u = ch[u][k]; ans += cntEnd[u]; // ① 比暗号短(或一样长)的那些 } if (full) ans += cntPass[u] - cntEnd[u]; // ② 比暗号长的那些(⚠ 减掉重复的) return (int)ans;}
int main() { ios::sync_with_stdio(false); cin.tie(nullptr);
int m, n; if (!(cin >> m >> n)) return 0; for (int i = 0; i < m; i++) { int b; cin >> b; vector<int> v(b); for (int j = 0; j < b; j++) cin >> v[j]; insertMsg(v); } for (int i = 0; i < n; i++) { int c; cin >> c; vector<int> v(c); for (int j = 0; j < c; j++) cin >> v[j]; cout << askCode(v) << '\n'; } return 0;}点「运行 ▶」看结果
2★★★ 第一版:两两比 —— 而这道题的「顶格」有两个方向,差 1856 倍
题面给的是 Σbᵢ + Σcⱼ ≤ 5 × 10⁵,同时 M, N ≤ 5 × 10⁴、每条 ≤ 10⁴ 位。
⇒ 同样用满 5 × 10⁵ 位,可以切成「很多条很短的」,也可以切成「很少条很长的」:
| 顶格形状(位数完全一样,都是 5 × 10⁵) | 对数 M × N |
暴力比了多少位 | ✗ 暴力 | ★ 正解 |
|---|---|---|---|---|
many:M = N = 5×10⁴,每条 5 位 |
2 500 000 000 | 4 921 848 749 | ⚠ 18.56 秒 | 0.01 秒 |
long:M = N = 25,每条 10⁴ 位 |
★ 625 | ★ 1 240 | ★ 0.01 秒 | 0.01 秒 |
share:多而短 + 共用前缀 |
2 500 000 000 | 9 687 548 415 | 19.32 秒 | 0.01 秒 |
⇒ ★★★ 两档的位数一个字都不差,而暴力差 1856 倍。 这是本书「顶格 ≠ 最坏」最干净的一次: 不是「大小一样、形状不同」,是同一个约束的两种切法。
★ 而正解那一列两档都是 0.01 秒 —— 它做多少活只看 Σ 位数
(插一遍 + 走一遍 = 500 000 步),对这一刀切在哪儿完全不敏感。
⚠ 顺手写一个「M = N = 25、每条一万位」的顶格数据跑一遍,你会得出「暴力能过」的结论。
| 静态数组要开多大 | 题面给 | 余量 | |
|---|---|---|---|
| P8306 | 709.5 MiB | 1024 MiB | 1.4 倍 |
| P3879 | ⚠ 599.2 MiB | 512 MiB | ★ 不够,MLE |
| 这道题 | ch[500005][2] + 两个计数 = 7.6 MiB |
125 MiB | ★ 16.4 倍 |
⇒ 因为字符集只有 2(0 和 1)—— 每个节点 2 个儿子,而不是 26 个或 62 个。
★ 顺带一句:答案 ≤ M = 5 × 10⁴ ⇒ int 绰绰有余,这道题上没有溢出这回事。
3⚠ 三个错法,而三个都长在「那两个计数怎么用」上
| 它其实在算什么 | 白送的推论 | |
|---|---|---|
| ①Dbl | 正确答案 +(和这条暗号一模一样的信息条数) | 没有信息和暗号完全相同时,一分不扣 |
| ②EndOnly | 只数「一样长且相同」的 +「比暗号长」的(漏掉「比暗号短」的一整类) | ⚠ 所有串等长时,一分不扣 |
| ③Break | 把暗号截短到「树上还走得通」那一段之后的答案 | ⚠ 所有暗号都走得到底时,一分不扣 |
★★ 最右边那一列不是猜的 —— 下面那张表里,后两条各被一个对照档打成了能证的精确的 0。
4★ 对拍:三个错法 + 一份「一律输出 0」
// P2922 的两笔账:Trie 有多少个节点,两条路各做多少次基本动作//// 用法:./p2922Count <csv|table> < 一份输入
#include <bits/stdc++.h>using namespace std;
int main(int argc, char** argv) { string mode = argc > 1 ? argv[1] : "table";
int m, n; if (scanf("%d %d", &m, &n) != 2) return 0; vector<string> msg(m), code(n); auto readBits = [](int cnt) { string s; s.reserve(cnt); for (int j = 0; j < cnt; j++) { int x; if (scanf("%d", &x) != 1) return s; s += (char)('0' + x); } return s; }; long long bBits = 0, cBits = 0; for (int i = 0; i < m; i++) { int b; if (scanf("%d", &b) != 1) return 0; msg[i] = readBits(b); bBits += b; } for (int i = 0; i < n; i++) { int c; if (scanf("%d", &c) != 1) return 0; code[i] = readBits(c); cBits += c; }
/* ① Trie 节点数 = 所有信息的不同前缀个数 */ long long nodes = 0; { vector<string> v(msg); sort(v.begin(), v.end()); string prev; for (size_t i = 0; i < v.size(); i++) { size_t l = 0; while (l < v[i].size() && l < prev.size() && v[i][l] == prev[l]) l++; nodes += (long long)v[i].size() - (long long)l; prev = v[i]; } }
/* ② 两条路各做多少次「看一位」 */ long long bruteBits = 0; for (int i = 0; i < n; i++) for (int j = 0; j < m; j++) { size_t len = min(code[i].size(), msg[j].size()); size_t k = 0; while (k < len && code[i][k] == msg[j][k]) k++; bruteBits += (long long)k + 1; } long long trieBits = bBits + cBits; // 每条信息插一遍、每条暗号走一遍
vector<pair<string, long long> > out; out.push_back(make_pair("m", m)); out.push_back(make_pair("n", n)); out.push_back(make_pair("bits", bBits + cBits)); out.push_back(make_pair("nodes", nodes)); out.push_back(make_pair("brute_pairs", (long long)m * n)); out.push_back(make_pair("brute_bits", bruteBits)); out.push_back(make_pair("trie_bits", trieBits)); out.push_back(make_pair("ratio", trieBits ? bruteBits / trieBits : 0)); out.push_back(make_pair("mib_x100", (long long)(nodes * 3.0 * 4 * 100 / 1048576.0)));
if (mode == "csv") for (size_t i = 0; i < out.size(); i++) printf("%s,%lld\n", out[i].first.c_str(), out[i].second); else for (size_t i = 0; i < out.size(); i++) printf(" %-14s %lld\n", out[i].first.c_str(), out[i].second); return 0;}点「运行 ▶」看结果
| 档位 | ①Dbl | ②EndOnly | ③Break | 试金石 |
|---|---|---|---|---|
| 0 顺手写法(两批串独立随机,3~8 位) | 107 | 234 | 296 | 279 |
| 1 + 一半暗号一模一样地抄一条信息 | 299 | 213 | 273 | 300 |
| 2 位串压到 1~3 位(大量前缀关系) | 233 | 300 | 234 | 300 |
| 3 最终档 = 1 + 2 | 298 | 300 | 189 | 300 |
| ★ 4 对照档:所有串长度都一样 | 249 | ★ 0 | 286 | 249 |
| ★ 5 对照档:每条暗号都是某条信息的前缀 | 262 | 119 | ★ 0 | 300 |
★★★ 最后两行是这张表的价值所在 —— 两个 0 都能证,一行一个:
- 档 4(全等长):所有信息一样长 ⇒ 除终点外沿途每个节点的
cntEnd恒为 0 ⇒ 「沿途累加」和「只在终点收一次」是同一件事 ⇒ ②EndOnly 一分不扣; - 档 5(暗号都是某条信息的前缀):暗号永远走得到底
⇒ 那个
full判断一次都没起过作用 ⇒ ③Break 一分不扣。
⇒ ★★ 而两个对照档同时也是「说清楚它算了什么」的兑现: 先写出「它算的是什么」,再照着那句话造一档数据 —— 0 就是推出来的,不是撞出来的。
| ①Dbl | ②EndOnly | ③Break | 试金石 | |
|---|---|---|---|---|
| 官方那组样例 | 死 | 死 | ★ 放过 | 死 |
★ ③Break 被放过的原因,就在题面说明里那条 01001:
它走到 010 之后确实没路了 —— 可 010 正好是一条信息的结尾,
于是 cntPass[010] − cntEnd[010] = 1 − 1 = 0,多加的那一下恰好是 0。
⇒ 又一次「这组样例在结构上问不出这个问题」:
样例踩到了那个分支,只是踩上去的那一脚没有分量。
| 顶格形状 | ✗ 暴力看的位数 | ★ Trie 走的步数 | 倍数 |
|---|---|---|---|
many(多而短) |
4 921 848 749 | 500 000 | ★ 9843 倍 |
share(多而短 + 共用前缀) |
9 687 548 415 | 500 000 | ★ 19 375 倍 |
long(少而长) |
★ 1 240 | 500 000 | ⚠ 0.002 倍 |
⇒ ★★★ 最后一行是白送的一课:在「少而长」那一档,暴力看的位数比 Trie 还少 403 倍。 道理很直白 —— 25 条一万位的随机串,两两之间平均比两位就分出高下了, 而 Trie 不管怎样都要把 5×10⁵ 位全走一遍。 ⇒ ⚠ 「谁做的活少」这句话,不写清楚是哪一档数据就是错的。
5★ 哪一版就已经能过了
| 版本 | 能过吗 | 数字 |
|---|---|---|
| ✗ 两两比 | 一分不给 | many 档 18.56 秒 / 时限 1 秒;⚠ 这道题一个部分分档都没有 |
| ★ 正解 | ✓ | 0.01 秒、7.6 MiB / 125 MiB(余量 16.4 倍) |
⇒ 这道题的全部功课就是那一行:
ans = Σ(沿途 cntEnd) + cntPass[终点] − cntEnd[终点] ← 走到底才加后半截⚠ 而三个错法各拆掉它的一块,官方样例只挡得住两块。