0题目原文(本地存了一份)
原题在洛谷上(页头有链接)。别人的网站不归我们管,打不开、改版、题号调整都可能发生。 所以每个解析页都把题面转录一份存在本地,跟着仓库一起进版本库。
转录自洛谷 P1892,日期见页头。两边不一致时信原站。
题目描述
现在有 n 个人,他们之间有可能两种关系:朋友和敌人。当然,他们可能没见过面,所以没有直接关系。
我们只知道:
- 一个人的朋友的朋友是朋友
- 一个人的敌人的敌人是朋友
现在要对这些人进行组团。如果两个人是朋友,那么这两个人一定在同一个团体中, 并且一个人只能加入一个团体。请求出这些人中最多可能有的团体数。
输入格式
第一行输入一个整数 n 代表人数。第二行输入一个整数 m 表示接下来要列出 m 个关系。
接下来 m 行,每行一个字符 opt 和两个整数 p, q。
opt 为 F 表示 p 和 q 是朋友;opt 为 E 表示 p 和 q 是敌人。
输出格式
一行一个整数代表最多的团体数。
说明/提示
对于本题的测试数据,并非每两个人之间都有关系,或者说,我们可能不知道某两个人之间的关系。
对于 100% 的数据,2 ≤ n ≤ 1000,1 ≤ m ≤ 5000,1 ≤ p, q ≤ n。
时限 1 秒,内存 128 MB。
输入输出样例
输入
6 4 E 1 4 F 3 5 F 4 6 E 1 2
输出
3
E 1 4、E 1 2 ⇒ 2 和 4 是「1 的两个敌人」⇒ 他俩是朋友;再加 F 4 6 ⇒ {2, 4, 6} 一团。
F 3 5 ⇒ {3, 5} 一团。剩下 {1} 自己一团。⇒ 3 个团体。
1★★ 关键的一步:把点数翻倍
// P1892 [BalticOI 2003] 团伙 —— ★ 这一版就能 AC//// ★★ 关键的一步:**把点数翻倍**。第 i 号点开两个位置:// i = 「i 本人」所在的朋友团// i + n = 「i 的敌人」所在的朋友团(一个**虚**的位置,代表「和 i 敌对的那一派」)//// F p q(朋友)⇒ union(p, q) 且 union(p+n, q+n) —— 他俩同派,他俩的敌人也同派// E p q(敌人)⇒ union(p, q+n) 且 union(p+n, q) —— p 属于「q 的敌人」那一派,反之亦然//// 「敌人的敌人是朋友」不用另外写:p 和 q 是敌人、q 和 r 是敌人// ⇒ p 和 r 都被并进了 q+n 那一派,自然成了朋友。//// ⚠⚠ 最后数团体数有一个坑,本页作者当场踩了:**不能写 `if (find(i) == i) ans++`** ——// 一个团体的**根完全可能落在虚点上**(i + n 那一半),那时前 n 个位置里一个「自己是根」的都没有。// ⇒ 正确的数法是:把 find(1..n) 的结果扔进一个集合,数**有几个不同的根**(本页第 ③ 步)。#include <bits/stdc++.h>using namespace std;
static const int N = 2005; // 2nstatic int fa[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); }
int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int n, m; if (!(cin >> n >> m)) return 0; for (int i = 1; i <= 2 * n; i++) fa[i] = i; for (int i = 0; i < m; i++) { char op; int p, q; cin >> op >> p >> q; if (op == 'F') { unite(p, q); unite(p + n, q + n); } else { unite(p, q + n); unite(p + n, q); } } set<int> roots; for (int i = 1; i <= n; i++) roots.insert(find(i)); // ★ 数「有几个不同的根」 cout << roots.size() << '\n'; return 0;}点「运行 ▶」看结果
| 位置 | 含义 |
|---|---|
i |
「i 本人」所在的朋友团 |
i + n |
★ 「和 i 敌对的那一派」—— 一个虚的位置,它不是人 |
F p q ⇒ union(p, q) 且 union(p+n, q+n) 他俩同派,他俩的敌人也同派
E p q ⇒ union(p, q+n) 且 union(p+n, q) p 属于「q 的敌人」那一派,反之亦然★ 「敌人的敌人是朋友」不用另外写一行 —— p 和 q 敌对、q 和 r 敌对,
那么 p、r 都被并进了 q + n 那一派,自动成了朋友。
⇒ 这就是「翻倍」的全部价值:把一条本来要推导的规则,变成了并查集自己会做的事。
2⚠⚠ 而数团体数有一个坑,本页作者当场踩了
// P1892 · 错法 ①:数团体数时写成 `if (find(i) == i) ans++`//// ⚠⚠ **本页作者第一版就是这么写的,官方样例当场打出 2(正确答案 3)。**// ★ 原因说得清:一个团体的**根完全可能落在虚点上**(`i + n` 那一半)——// 扩展域并查集里,`unite(p, q + n)` 会让真点挂到虚点下面。// 那时前 n 个位置里**一个「自己是根」的都没有**,这个团体就被漏数了。// ⇒ 它总是**少数**,答案恒 ≤ 正解(本页第 ③ 步量了这条)。#include <bits/stdc++.h>using namespace std;
static const int N = 2005; // 2nstatic int fa[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); }
int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int n, m; if (!(cin >> n >> m)) return 0; for (int i = 1; i <= 2 * n; i++) fa[i] = i; for (int i = 0; i < m; i++) { char op; int p, q; cin >> op >> p >> q; if (op == 'F') { unite(p, q); unite(p + n, q + n); } else { unite(p, q + n); unite(p + n, q); } } int ans = 0; for (int i = 1; i <= n; i++) if (find(i) == i) ans++; // ✗ 根可能落在虚点上 cout << ans << '\n'; return 0;}点「运行 ▶」看结果
数「有几个团体」最顺手的写法是 for i in 1..n: if (find(i) == i) ans++ ——
在普通并查集里这是对的,在扩展域里不是。
unite(p, q + n) 会让真点挂到虚点下面。于是一个团体的根可能是 i + n 那一半里的某个位置,
这时前 n 个位置里一个「自己是根」的都没有,整个团体就被漏数了。
★ 正确的数法:把 find(1..n) 的结果扔进一个集合,数有几个不同的根。
| 300 轮 | 档 0 | ★ 档 1 全是 F | 档 2 全是 E | 档 3 敌对链 |
|---|---|---|---|---|
| 至少有一个团体的根落在虚点上 | 252 | ★ 0 | 264 | 300 |
⇒ 「find(i) == i」被抓 |
★ 252 | ★ 0 | ★ 264 | ★ 300 |
★★ 四个档一个不差 —— 而档 1(全是 F)那个 0 也能证:
一条敌对关系都没有 ⇒ unite 从来没跨过 n 那条线 ⇒ 根不可能落在虚点上。
★ 这一版恒 ≥ 正解(把虚点也当成人了);上一版恒 ≤ 正解(漏数)。 ⇒ 又一次「答案偏大还是偏小,不用跑就能判」。
3⚠ 敌对关系只连一半:不是每组都错
// P1892 · 错法 ③:敌对关系只连了一半//// E p q ⇒ union(p, q + n) 且 union(p + n, q) ← 正解,两条都要// E p q ⇒ union(p, q + n) ← 这一版只连一条//// ⚠ 「敌人的敌人是朋友」靠的正是那两条一起:p 和 r 都并进 q+n 才成为朋友。// ★ 而它并不是每组都错(本页第 ④ 步量了触发条件)。#include <bits/stdc++.h>using namespace std;
static const int N = 2005; // 2nstatic int fa[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); }
int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int n, m; if (!(cin >> n >> m)) return 0; for (int i = 1; i <= 2 * n; i++) fa[i] = i; for (int i = 0; i < m; i++) { char op; int p, q; cin >> op >> p >> q; if (op == 'F') { unite(p, q); unite(p + n, q + n); } else { unite(p, q + n); } // ✗ 少了一半 } set<int> roots; for (int i = 1; i <= n; i++) roots.insert(find(i)); // ★ 数「有几个不同的根」 cout << roots.size() << '\n'; return 0;}点「运行 ▶」看结果
| 300 轮 | 档 0 | ★ 档 1 全是 F | 档 2 全是 E | ★ 档 3 敌对链 |
|---|---|---|---|---|
| 某个人有两个不同的敌人 | 167 | ★ 0 | 282 | 300 |
| ⇒ 真被抓 | 148 | ★ 0 | 246 | ★ 300 |
★ 「敌人的敌人是朋友」靠的正是那两条一起 ⇒ 第一层写得很准(167 vs 148,1.13 倍), 而敌对链那一档 300 ≡ 300 —— 那一档每个中间人都恰好有两个敌人。
4★★★ 另一条正确的路:不翻倍,给每一伙记一个「已知的敌人」
// P1892 · 另一条正确的路:**不翻倍**,给每个人记一个「已知的敌人」//// ★ 只开 n 个位置的普通并查集,外加一个数组 `foe[i]`:// E p q ⇒ 若 p 已经记着一个敌人 e,那么 q 和 e 是「同一个人的两个敌人」⇒ **他俩是朋友**,// union(q, e);否则就把 q 记成 p 的敌人。对 q 同样做一遍。// F p q ⇒ union(p, q)//// ⚠⚠ 有一个非做不可的细节:**`foe[]` 挂在「集合的根」上,而且合并时要一起并** ——// 按**点**存会一声不吭地漏掉合并(p1892Node.cpp 就是那一版,本页第 ⑤ 步)。//// ⇒ 它和扩展域那版**一行代码都不共享**,可答案必须一样 —— 拿它当参照物(本页第 ⑥ 步)。// ★ 而它顺带说清了扩展域到底在干什么:`i + n` 那个虚点,就是这里的「这一伙的敌人」那一派。#include <bits/stdc++.h>using namespace std;
static const int N = 1005;static int fa[N], foe[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;}/** ★ 合并时把两边记着的「敌人代表」也并起来 —— foe 是**集合**的属性,不是点的 */static void unite(int a, int b) { int x = find(a), y = find(b); if (x == y) return; fa[x] = y; if (foe[x] && foe[y]) unite(foe[x], foe[y]); else if (foe[x]) foe[y] = foe[x];}
int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int n, m; if (!(cin >> n >> m)) return 0; for (int i = 1; i <= n; i++) { fa[i] = i; foe[i] = 0; } for (int i = 0; i < m; i++) { char op; int p, q; cin >> op >> p >> q; if (op == 'F') { unite(p, q); continue; } int rp = find(p); if (foe[rp]) unite(q, foe[rp]); else foe[rp] = q; int rq = find(q); if (foe[rq]) unite(p, foe[rq]); else foe[rq] = p; } set<int> roots; for (int i = 1; i <= n; i++) roots.insert(find(i)); cout << roots.size() << '\n'; return 0;}点「运行 ▶」看结果
E p q ⇒ 若这一伙已经记着一个敌人 e,那么 q 和 e 是「同一伙的两个敌人」⇒ union(q, e);
否则就把 q 记成这一伙的敌人。对 q 那边同样做一遍。
F p q ⇒ union(p, q)
★★ 它和扩展域那版一行代码都不共享,可两边答案必须一样 ⇒ 拿它当参照物。
★ 而它顺带把扩展域说破了:i + n 那个虚点,就是这里的「这一伙的敌人」那一派 ——
翻倍不过是把 foe[] 摊进了并查集自己。
顶格 n = 1000 / m = 5000 |
开几个位置 | 秒表 |
|---|---|---|
| 扩展域 | 2n = 2000 | 0.11 ms |
| 记一个敌人 | ★ n = 1000 | 0.10 ms |
⇒ 两条路都快得测不出差别,选哪条只看你觉得哪条更说得清。
5★★★ 而「记一个敌人」有一个极有迷惑性的写法 —— 三个极端档一起漏掉了它
// P1892 · 错法 ⑤:「不翻倍、记一个敌人」—— 但 foe 按**点**存,不是按**根**存//// ⚠⚠ **这一版是写这一页时当场踩的坑**,而且它极有迷惑性:// 「记一个已知敌人」这个技巧本身是对的(见 p1892Two.cpp),// 可 `foe[]` 必须挂在**集合的根**上、并且在合并时一起并 ——// 按**点**存的话,p 后来和别人合并成一伙之后,那一伙的敌人信息就散在几个成员身上,// 下一次 `foe[p]` 是空的,于是**该合并的没合并**。//// ★★★ 而这一页最值钱的一条就在这个 bug 上(本页第 ⑤ 步):// **三个精心设计的极端档(全 F / 全 E / 敌对链)全都是精确的 0,// 抓到它的偏偏是那个最朴素的「F 和 E 随机混着来」的默认档。**#include <bits/stdc++.h>using namespace std;
static const int N = 1005;static int fa[N], foe[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); }
int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int n, m; if (!(cin >> n >> m)) return 0; for (int i = 1; i <= n; i++) { fa[i] = i; foe[i] = 0; } for (int i = 0; i < m; i++) { char op; int p, q; cin >> op >> p >> q; if (op == 'F') { unite(p, q); continue; } if (foe[p]) unite(q, foe[p]); else foe[p] = q; if (foe[q]) unite(p, foe[q]); else foe[q] = p; } set<int> roots; for (int i = 1; i <= n; i++) roots.insert(find(i)); cout << roots.size() << '\n'; return 0;}点「运行 ▶」看结果
if (foe[p]) unite(q, foe[p]); else foe[p] = q; // ✗ foe 挂在**点**上
int rp = find(p); if (foe[rp]) … // ★ 正解:挂在**根**上,合并时一起并p 后来和别人合并成一伙之后,这一伙的敌人信息散在几个成员身上;
下一次查 foe[p] 是空的 ⇒ 该合并的没合并。
| 300 轮 | 档 0 F / E 混着来 | ★ 档 1 全是 F | ★ 档 2 全是 E | ★ 档 3 敌对链 |
|---|---|---|---|---|
| F 和 E 同时出现(第一层) | 266 | ★ 0 | ★ 0 | ★ 0 |
| ⇒ 真被抓 | ★ 61 | ★ 0 | ★ 0 | ★ 0 |
三个 0 都能证:它要先有一次 F 把两个人并成一伙,再有一次 E 去查那一伙的敌人 ——
全 F 没有第二步,全 E 和敌对链没有第一步。
⇒ ★★★ 这一章里同样的形状出现了两次(另一次是隔壁 P1551 的「合并没先找根」,
链式 / 星形 / 空关系三个档全 0、随机档抓 135):
「造一个极端档去测边界」有系统性的盲区 —— 极端档往往结构太规整,反而把 bug 喂对了。
⇒ ★★ 随机那一档不是凑数的默认值,有时它是唯一抓得到的那一档。
★ 而官方样例也放过了它(那四行里 F 和 E 涉及的人不重叠)——
⇒ 这是四个错法里唯一样例没挡住的。
6★ 对拍这一页
参照物是照定义反复推到不动为止(p1892Brute.cpp):
F 直接合并;a、b 敌对且 c、d 敌对而 a、c 已同伙 ⇒ 合并 b、d。反复扫到没有变化。
300 轮(n 随机 5~10) |
档 0 混合 | ★ 档 1 全是 F | 档 2 全是 E | ★ 档 3 敌对链 |
|---|---|---|---|---|
| 扩展域(正解) | ★ 0 | ★ 0 | ★ 0 | ★ 0 |
| 记一个敌人(按根存) | ★ 0 | ★ 0 | ★ 0 | ★ 0 |
find(i) == i 数团体 |
252 | ★ 0 | 264 | 300 |
| 把 2n 个位置全数了 | 261 | 300 | 249 | 198 |
| 敌对只连一半 | 148 | ★ 0 | 246 | 300 |
| 把敌人直接合并 | 167 | ★ 0 | 197 | 300 |
| ⚠ 记一个敌人(按点存) | ★ 61 | ★ 0 | ★ 0 | ★ 0 |
7度量程序和生成器
8一页纸
| ★★ 关键的一步 | 翻倍:i + n = 「和 i 敌对的那一派」⇒ 「敌人的敌人是朋友」不用另外写 |
| ⚠⚠ 作者当场踩的 | find(i) == i 数团体是错的 —— 根可能落在虚点上(触发 ≡ 抓获,四档全中) |
| ★ 两个方向 | 漏数那版恒 ≤ 正解,数了 2n 那版恒 ≥ 正解 |
| ★★ 第二条正确的路 | 不翻倍、给每一伙记一个敌人 —— ★ 它把扩展域说破了:虚点就是那个 foe |
| ★★★ 而它有个极像的错法 | foe 按点存而不按根存 —— 官方样例放过、三个极端档全是能证的 0 |
| ⇒ 一条能带走的规矩 | 极端档往往结构太规整,反而把 bug 喂对了;这一章两道题各撞一次 |
| ★ 只连一半 | 第一层「某人有两个敌人」167 / 抓 148(1.13 倍),敌对链那档 300 ≡ 300 |