题单 · 习题解析

洛谷 P1892 [BalticOI 2003] 团伙

★★ 关键的一步是**翻倍**:`i + n` = 「和 i 敌对的那一派」⇒ 「敌人的敌人是朋友」**一行都不用另外写**;⚠⚠ 而数团体数时**本页作者当场踩了一个** —— `if (find(i) == i) ans++` 是错的,**团体的根完全可能落在虚点上**(触发 ≡ 抓获,四档一个不差);★★ 第二条正确的路是**不翻倍、给每一伙记一个敌人**,它顺带把扩展域说破了(虚点就是那个 `foe`);★★★ 而它有一个极像的错法:`foe` 按**点**存而不按**根**存 —— 官方样例放过、而且**三个精心设计的极端档(全 F / 全 E / 敌对链)全是能证的 0,只有最朴素的随机档抓到 61** ⇒ **极端档往往结构太规整,反而把 bug 喂对了**(同一章的 [P1551](/sol/p1551/) 是同一个形状)

原题:洛谷 P1892出自 第 36 章 并查集 的题单题面本地存档:2026-08-31
⚠ 先自己写一遍,再往下看

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

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

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

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

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

题目描述

现在有 n 个人,他们之间有可能两种关系:朋友和敌人。当然,他们可能没见过面,所以没有直接关系。 我们知道:

  • 一个人的朋友的朋友是朋友
  • 一个人的敌人的敌人是朋友

现在要对这些人进行组团。如果两个人是朋友,那么这两个人一定在同一个团体中, 并且一个人只能加入一个团体。请求出这些人中最多可能有的团体数。

输入格式

第一行输入一个整数 n 代表人数。第二行输入一个整数 m 表示接下来要列出 m 个关系。

接下来 m 行,每行一个字符 opt 和两个整数 p, qoptF 表示 pq 是朋友;optE 表示 pq 是敌人。

输出格式

一行一个整数代表最多的团体数。

说明/提示

对于本题的测试数据,并非每两个人之间都有关系,或者说,我们可能不知道某两个人之间的关系。

对于 100% 的数据,2 ≤ n ≤ 10001 ≤ m ≤ 50001 ≤ p, q ≤ n

时限 1 秒,内存 128 MB。

输入输出样例

输入

6
4
E 1 4
F 3 5
F 4 6
E 1 2

输出

3

E 1 4E 1 2 ⇒ 2 和 4 是「1 的两个敌人」⇒ 他俩是朋友;再加 F 4 6{2, 4, 6} 一团。 F 3 5{3, 5} 一团。剩下 {1} 自己一团。⇒ 3 个团体

1★★ 关键的一步:把点数翻倍

p1892.cpp★ 这一版就能 AC(顶格 n = 1000 / m = 5000,本机 0.11 毫秒)
// 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; // 2n
static 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
★★ 第 i 号点开两个位置,后一个是「虚」的
位置 含义
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 的敌人」那一派,反之亦然

★ 「敌人的敌人是朋友」不用另外写一行 —— pq 敌对、qr 敌对, 那么 pr 都被并进了 q + n 那一派,自动成了朋友。 ⇒ 这就是「翻倍」的全部价值:把一条本来要推导的规则,变成了并查集自己会做的事。

2⚠⚠ 而数团体数有一个坑,本页作者当场踩了

p1892Self.cpp✗ 写成 if (find(i) == i) ans++(官方样例打出 2,正解 3)
// 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; // 2n
static 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
★★★ 团体的根「完全可能落在虚点上」

数「有几个团体」最顺手的写法是 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 那条线 ⇒ 根不可能落在虚点上。

p1892All.cpp✗ 反过来:把 2n 个位置全数了(样例打出 4)

★ 这一版恒 ≥ 正解(把虚点也当成人了);上一版恒 ≤ 正解(漏数)。 ⇒ 又一次「答案偏大还是偏小,不用跑就能判」

3⚠ 敌对关系只连一半:不是每组都错

p1892Half.cpp✗ E 只连了 union(p, q+n),漏掉 union(p+n, q)(样例打出 4)
// 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; // 2n
static 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
300 轮 档 0 ★ 档 1 全是 F 档 2 全是 E ★ 档 3 敌对链
某个人有两个不同的敌人 167 0 282 300
⇒ 真被抓 148 0 246 300

★ 「敌人的敌人是朋友」靠的正是那两条一起 ⇒ 第一层写得很准(167 vs 148,1.13 倍), 而敌对链那一档 300 ≡ 300 —— 那一档每个中间人都恰好有两个敌人。

p1892Enemy.cpp✗ 干脆把敌人直接合并(样例打出 2)—— 没想明白为什么要翻倍

4★★★ 另一条正确的路:不翻倍,给每一伙记一个「已知的敌人」

p1892Two.cpp★ 只开 n 个位置,foe[] 挂在集合的根上
// 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
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★★★ 而「记一个敌人」有一个极有迷惑性的写法 —— 三个极端档一起漏掉了它

p1892Node.cpp✗ foe 按「点」存(⚠ 官方样例照样打出 3)
// 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;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
★★★ 三个精心设计的极端档全是精确的 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 喂对了。 ⇒ ★★ 随机那一档不是凑数的默认值,有时它是唯一抓得到的那一档。 ★ 而官方样例也放过了它(那四行里 FE 涉及的人不重叠)—— ⇒ 这是四个错法里唯一样例没挡住的。

6★ 对拍这一页

参照物是照定义反复推到不动为止p1892Brute.cpp): F 直接合并;ab 敌对且 cd 敌对而 ac 已同伙 ⇒ 合并 bd。反复扫到没有变化。

p1892Brute.cpp参照物:照定义推到不动为止(不用任何技巧)
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度量程序和生成器

p1892Count.cpp度量程序(本页所有数字都出自它)
p1892Gen.cpp(五个档位)数据生成器

8一页纸

★★ 关键的一步 翻倍:i + n = 「和 i 敌对的那一派」⇒ 「敌人的敌人是朋友」不用另外写
⚠⚠ 作者当场踩的 find(i) == i 数团体是错的 —— 根可能落在虚点上(触发 ≡ 抓获,四档全中)
★ 两个方向 漏数那版恒 ≤ 正解,数了 2n 那版恒 ≥ 正解
★★ 第二条正确的路 不翻倍、给每一伙记一个敌人 —— ★ 它把扩展域说破了:虚点就是那个 foe
★★★ 而它有个极像的错法 foe存而不按存 —— 官方样例放过、三个极端档全是能证的 0
⇒ 一条能带走的规矩 极端档往往结构太规整,反而把 bug 喂对了;这一章两道题各撞一次
★ 只连一半 第一层「某人有两个敌人」167 / 抓 148(1.13 倍),敌对链那档 300 ≡ 300