题单 · 习题解析

洛谷 P2922 [USACO08DEC] Secret Message G

★★★ 这一章题单的收尾题,**本章那两个计数第一次同时用上,而且还要相减** —— 题面那句「前缀长度等于两者**较小者**」拆开是两种情形(信息更短 ⇒ 沿途累加 `cntEnd`;信息更长 ⇒ `cntPass[终点]`),⚠⚠ 而后者**含**前者已经数过的那些 ⇒ `ans = Σ沿途cntEnd + cntPass[终点] − cntEnd[终点]`,**那个减号就是这道题的全部**;★★★ 而这一页最值钱的是「顶格 ≠ 最坏」最干净的一次:题面的顶格条件是一句**和**(`Σb + Σc ≤ 5×10⁵`),而这一刀切成「多而短」(`M = N = 5×10⁴`、每条 5 位 ⇒ 2.5×10⁹ 对,**18.56 秒**)还是「少而长」(`M = N = 25`、每条 10⁴ 位 ⇒ **625 对,0.01 秒**)**位数一个字都不差,暴力却差 1856 倍** ⇒ 顺手造后一种,你会得出「暴力能过」的结论;★★ 而正解两档都是 0.01 秒 —— 它做多少活只看 `Σ 位数`;★★ 换尺子那张表还有一课**反过来的**:`long` 那一档**暴力看的位数(1240)比 Trie 走的步数(5×10⁵)还少 403 倍**(25 条随机万位串平均比 1.98 位就分出高下)⇒ **「谁做的活少」不写清楚是哪一档数据就是错的**;★★★ 三个错法各拆掉那一行的一块,而**后两个各被一个对照档打成能证的精确的 0**(所有串等长 ⇒ 沿途 `cntEnd` 恒为 0 ⇒「只在终点收」和「沿途累加」是同一件事;所有暗号都是某条信息的前缀 ⇒ 那个 `full` 判断一次都没起过作用)—— **0 是推出来的,不是撞出来的**;⚠ 官方样例三个里挡住两个,放过的那个原因很具体:样例的 `01001` **踩到了**「走不下去」那个分支,只是那一脚 `cntPass − cntEnd = 1 − 1 = 0`,**没有分量**;★ 顺带一条对照:字符集只有 2 ⇒ 静态数组只要 **7.6 MiB / 125 MiB(余量 16.4 倍)**,是这一章题单里最松的一档([P8306](/sol/p8306/) 1.4 倍、[P3879](/sol/p3879/) 直接 MLE)

原题:洛谷 P2922出自 第 50 章 Trie(字典树):一堆字符串摆成一棵树 的题单题面本地存档:2026-09-10
⚠ 先自己写一遍,再往下看

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

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

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

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

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

题目描述

贝茜正在领导奶牛们逃跑。为了联络,奶牛们互相发送秘密信息。

信息是二进制的,共有 M1 ≤ M ≤ 50000)条,反间谍能力很强的约翰已经部分拦截了这些信息, 知道了第 i 条二进制信息的前 bᵢ1 ≤ bᵢ ≤ 10000)位,他同时知道, 奶牛使用 N1 ≤ N ≤ 50000)条暗号.但是,他仅仅知道第 j 条暗号的前 cⱼ1 ≤ cⱼ ≤ 10000)位。

对于每条暗号 j,他想知道有多少截得的信息能够和它匹配。也就是说,有多少信息和这条暗号有着 相同的前缀。当然,这个前缀长度必须等于暗号和那条信息长度的较小者

在输入文件中,位的总数(即 Σbᵢ + Σcᵢ)不会超过 5 × 10⁵

输入格式

第 1 行:两个整数 MN

接下来 M 行,其中的第 i 行:一个整数 bᵢ,后跟 bᵢ 个空格分隔的「0」和「1」,描述截取的消息 i

接下来 N 行,其中的第 j 行:一个整数 cⱼ,后跟 cⱼ 个空格分隔的「0」和「1」,描述暗号 j

输出格式

N 行,第 i 行表示第 i 个暗号可以匹配的消息数。

说明

四条消息;五条暗号。截获的消息以 0101100110 开头。 可能的暗号以 01010100111 开头。

0:匹配 010:1 个匹配。 1:匹配 1100110,3 个匹配。 01:匹配 010,1 个匹配。 01001:匹配 010,1 个匹配。 11:匹配 1110,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 时,1100 (信息更长);问 01001 时,01001010 (信息更短)。

1★★★ 本章那两个计数,第一次同时登场 —— 而且还要相减

★★ 把题面那句「较小者」拆成两句话

「有多少信息和这条暗号有着相同的前缀。这个前缀长度必须等于暗号和那条信息长度的较小者。」

拆开就是两种情形,而它们在 Trie 上落在两个完全不同的地方:

   ① 信息比暗号短(或一样长)  ⇒ 信息是暗号的前缀
      ⇒ 它在暗号那条路上的**某个节点**结束   ⇒ 沿途把 cntEnd 加起来
   ② 信息比暗号长              ⇒ 暗号是信息的前缀
      ⇒ 它**路过**暗号的终点                 ⇒ 那就是 cntPass[终点]

⚠⚠ 而 cntPass[终点]也含「正好在终点结束」的那些信息 —— 它们在 ① 里已经数过一遍。

   ans = Σ(沿途每个节点的 cntEnd)  +  cntPass[终点] − cntEnd[终点]
                                                    ↑ 这个减号是这道题的全部

⇒ ★★★ 这一章题单走到最后一道,本章第 4 步那两个计数第一次同时用上P8306 只要 cntPassP2580 只要「cntEnd 是不是 0」、 P10471 一个都不要、P3879 挂的是别的东西 —— 只有这道题两个都要,而且还要把它们相减。

p2922.cpp★ 正解:Trie,路过 + 结尾两个计数一起用
// 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

2★★★ 第一版:两两比 —— 而这道题的「顶格」有两个方向,差 1856 倍

p2922Brute.cpp✗ 第一版:题面的直译(也是对拍的标准答案)
p2922GenBig.cpp★ 顶格生成器:many / long / share
★★★ 题面的顶格条件是一句「和」,而这一刀切在哪儿,决定了暴力生死

题面给的是 Σbᵢ + Σcⱼ ≤ 5 × 10⁵,同时 M, N ≤ 5 × 10⁴、每条 ≤ 10⁴ 位。 ⇒ 同样用满 5 × 10⁵ 位,可以切成「很多条很短的」,也可以切成「很少条很长的」:

顶格形状(位数完全一样,都是 5 × 10⁵) 对数 M × N 暴力比了多少位 ✗ 暴力 ★ 正解
manyM = N = 5×10⁴,每条 5 位 2 500 000 000 4 921 848 749 18.56 秒 0.01 秒
longM = 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⚠ 三个错法,而三个都长在「那两个计数怎么用」上

p2922Dbl.cpp✗ 错法①:忘了减 cntEnd[终点](长度相等的被数两遍)
p2922EndOnly.cpp✗ 错法②:只在终点收一次 cntEnd(没有沿途累加)
p2922Break.cpp✗ 错法③:走不下去了,照样加 cntPass
★ 三个错法各自「算了什么」,一句话一个
它其实在算什么 白送的推论
①Dbl 正确答案 +(和这条暗号一模一样的信息条数) 没有信息和暗号完全相同时,一分不扣
②EndOnly 只数「一样长且相同」的 +「比暗号长」的(漏掉「比暗号短」的一整类) ⚠ 所有串等长时,一分不扣
③Break 把暗号截短到「树上还走得通」那一段之后的答案 ⚠ 所有暗号都走得到底时,一分不扣

★★ 最右边那一列不是猜的 —— 下面那张表里,后两条各被一个对照档打成了能证的精确的 0

4★ 对拍:三个错法 + 一份「一律输出 0」

p2922Zero.cpp★ 试金石:一律输出 0
p2922Gen.cpp★ 生成器:四个档位 + 两个对照档
p2922Count.cpp★ 换尺子:节点数、两条路各看了多少位
// 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;
}
点一下即可编辑
输出
点「运行 ▶」看结果
★★★ 300 轮 × 六档(本机实测,2026-09-10)
档位 ①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★ 哪一版就已经能过了

★★ 结论
版本 能过吗 数字
✗ 两两比 一分不给 many18.56 秒 / 时限 1 秒;⚠ 这道题一个部分分档都没有
正解 0.01 秒7.6 MiB / 125 MiB(余量 16.4 倍)

⇒ 这道题的全部功课就是那一行:

   ans = Σ(沿途 cntEnd) + cntPass[终点] − cntEnd[终点]     ← 走到底才加后半截

⚠ 而三个错法各拆掉它的一块,官方样例只挡得住两块

★ 一句话带走 —— 这一章题单的收尾

六道题,Trie 的骨架一个字都没变过,变的一直是挂在节点上的东西:

节点上挂什么
P8306 一个计数
P2580 一个会被改写的三态
P10471 什么都不挂(只剩「这条边在不在」)
P3879 一串文章号(长度不定 ⇒ 内存第一次成了关卡)
P4551 什么都不挂(01-Trie 贪心)
这道题 两个计数一起用,而且要相减

⇒ ★★★ 「让公共前缀只存一份」是 Trie 的全部本事;剩下的每一样,都是每道题各自的事。