第一次接触并查集是在解决图论中的连通性问题时。当时我需要处理一个社交网络中的好友关系,发现传统的深度优先搜索虽然能解决问题,但在处理动态关系时效率太低。这时一位前辈推荐我学习并查集(Disjoint Set Union,DSU)这个数据结构,从此打开了新世界的大门。
并查集的核心思想其实非常贴近现实生活。想象你在整理一堆杂乱无章的书籍,开始时每本书都是独立的个体(各自为一个集合)。当你发现两本书属于同一类别时,就把它们放在同一个书架上(合并集合)。之后想要查询某本书是否和另一本在同一类别,只需要看它们是否在同一个书架上即可。这种"动态归类"的能力正是并查集的精髓。
在本题P1892中,我们需要处理的是一个典型的团伙识别问题。题目描述了两个关键关系:"朋友"和"敌人"。朋友关系具有传递性(A与B是朋友,B与C是朋友,则A与C也是朋友),而敌人关系则具有对称性(A与B是敌人,则B与A也是敌人)和间接性(敌人的敌人是朋友)。这种复杂的关系网络正是并查集大显身手的场景。
提示:理解题目中的关系传递规则是解题的关键。在实际编码前,建议先用小规模测试案例手工模拟合并过程。
标准的并查集通常只需要两个核心数组:parent[]和rank[](或size[])。parent数组记录每个元素的父节点,rank数组则用于优化合并操作。在本题中,我们有n个人,编号从1到n,因此初始化如下:
cpp复制const int MAXN = 1005; // 根据题目n≤1000设定
int parent[MAXN];
int rank[MAXN];
void init(int n) {
for (int i = 1; i <= n; ++i) {
parent[i] = i; // 初始时每个人自成一个集合
rank[i] = 0; // 初始高度为0
}
}
这里有个细节需要注意:题目中人的编号是从1开始的,所以我们的数组也从1开始使用,避免浪费下标0的空间。虽然现代计算机内存充足,但这种细节处理能体现程序员的专业素养。
查找操作的目的是确定某个元素所属的集合代表(根节点)。朴素实现会一直向上查找直到根节点,但这样最坏情况下时间复杂度会退化到O(n)。路径压缩优化通过在查找过程中"扁平化"树结构,使得后续查询更快:
cpp复制int find(int x) {
if (parent[x] != x) {
parent[x] = find(parent[x]); // 路径压缩
}
return parent[x];
}
这个递归实现简洁优雅,但可能在大数据量时导致栈溢出。迭代版本虽然稍长,但更安全:
cpp复制int find(int x) {
int root = x;
while (parent[root] != root) {
root = parent[root];
}
// 路径压缩
while (x != root) {
int next = parent[x];
parent[x] = root;
x = next;
}
return root;
}
合并操作将两个集合合并为一个。按秩合并(union by rank)是一种常见的优化策略,它总是将较矮的树合并到较高的树下,从而保持树的平衡:
cpp复制void unite(int x, int y) {
x = find(x);
y = find(y);
if (x == y) return; // 已在同一集合
if (rank[x] < rank[y]) {
parent[x] = y;
} else {
parent[y] = x;
if (rank[x] == rank[y]) {
rank[x]++;
}
}
}
在实际应用中,我发现按秩合并和路径压缩配合使用,能使并查集的操作接近常数时间复杂度(反阿克曼函数)。这也是并查集如此高效的原因。
朋友关系的处理相对直接,因为朋友关系具有传递性,这正是并查集最擅长的场景。当输入"F p q"时,我们只需要简单合并p和q所在的集合:
cpp复制if (cmd == 'F') {
unite(p, q);
}
敌人关系的处理是本问题的难点。根据题意,敌人的敌人是朋友,这意味着我们需要记录每个人的敌人,并在遇到新的敌人关系时建立相应的朋友关系。
我的解决方案是使用一个enemy数组来记录每个人的一个敌人代表(不需要记录所有敌人,因为关系可以通过并查集传递):
cpp复制int enemy[MAXN] = {0}; // 初始时没有敌人
// 处理敌人关系
if (cmd == 'E') {
if (enemy[p] == 0) {
enemy[p] = q;
} else {
unite(q, enemy[p]); // q与p的敌人是朋友
}
if (enemy[q] == 0) {
enemy[q] = p;
} else {
unite(p, enemy[q]); // p与q的敌人是朋友
}
}
这种实现巧妙地利用了敌人的对称性。当p和q是敌人时:
最后统计团伙数量时,只需要统计有多少个不同的根节点即可:
cpp复制int countGroups(int n) {
unordered_set<int> groups;
for (int i = 1; i <= n; ++i) {
groups.insert(find(i));
}
return groups.size();
}
这里使用哈希集合来去重,时间复杂度是O(nα(n)),其中α是反阿克曼函数,效率非常高。
结合上述分析,完整的解决方案如下:
cpp复制#include <iostream>
#include <unordered_set>
using namespace std;
const int MAXN = 1005;
int parent[MAXN];
int rank[MAXN];
int enemy[MAXN] = {0};
void init(int n) {
for (int i = 1; i <= n; ++i) {
parent[i] = i;
rank[i] = 0;
}
}
int find(int x) {
if (parent[x] != x) {
parent[x] = find(parent[x]);
}
return parent[x];
}
void unite(int x, int y) {
x = find(x);
y = find(y);
if (x == y) return;
if (rank[x] < rank[y]) {
parent[x] = y;
} else {
parent[y] = x;
if (rank[x] == rank[y]) {
rank[x]++;
}
}
}
int main() {
int n, m;
cin >> n >> m;
init(n);
while (m--) {
char cmd;
int p, q;
cin >> cmd >> p >> q;
if (cmd == 'F') {
unite(p, q);
} else if (cmd == 'E') {
if (enemy[p] == 0) {
enemy[p] = q;
} else {
unite(q, enemy[p]);
}
if (enemy[q] == 0) {
enemy[q] = p;
} else {
unite(p, enemy[q]);
}
}
}
unordered_set<int> groups;
for (int i = 1; i <= n; ++i) {
groups.insert(find(i));
}
cout << groups.size() << endl;
return 0;
}
在实际编程竞赛中,为了节省编码时间,我通常会做以下优化:
对于大规模数据,C++的cin/cout可能较慢。可以添加以下优化:
cpp复制ios::sync_with_stdio(false);
cin.tie(nullptr);
或者使用scanf/printf来获得更好的性能。
验证代码正确性时,我通常会设计以下几类测试用例:
基础朋友关系:
code复制3 2
F 1 2
F 2 3
预期输出:1(所有人都在一个团伙)
基础敌人关系:
code复制4 2
E 1 2
E 3 4
预期输出:2(1和3一组,2和4一组,或者1和4一组,2和3一组)
混合关系:
code复制5 4
F 1 2
E 2 3
F 3 4
E 4 5
预期输出:1(所有人在一个团伙)
边界情况:
code复制1 0
预期输出:1(只有一个人)
在调试并查集问题时,我常用的方法包括:
注意:在竞赛中要记得删除调试输出,否则可能导致超时或输出错误。
通过解决这道题,我们可以总结出并查集处理复杂关系的通用模式:
在实际工程中,并查集常用于:
理解并查集的本质后,我发现很多看似复杂的问题都能转化为集合的合并与查询问题。这种抽象思维能力正是算法学习中最宝贵的收获。