0题目原文(本地存了一份)
原题在洛谷上(页头有链接)。别人的网站不归我们管,打不开、改版、题号调整都可能发生。 所以每个解析页都把题面转录一份存在本地,跟着仓库一起进版本库。
转录自洛谷 P2024,日期见页头。两边不一致时信原站。
题目描述
动物王国中有三类动物 A, B, C,这三类动物的食物链构成了有趣的环形。
A 吃 B,B 吃 C,C 吃 A。
现有 N 个动物,以 1 ~ N 编号。每个动物都是 A, B, C 中的一种,但是我们并不知道它到底是哪一种。
有人用两种说法对这 N 个动物所构成的食物链关系进行描述:
- 第一种说法是
1 X Y,表示X和Y是同类。 - 第二种说法是
2 X Y,表示X吃Y。
此人对 N 个动物,用上述两种说法,一句接一句地说出 K 句话,这 K 句话有的是真的,有的是假的。
当一句话满足下列三条之一时,这句话就是假话,否则就是真话。
- 当前的话与前面的某些真的话冲突,就是假话;
- 当前的话中
X或Y比N大,就是假话; - 当前的话表示
X吃X,就是假话。
你的任务是根据给定的 N 和 K 句话,输出假话的总数。
输入格式
第一行两个整数,N, K。第二行开始每行一句话。
输出格式
一行,一个整数,表示假话的总数。
数据规模与约定
对于全部数据,1 ≤ N ≤ 5 × 10⁴,1 ≤ K ≤ 10⁵,|X|, |Y| < 2³²。
时限 1 秒,内存 128 MB。
输入输出样例
输入
100 7 1 101 1 2 1 2 2 2 3 2 3 3 1 1 3 2 3 1 1 5 5
输出
3
1 101 1 超范围(假);2 3 3 是「3 吃 3」(假);1 1 3 和前面冲突(假)。
其余三句为真。⇒ 3 句假话。
⚠ 那半行 |X|, |Y| < 2³² 比 int 的范围大一倍 —— 第 ⑤ 步专门讲它。
1★★ 关键的一步:这一次要把点数翻三倍
// P2024 [NOI2001] 食物链 —— ★ 这一版就能 AC(扩展域 / 三倍点)//// ★★ 关键的一步还是「把点数翻倍」,只不过这一次要翻**三倍**:// i = 「和 i 同类」的那一族// i + n = 「被 i 吃」的那一族(i 的猎物)// i + 2n = 「吃 i」的那一族(i 的天敌)//// 1 X Y(同类)⇒ 三条都要连:union(X, Y)、union(X+n, Y+n)、union(X+2n, Y+2n)// 2 X Y(X 吃 Y)⇒ union(X+n, Y)、union(X+2n, Y+n)、union(X, Y+2n)// (Y 是 X 的猎物;Y 的猎物是 X 的天敌;X 是 Y 的天敌 —— 环形食物链绕一圈)//// ⚠ 判假话要**在合并之前**做,而且三条判据的顺序题面写死了(本页第 ③ 步):// ① X 或 Y 比 N 大 ② X 吃 X ③ 和前面的真话冲突。//// ⚠⚠ 题面写着 |X|, |Y| < 2³² —— **X 可以超出 int**,读入必须用能装下的类型(本页第 ⑤ 步)。#include <bits/stdc++.h>using namespace std;
static const int N = 50005;static int fa[3 * N];
static int find(int x) { int r = x; while (fa[r] != r) r = fa[r]; while (fa[x] != r) { int nx = fa[x]; fa[x] = r; x = nx; } return r;}static void unite(int a, int b) { fa[find(a)] = find(b); }static bool same(int a, int b) { return find(a) == find(b); }
int main() { ios::sync_with_stdio(false); cin.tie(nullptr); long long n, k; if (!(cin >> n >> k)) return 0; for (long long i = 1; i <= 3 * n; i++) fa[i] = (int)i; long long lies = 0; for (long long i = 0; i < k; i++) { long long d, x, y; cin >> d >> x >> y; if (x > n || y > n || x < 1 || y < 1) { lies++; continue; } // ① 超范围 if (d == 2 && x == y) { lies++; continue; } // ② 自己吃自己 int X = (int)x, Y = (int)y, nn = (int)n; if (d == 1) { if (same(X, Y + nn) || same(X, Y + 2 * nn)) { lies++; continue; } // ③ 冲突 unite(X, Y); unite(X + nn, Y + nn); unite(X + 2 * nn, Y + 2 * nn); } else { if (same(X, Y) || same(X, Y + nn)) { lies++; continue; } // ③ 冲突 unite(X + nn, Y); unite(X + 2 * nn, Y + nn); unite(X, Y + 2 * nn); } } cout << lies << '\n'; return 0;}点「运行 ▶」看结果
| 位置 | 含义 |
|---|---|
i |
和 i 同类的那一族 |
i + n |
被 i 吃的那一族(i 的猎物) |
i + 2n |
吃 i 的那一族(i 的天敌) |
1 X Y(同类)⇒ union(X, Y)、union(X+n, Y+n)、union(X+2n, Y+2n) 三条都要
2 X Y(X 吃 Y)⇒ union(X+n, Y)、union(X+2n, Y+n)、union(X, Y+2n) 环形食物链绕一圈★ 隔壁 P1892 是两倍(朋友 / 敌人),这道题是三倍(同类 / 猎物 / 天敌)—— ⇒ 翻几倍取决于「关系有几种取值」:那道题的关系模 2,这道题模 3。
2★★★ 一条草稿被实测打回来:「忘了 X 吃 X」根本不是 bug
// P2024 · 错法 ①:忘了「X 吃 X 是假话」这一条//// ⚠ 题面把三条判据一条条列了出来,而这一条最容易漏 ——// 因为它**不是**冲突判断能顺手覆盖的:`2 X X` 在并查集里看起来只是「X 吃 X」,// 而 X 和 X 本来就同类 ⇒ 它其实会被第三条(冲突)抓到……**但只有在 X 已经出现过时**。// ★ 本页第 ③ 步量了它到底漏多少。#include <bits/stdc++.h>using namespace std;
static const int N = 50005;static int fa[3 * N];
static int find(int x) { int r = x; while (fa[r] != r) r = fa[r]; while (fa[x] != r) { int nx = fa[x]; fa[x] = r; x = nx; } return r;}static void unite(int a, int b) { fa[find(a)] = find(b); }static bool same(int a, int b) { return find(a) == find(b); }
int main() { ios::sync_with_stdio(false); cin.tie(nullptr); long long n, k; if (!(cin >> n >> k)) return 0; for (long long i = 1; i <= 3 * n; i++) fa[i] = (int)i; long long lies = 0; for (long long i = 0; i < k; i++) { long long d, x, y; cin >> d >> x >> y; if (x > n || y > n || x < 1 || y < 1) { lies++; continue; } // ① 超范围 // ✗ 少了「② 自己吃自己」这一条 int X = (int)x, Y = (int)y, nn = (int)n; if (d == 1) { if (same(X, Y + nn) || same(X, Y + 2 * nn)) { lies++; continue; } // ③ 冲突 unite(X, Y); unite(X + nn, Y + nn); unite(X + 2 * nn, Y + 2 * nn); } else { if (same(X, Y) || same(X, Y + nn)) { lies++; continue; } // ③ 冲突 unite(X + nn, Y); unite(X + 2 * nn, Y + nn); unite(X, Y + 2 * nn); } } cout << lies << '\n'; return 0;}点「运行 ▶」看结果
草稿里把「漏掉题面第三条判据」当成错法收了进来,理由听着很顺:
「题面明明写着 X 吃 X 是假话,不判怎么行?」
五个档 1500 轮,被抓 0 次。 一行就能证:
2 X X走到冲突判据时,第一个条件是same(X, Y)—— 而这里Y就是X,same(X, X)恒为真 ⇒ 它必然被判成假话,而且同样不会做任何合并。
⇒ 两版逐字节相同,不是「概率低」,是恒等。
| 五个档 300 轮 | 档 0 | 档 1 超范围 | 档 2 全是吃 | ★ 档 3 大量 X == Y |
档 5 |
|---|---|---|---|---|---|
输入里真的出现过 2 X X |
177 | 108 | 240 | 291 | — |
| ⇒ 「忘了那一条」被抓 | ★ 0 | ★ 0 | ★ 0 | ★ 0 | ★ 0 |
★★ 而这个「精确的 0」配了自检:专门造了一个「大量 X == Y」的档(291 / 300 轮里真有),
另外四个错法在同一批数据上被抓 35 / 88 次 —— 对拍是活的。
⇒ 又一次「看着像 bug、其实一次都不会错」。
⚠ 而结论不是「那一条判据可以不写」:写上它更说得清,而且不依赖「same(X,X) 恒真」这个巧合。
3⚠ 真正会咬人的第一条:判据的顺序
// P2024 · 错法 ④:**先合并、再判冲突**//// ⚠ 题面写得很清楚:「当前的话与前面的**某些真的话**冲突,就是假话」——// 判据里的「前面」是关键:**必须在把这句话并进去之前判**。// ★ 先并进去再判,那句话自己就把冲突消掉了 ⇒ **它恒判不出冲突**,// 假话数只剩前两条判据抓到的那些(本页第 ③ 步)。#include <bits/stdc++.h>using namespace std;
static const int N = 50005;static int fa[3 * N];
static int find(int x) { int r = x; while (fa[r] != r) r = fa[r]; while (fa[x] != r) { int nx = fa[x]; fa[x] = r; x = nx; } return r;}static void unite(int a, int b) { fa[find(a)] = find(b); }static bool same(int a, int b) { return find(a) == find(b); }
int main() { ios::sync_with_stdio(false); cin.tie(nullptr); long long n, k; if (!(cin >> n >> k)) return 0; for (long long i = 1; i <= 3 * n; i++) fa[i] = (int)i; long long lies = 0; for (long long i = 0; i < k; i++) { long long d, x, y; cin >> d >> x >> y; if (x > n || y > n || x < 1 || y < 1) { lies++; continue; } // ① 超范围 if (d == 2 && x == y) { lies++; continue; } // ② 自己吃自己 int X = (int)x, Y = (int)y, nn = (int)n; if (d == 1) { unite(X, Y); // ✗ 先并 unite(X + nn, Y + nn); unite(X + 2 * nn, Y + 2 * nn); if (same(X, Y + nn) || same(X, Y + 2 * nn)) lies++; // ✗ 再判 —— 永远判不出来 } else { unite(X + nn, Y); // ✗ 先并 unite(X + 2 * nn, Y + nn); unite(X, Y + 2 * nn); if (same(X, Y) || same(X, Y + nn)) lies++; // ✗ 再判 } } cout << lies << '\n'; return 0;}点「运行 ▶」看结果
题面写着「当前的话与前面的某些真的话冲突」—— 「前面」是关键: 必须在把这句话并进去之前判。先并进去再判,那句话自己就把冲突消掉了 ⇒ ★ 它永远判不出第三条,假话数只剩前两条抓到的那些。
| 300 轮 | 档 0 | 档 1 超范围 | 档 2 全是吃 | 档 3 大量 X == Y |
档 5 |
|---|---|---|---|---|---|
| 「先合并再判」被抓 | 234 | 12 | 196 | 88 | 159 |
| 300 轮 | 档 0 | ★ 档 1 超范围 | 档 2 | 档 3 | ★ 档 5 |
|---|---|---|---|---|---|
| 输入里真的有超范围的话 | ★ 0 | 300 | ★ 0 | ★ 0 | 284 |
| ⇒ 被抓 | ★ 0 | ★ 300 | ★ 0 | ★ 0 | ★ 284 |
★★ 五个档一个不差 —— ⚠ 而顺手写的生成器 X 老老实实取 1..n ⇒ 三个档是结构性的 0。
4⚠ 三条边只连一条
// P2024 · 错法 ③:三条边只连了一条//// 2 X Y ⇒ union(X+n, Y)、union(X+2n, Y+n)、union(X, Y+2n) ← 正解,三条都要// 2 X Y ⇒ union(X+n, Y) ← 这一版//// ⚠ 少了后两条,「环形食物链」就没绕成一圈 ——// 于是很多本该冲突的话没被判成假话(★ 它的假话数恒 ≤ 正解)。#include <bits/stdc++.h>using namespace std;
static const int N = 50005;static int fa[3 * N];
static int find(int x) { int r = x; while (fa[r] != r) r = fa[r]; while (fa[x] != r) { int nx = fa[x]; fa[x] = r; x = nx; } return r;}static void unite(int a, int b) { fa[find(a)] = find(b); }static bool same(int a, int b) { return find(a) == find(b); }
int main() { ios::sync_with_stdio(false); cin.tie(nullptr); long long n, k; if (!(cin >> n >> k)) return 0; for (long long i = 1; i <= 3 * n; i++) fa[i] = (int)i; long long lies = 0; for (long long i = 0; i < k; i++) { long long d, x, y; cin >> d >> x >> y; if (x > n || y > n || x < 1 || y < 1) { lies++; continue; } // ① 超范围 if (d == 2 && x == y) { lies++; continue; } // ② 自己吃自己 int X = (int)x, Y = (int)y, nn = (int)n; if (d == 1) { if (same(X, Y + nn) || same(X, Y + 2 * nn)) { lies++; continue; } // ③ 冲突 unite(X, Y); unite(X + nn, Y + nn); unite(X + 2 * nn, Y + 2 * nn); } else { if (same(X, Y) || same(X, Y + nn)) { lies++; continue; } // ③ 冲突 unite(X + nn, Y); // ✗ 只连了一条 } } cout << lies << '\n'; return 0;}点「运行 ▶」看结果
少了后两条,「环形食物链」就没绕成一圈 ⇒ 很多本该冲突的话没被判成假话。 四个档 140 / 9 / 182 / 35 / 68 —— 它恒 ≤ 正解(漏判)。
5★★ 题面那半行 |X|, |Y| < 2³² —— 比 int 大一倍
// P2024 · 错法 ⑤:X、Y 用 int 读//// ⚠⚠ 题面那一行很容易滑过去:**|X|, |Y| < 2³²** —— 它比 int 的范围大一倍。// 一旦真给出一个 ≥ 2³¹ 的 X,`cin >> int` 会**进 fail 状态**(并把值置 0),// 于是**这一句之后的所有输入全部读不进来**,剩下的话被当成 `0 0 0` 一路算成假话。// ⚠ 而顺手写的生成器只造 1..n 的 X ⇒ 这一档是**结构性的精确 0**(本页第 ⑤ 步)。#include <bits/stdc++.h>using namespace std;
static const int N = 50005;static int fa[3 * N];
static int find(int x) { int r = x; while (fa[r] != r) r = fa[r]; while (fa[x] != r) { int nx = fa[x]; fa[x] = r; x = nx; } return r;}static void unite(int a, int b) { fa[find(a)] = find(b); }static bool same(int a, int b) { return find(a) == find(b); }
int main() { ios::sync_with_stdio(false); cin.tie(nullptr); long long n, k; if (!(cin >> n >> k)) return 0; for (long long i = 1; i <= 3 * n; i++) fa[i] = (int)i; long long lies = 0; for (long long i = 0; i < k; i++) { int d = 0, x = 0, y = 0; // ✗ 装不下题面允许的 X cin >> d >> x >> y; if (x > n || y > n || x < 1 || y < 1) { lies++; continue; } // ① 超范围 if (d == 2 && x == y) { lies++; continue; } // ② 自己吃自己 int X = (int)x, Y = (int)y, nn = (int)n; if (d == 1) { if (same(X, Y + nn) || same(X, Y + 2 * nn)) { lies++; continue; } // ③ 冲突 unite(X, Y); unite(X + nn, Y + nn); unite(X + 2 * nn, Y + 2 * nn); } else { if (same(X, Y) || same(X, Y + nn)) { lies++; continue; } // ③ 冲突 unite(X + nn, Y); unite(X + 2 * nn, Y + nn); unite(X, Y + 2 * nn); } } cout << lies << '\n'; return 0;}点「运行 ▶」看结果
cin >> int 遇到 3000000000 会进 fail 状态(并把值置 0),
于是这一句之后的所有输入全部读不进来 —— 剩下的话被当成 0 0 0 一路算成假话。
| 300 轮 | 档 0 | 档 1 | 档 2 | 档 3 | ★★ 档 5 X ≥ 2³¹ |
|---|---|---|---|---|---|
输入里真的有 X ≥ 2³¹ |
★ 0 | ★ 0 | ★ 0 | ★ 0 | 284 |
| ⇒ 被抓 | ★ 0 | ★ 0 | ★ 0 | ★ 0 | 276 |
★ 两层差 8 轮(284 vs 276):有那么几轮,剩下的话本来就全是假话,读没读进来结果一样。 ⚠ 而四个档的 0 全是结构性的 —— 顺手写的生成器不会去造一个题面明明允许的值。 ⇒ 又一次「照题面随机」会精确地漏掉题面自己特意写下的那一行。
6★ 另一条正确的路:带权并查集(到根的距离模 3)
// P2024 · 另一条正确的路:**带权并查集**(到根的距离模 3)//// ★ 不翻倍,只给每个点记一个 `d[i]` = 「i 相对于它的**根**是什么关系」:// 0 = 同类 1 = i 吃根 2 = 根吃 i (模 3 的一个偏移量)// 两个点的关系就是 (d[x] − d[y]) mod 3。//// 路径压缩时把偏移量一路累加:`d[x] = (d[x] + d[fa[x]]) % 3`;// 合并 x、y 且要求「x 相对 y 是 rel」时,新边的权是 `(rel + d[y] − d[x] + 3) % 3`。//// ⇒ 它和三倍点那版**一行代码都不共享**(一个靠「多开点」,一个靠「记偏移」),// 可答案必须一样 —— 拿它当参照物(本页第 ⑥ 步)。// ★ 这两条路的关系值得说破:**三倍点就是把「模 3 的余数」摊成了三份点**。#include <bits/stdc++.h>using namespace std;
static const int N = 50005;static int fa[N], d[N];
/** 迭代两趟:先找根,再回头把这一路的偏移量累加起来 */static int find(int x) { int r = x; while (fa[r] != r) r = fa[r]; int cur = x, acc = 0; /* 第一趟先把 x 到根的总偏移算出来 */ while (cur != r) { acc = (acc + d[cur]) % 3; cur = fa[cur]; } /* 第二趟把路上每个点直接挂到根,并写上它自己到根的偏移 */ cur = x; int rest = acc; while (cur != r) { int nx = fa[cur], nd = (rest - d[cur] + 3) % 3; fa[cur] = r; d[cur] = rest; rest = nd; cur = nx; } return r;}
int main() { ios::sync_with_stdio(false); cin.tie(nullptr); long long n, k; if (!(cin >> n >> k)) return 0; for (long long i = 1; i <= n; i++) { fa[i] = (int)i; d[i] = 0; } long long lies = 0; for (long long i = 0; i < k; i++) { long long op, x, y; cin >> op >> x >> y; if (x > n || y > n || x < 1 || y < 1) { lies++; continue; } if (op == 2 && x == y) { lies++; continue; } int X = (int)x, Y = (int)y, rel = (op == 1) ? 0 : 1; // rel = X 相对 Y 的关系 int rx = find(X), ry = find(Y); if (rx == ry) { if (((d[X] - d[Y]) % 3 + 3) % 3 != rel) lies++; } else { fa[rx] = ry; d[rx] = ((rel + d[Y] - d[X]) % 3 + 3) % 3; } } cout << lies << '\n'; return 0;}点「运行 ▶」看结果
带权那版给每个点记 d[i] = 「i 相对它的根是什么关系」(0 同类 / 1 吃根 / 2 被根吃),
两点的关系就是 (d[x] − d[y]) mod 3。
顶格 n = 5×10⁴ / k = 10⁵ |
开几个位置 | 秒表 |
|---|---|---|
| 三倍点 | 150 000 个 int | 7 ms |
| 带权 | ★ 50 000 个 int(+ 5 万个偏移) | 6 ms |
★ 两条路 1500 轮 + 顶格答案逐个相同(顶格都是 33 847 句假话)
⇒ 拿它当参照物正合适。
★★ 而它把三倍点说破了:i、i+n、i+2n 三份点,就是 d[i] 的三个取值。
⇒ 翻倍 / 翻三倍,本质是把一个「模 k 的标签」摊进并查集自己。
7★ 对拍这一页
300 轮(n 随机 5~10) |
档 0 | ★ 档 1 超范围 | 档 2 全是吃 | ★ 档 3 大量 X == Y |
★★ 档 5 X ≥ 2³¹ |
|---|---|---|---|---|---|
| 带权并查集 | ★ 0 | ★ 0 | ★ 0 | ★ 0 | ★ 0 |
| ⚠ 忘了「X 吃 X」 | ★ 0 | ★ 0 | ★ 0 | ★ 0 | ★ 0 |
| 忘了范围判据 | ★ 0 | 300 | ★ 0 | ★ 0 | 284 |
| 三条边只连一条 | 140 | 9 | 182 | 35 | 68 |
| 先合并再判冲突 | 234 | 12 | 196 | 88 | 159 |
X、Y 用 int 读 |
★ 0 | ★ 0 | ★ 0 | ★ 0 | 276 |
| 错法 | 官方样例挡住了吗 | 为什么 |
|---|---|---|
| 忘了范围判据 | ★ 挡住 | 第一句 1 101 1 就是超范围的 |
| 先合并再判冲突 | ★ 挡住(打出 4) | 样例里有一句真正的冲突 |
| 三条边只连一条 | ⚠ 放过 | 那七句话没绕到需要第二、三条边的地方 |
X 用 int 读 |
⚠ 放过 | 最大的 X 才 101 |
| 忘了「X 吃 X」 | —— | ★ 它根本不是 bug(第 ② 步) |
★ 这组样例质量很高:七句话把题面三条判据都问了一遍。
8度量程序和生成器
9一页纸
| ★★ 关键的一步 | 翻三倍(同类 / 猎物 / 天敌)—— ⇒ 翻几倍取决于关系模几 |
| ★★★ 草稿被打回 | 「忘了 X 吃 X」根本不是 bug:same(X, X) 恒真,冲突判据自己覆盖了它(1500 轮 0 次,配了自检) |
| ⚠ 真正会咬人的 | 判据的顺序 —— 必须先判后并,先并后判永远判不出冲突(234 / 300) |
| ★★ 范围判据 | 触发 ≡ 抓获,五个档一个不差;⚠ 而顺手生成器让三个档变成结构性的 0 |
| ★★ 题面那半行绝对值范围 | < 2³²,比 int 大一倍 —— cin >> int 会进 fail 状态、后面全读不进来(档 5 抓 276) |
| ★ 第二条正确的路 | 带权并查集(模 3 偏移),开 5 万个位置而不是 15 万;★ 它把三倍点说破了 |