All men are brothers - 题解
标签与难度
标签: 并查集, 组合数学, 计数问题, 动态维护, 数据结构 难度: 1900
题目大意喵~
你好呀,指挥官!Amy小姐姐又来出题考验我们啦,这次可不能被难倒喵~
题目是这样的:一开始有 个素不相识的人。接下来会发生 件事,每一件事都是两个人 和 成为了朋友。
这个“朋友关系”很特别哦,它有两个性质:
- 相互性: 如果 A 是 B 的朋友,那 B 也是 A 的朋友,喵~
- 传递性: 如果 A 是 B 的朋友,B 是 C 的朋友,那么 A 和 C 也会自动成为朋友!
这种关系会把所有 个人划分成一个个“朋友圈”。在同一个朋友圈里,任何两个人都是朋友。不同朋友圈的人则互相不认识。
我们的任务是,在游戏最开始时,以及每当有一对新朋友产生后,都要计算出:从所有人中选出 4 个人,并且这 4 个人两两之间都不是朋友,一共有多少种选法呢?
解题思路分析
喵哈哈,这个问题看起来有点复杂,但只要我们把它拆解开来,就会发现其中的奥秘啦!
Step 1: 朋友关系的模型化,喵!
首先,我们来分析一下这个“朋友关系”。它满足相互性(对称性)和传递性。这不就是数学中的等价关系嘛!等价关系会将一个集合划分成若干个不相交的子集,每个子集我们称之为一个等价类。
在这个问题里,每个“朋友圈”就是一个等价类。在同一个朋友圈里的人,彼此都是朋友。我们的任务就是要从 个人中选出 4 个,要求他们来自 4 个不同的朋友圈。
为了维护这些朋友圈(集合)的合并,最适合的工具就是并查集 (Disjoint Set Union, DSU) 啦!我们可以用并查集来追踪谁和谁在同一个朋友圈里,并且记录下每个朋友圈的大小。
parent[i]: 记录第i个人的“代表”(朋友圈的根节点)。size[i]: 如果i是一个根节点,size[i]就记录了这个朋友圈的总人数。
Step 2: 思考如何计数,呐~
我们的目标是:从所有朋友圈中,选出 4 个不同的朋友圈,然后从这 4 个朋友圈里各选一个人。
假设当前有 个朋友圈,它们的大小分别是 。 如果我们想选出 4 个人,分别来自第 这四个不同的朋友圈,那么选法就有 种。 所以,总的方案数就是把所有四个不同朋友圈组合的乘积加起来:
这个公式直接计算起来太麻烦了,特别是每次朋友圈合并后,大小 都会变化,重新计算会非常慢。我们得想个更聪明的办法,喵!
Step 3: 增量更新的魔法!
我们不从头计算,而是思考每次合并对答案有什么影响。
最开始,大家互不相识,有 个朋友圈,每个圈里只有 1 个人。选 4 个来自不同圈的人,就等于从 个人里随便选 4 个,方案数是 。这就是我们的初始答案。
现在,假设我们要合并两个朋友圈,设为圈 和圈 ,它们的大小分别是 和 。 这次合并,会让一些原本合法的“四人组”变得不合法。哪些四人组会失效呢?
一个四人组 {a, b, c, d} 原本是合法的,意味着 a, b, c, d 来自四个不同的朋友圈。如果它在合并后变得不合法,那一定是其中有两个人因为合并而成为了朋友。这只有一种可能:这四个人中,恰好有一个人来自圈 ,一个人来自圈 !
比如说,a 来自圈 ,b 来自圈 ,c 来自圈 ,d 来自圈 (其中 互不相同)。合并前,这四个人两两不是朋友,是合法的。合并后,圈 和圈 合并了,a 和 b 成了朋友,这个四人组就不合法了。
所以,答案的减少量,就等于这种失效的四人组的数量。我们来数一数:
- 从圈 中选一个人:有 种选法。
- 从圈 中选一个人:有 种选法。
- 从剩下的所有人中选两个人(设为
c和d),并且c和d互相不是朋友。
第 3 步是关键。怎么计算呢?我们可以用容斥原理(或者叫补集思想): (从剩下的人里选两个不是朋友的) = (从剩下的人里随便选两个) - (从剩下的人里选两个是朋友的)
- 剩下的人数是 。
- 从这 个人里随便选两个,有 种方法。
- 从这 个人里选两个是朋友的,意味着这两个人必须来自同一个朋友圈(当然这个朋友圈不能是 或 )。这个数量等于 。
所以,每次合并, 和 两个圈时,答案的减少量为:
这个 还是有点麻烦。但我们可以维护一个全局变量,就叫 pairs_in_same_set 吧,它表示当前所有“朋友对”的总数,即 。 那么, 就等于 pairs_in_same_set - 。
这样我们的思路就完整了!
初始化:
- 总答案
total_ways= 。 pairs_in_same_set= 0 (因为初始每个圈只有1人,)。- 并查集初始化,
n个圈,每个大小为 1。
- 总答案
每次合并操作 (u, v):
- 用并查集找到
u和v所在的圈的根节点root_u和root_v。 - 如果
root_u == root_v,他们早就是朋友了,什么都不用做,答案不变。 - 如果
root_u != root_v,设它们的大小为 和 。 a. 计算答案减少量: -removable_pairs = pairs_in_same_set - C(s_u, 2) - C(s_v, 2)。 -other_people_count = n - s_u - s_v。 -ways_to_pick_two_others = C(other_people_count, 2) - removable_pairs。 -delta_ways = s_u * s_v * ways_to_pick_two_others。 b. 更新总答案:total_ways -= delta_ways。 c. 更新pairs_in_same_set: -pairs_in_same_set -= C(s_u, 2)-pairs_in_same_set -= C(s_v, 2)-pairs_in_same_set += C(s_u + s_v, 2)d. 用并查集执行合并,更新圈的大小。
- 用并查集找到
输出: 每次操作后,输出当前的
total_ways。
这样,我们每次只需要做一些常数时间的计算,就能动态地维护答案了,是不是很高效呀,喵~
代码实现
下面就是我根据这个思路精心编写的代码啦,加了详细的注释,希望能帮到你,喵~
#include <iostream>
#include <vector>
#include <numeric>
// 使用 long long 来防止计算过程中溢出,这很重要哦!
using ll = long long;
// 并查集结构体,把相关的数据和操作封装在一起,更清晰喵
struct DSU {
std::vector<int> parent;
std::vector<ll> sz; // 每个集合的大小
// 初始化n个独立的集合
DSU(int n) {
parent.resize(n + 1);
// std::iota 可以快速填充 0, 1, 2, ...
std::iota(parent.begin(), parent.end(), 0);
sz.assign(n + 1, 1);
}
// 查找x所属集合的根节点(带路径压缩)
int find(int x) {
if (parent[x] == x) {
return x;
}
return parent[x] = find(parent[x]);
}
// 合并x和y所在的集合(按大小合并)
void unite(int x, int y) {
int rootX = find(x);
int rootY = find(y);
if (rootX != rootY) {
// 总是把小的集合合并到大的集合里,可以优化树的高度
if (sz[rootX] < sz[rootY]) {
std::swap(rootX, rootY);
}
parent[rootY] = rootX;
sz[rootX] += sz[rootY];
}
}
};
// 计算组合数 C(n, k) 的函数
ll combinations(ll n, int k) {
if (k < 0 || k > n) {
return 0;
}
if (k == 0 || k == n) {
return 1;
}
if (k > n / 2) {
k = n - k;
}
ll res = 1;
for (ll i = 1; i <= k; ++i) {
res = res * (n - i + 1) / i;
}
return res;
}
int main() {
// 加速输入输出,让程序跑得更快!
std::ios_base::sync_with_stdio(false);
std::cin.tie(NULL);
int n, m;
std::cin >> n >> m;
DSU dsu(n);
// total_ways: 最终答案,即选4个互不为朋友的人的方案数
ll total_ways = combinations(n, 4);
// pairs_in_same_set: 从同一个朋友圈里选2个人的总方案数
// 即 Sum(C(size_i, 2)) for all sets i
ll pairs_in_same_set = 0;
// 输出初始答案
std::cout << total_ways << "\n";
for (int i = 0; i < m; ++i) {
int u, v;
std::cin >> u >> v;
int root_u = dsu.find(u);
int root_v = dsu.find(v);
if (root_u != root_v) {
ll size_u = dsu.sz[root_u];
ll size_v = dsu.sz[root_v];
// 1. 计算答案减少量
// a. 先从 pairs_in_same_set 中移除旧的 u 和 v 所在集合的贡献
ll pairs_in_other_sets = pairs_in_same_set - combinations(size_u, 2) - combinations(size_v, 2);
// b. 剩下的人数
ll other_people_count = n - size_u - size_v;
// c. 从剩下的人中选2个互不为朋友的人的方案数
ll ways_to_pick_two_others = combinations(other_people_count, 2) - pairs_in_other_sets;
// d. 总的减少量
ll delta_ways = size_u * size_v * ways_to_pick_two_others;
total_ways -= delta_ways;
// 2. 更新 pairs_in_same_set
// 减去旧的贡献,加上新集合的贡献
pairs_in_same_set -= combinations(size_u, 2);
pairs_in_same_set -= combinations(size_v, 2);
pairs_in_same_set += combinations(size_u + size_v, 2);
// 3. 执行并查集合并
dsu.unite(u, v);
}
// 每次操作后都输出当前答案
std::cout << total_ways << "\n";
}
return 0;
}复杂度分析
时间复杂度:
- 我们需要处理 次合并操作。
- 每次操作中,并查集的
find和unite操作的平均时间复杂度是近乎常数的,记为 (阿克曼函数的反函数),它增长得非常非常慢,对于所有实际情况都可以看作是一个小于 5 的小常数。 - 其他计算都是 的。
- 所以总的时间复杂度就是 ,非常高效!
空间复杂度:
- 我们需要两个大小为 的数组来维护并查集的
parent和sz信息。所以空间开销是线性的,喵~
- 我们需要两个大小为 的数组来维护并查集的
知识点总结
这道题真是一次有趣的挑战呢!它融合了好几个知识点:
- 并查集 (DSU): 识别出题目中的“传递性朋友关系”是等价关系,并使用并查集来高效地维护集合的合并与查询,是解题的第一步。
- 组合数学: 问题的核心是计数,需要用到组合数 的计算。
- 动态维护/增量思想: 面对一系列更新操作,直接重新计算整个答案通常是不可行的。本题的精髓在于思考每次“合并”操作对最终答案产生了多大的“变化量”(减少了多少),然后动态地更新答案。这种增量思想在许多算法竞赛题中都非常有用!
- 容斥原理: 在计算减少量时,我们用“总的减去不合法的”这种补集思想,来算出从剩下的人中挑选两个互不为朋友的方案数,这也是一个常用的计数技巧。
希望这篇题解能帮助你更好地理解这个问题!继续加油哦,指挥官,我会一直为你应援的,喵~!