一. 带权并查集简介
带权并查集是在普通并查集的基础上进行了扩展。普通并查集主要用于处理不相交集合的合并与查询问题,它可以高效地判断两个元素是否属于同一个集合,以及合并两个不同的集合。带权并查集在每个元素上额外维护了一个权值,这个权值可以表示元素之间的某种关系,比如距离、差值等
。通过这些权值,我们可以在合并和查询操作的过程中获取更多关于元素之间关系的信息。
带权并查集非常灵活,同时也学习成本也较高,作者在初看时也云里雾里,不过通过画图理清各个元素和树之间的逻辑关系,再到理解逻辑到内存上的映射关系,就能略懂一二了。
二. 算法实现原理
1. 初始化
与普通并查集类似,在初始化时,每个元素的父节点初始化为自身。但是还需要一个额外的数组来维护各个元素所包含的权值,当然也可以通过结构体(struct)来实现,这里作者使用两个数组实现,同时将每个元素的权值初始化为 0。这表示:每个元素最初都属于一个独立的集合,且自身与自身的关系权值为 0。
2. 查询操作
在查找元素的根节点时,除了进行普通的路径压缩,还需要更新元素的权值。路径压缩的目的是将元素直接连接到根节点,以减少后续查找的时间复杂度。在更新权值时,需要根据当前元素到其父节点的权值以及其父节点到根节点的权值来计算当前元素到根节点的权值。
注意: dist数组存放的是当前节点到root节点的距离,但是实际上合并时不会修改除了root节点之外的值所以实际存放的是到父亲节点的信息,但是该信息会在find查询的过程中通过路径压缩修正
3. 合并操作
在合并两个集合时,需要先找到两个元素的根节点,在查找过程中会调用find函数并自动修正dist中的值。然后将一个根节点的父节点设置为另一个根节点,并根据两个元素之间的关系更新权值。更新权值的具体方式取决于权值所表示的实际意义。
以下是一个带权并查集的简单实现
#include <iostream>
#include <vector>
using namespace std;
const int MAXN = 100005;
// 带权并查集类
class WeightedUnionFind {
private:
vector<int> parent; // 存储每个元素的父节点
vector<int> weight; // 存储每个元素到其父节点的权值
public:
// 构造函数,初始化并查集
WeightedUnionFind(int n) {
parent.resize(n + 1);
weight.resize(n + 1);
for (int i = 1; i <= n; ++i) {
parent[i] = i;
weight[i] = 0;
}
}
// 查找元素 x 的根节点,并进行路径压缩和权值更新
int find(int x) {
if (parent[x] == x) return x;
int root = find(parent[x]);
weight[x] += weight[parent[x]];
parent[x] = root;
return root;
}
// 合并元素 x 和 y 所在的集合,并更新权值
void unite(int x, int y, int w) {
int rootX = find(x);
int rootY = find(y);
if (rootX != rootY) {
parent[rootX] = rootY;
weight[rootX] = weight[y] + w - weight[x];
}
}
// 判断元素 x 和 y 是否属于同一个集合
bool isConnected(int x, int y) {
return find(x) == find(y);
}
// 获取元素 x 到其根节点的权值
int getWeight(int x) {
find(x); // 确保路径压缩和权值更新
return weight[x];
}
};
int main() {
int n = 5; // 元素个数
WeightedUnionFind uf(n);
// 合并操作
uf.unite(1, 2, 3);
uf.unite(2, 3, 4);
// 查询操作
if (uf.isConnected(1, 3)) {
cout << "元素 1 和 3 属于同一个集合,它们之间的权值差为: " << uf.getWeight(1) - uf.getWeight(3) << endl;
} else {
cout << "元素 1 和 3 不属于同一个集合" << endl;
}
return 0;
}
代码解释
WeightedUnionFind
类:parent
数组用于存储每个元素的父节点。weight
数组用于存储每个元素到其父节点的权值。find
函数用于查找元素的根节点,并进行路径压缩和权值更新。unite
函数用于合并两个元素所在的集合,并更新权值。isConnected
函数用于判断两个元素是否属于同一个集合。getWeight
函数用于获取元素到其根节点的权值。
main
函数:- 初始化一个包含 5 个元素的带权并查集。
- 进行合并操作,将元素 1 和 2 合并,权值为 3;将元素 2 和 3 合并,权值为 4。
- 查询元素 1 和 3 是否属于同一个集合,并输出它们之间的权值差。
通过上述代码,我们可以看到带权并查集的基本使用方法。在实际应用中,权值的具体含义和更新方式可以根据具体问题进行调整。
可以得知带权并查集最重要的是其权值所代表的含义,得益于其多样的设定和理解,带权并查集才如此费解和灵活。
三. 经典例题解析
1. P1196 [NOI2002] 银河英雄传说
题目描述
杨威利擅长排兵布阵,巧妙运用各种战术屡次以少胜多,难免恣生骄气。在这次决战中,他将巴米利恩星域战场划分成
30000
30000
30000 列,每列依次编号为
1
,
2
,
…
,
30000
1, 2,\ldots ,30000
1,2,…,30000。之后,他把自己的战舰也依次编号为
1
,
2
,
…
,
30000
1, 2, \ldots , 30000
1,2,…,30000,让第
i
i
i 号战舰处于第
i
i
i 列,形成“一字长蛇阵”,诱敌深入。这是初始阵形。当进犯之敌到达时,杨威利会多次发布合并指令,将大部分战舰集中在某几列上,实施密集攻击。合并指令为 M i j
,含义为第
i
i
i 号战舰所在的整个战舰队列,作为一个整体(头在前尾在后)接至第
j
j
j 号战舰所在的战舰队列的尾部。显然战舰队列是由处于同一列的一个或多个战舰组成的。合并指令的执行结果会使队列增大。
然而,老谋深算的莱因哈特早已在战略上取得了主动。在交战中,他可以通过庞大的情报网络随时监听杨威利的舰队调动指令。
在杨威利发布指令调动舰队的同时,莱因哈特为了及时了解当前杨威利的战舰分布情况,也会发出一些询问指令:C i j
。该指令意思是,询问电脑,杨威利的第
i
i
i 号战舰与第
j
j
j 号战舰当前是否在同一列中,如果在同一列中,那么它们之间布置有多少战舰。
作为一个资深的高级程序设计员,你被要求编写程序分析杨威利的指令,以及回答莱因哈特的询问。
输入格式
第一行有一个整数 T T T( 1 ≤ T ≤ 5 × 1 0 5 1 \le T \le 5 \times 10^5 1≤T≤5×105),表示总共有 T T T 条指令。
以下有 T T T 行,每行有一条指令。指令有两种格式:
-
M i j
: i i i 和 j j j 是两个整数( 1 ≤ i , j ≤ 30000 1 \le i,j \le 30000 1≤i,j≤30000),表示指令涉及的战舰编号。该指令是莱因哈特窃听到的杨威利发布的舰队调动指令,并且保证第 i i i 号战舰与第 j j j 号战舰不在同一列。 -
C i j
: i i i 和 j j j 是两个整数( 1 ≤ i , j ≤ 30000 1 \le i,j \le 30000 1≤i,j≤30000),表示指令涉及的战舰编号。该指令是莱因哈特发布的询问指令。
输出格式
依次对输入的每一条指令进行分析和处理:
- 如果是杨威利发布的舰队调动指令,则表示舰队排列发生了变化,你的程序要注意到这一点,但是不要输出任何信息。
- 如果是莱因哈特发布的询问指令,你的程序要输出一行,仅包含一个整数,表示在同一列上,第 i i i 号战舰与第 j j j 号战舰之间布置的战舰数目。如果第 i i i 号战舰与第 j j j 号战舰当前不在同一列上,则输出 − 1 -1 −1。
说明/提示
题目中没有强制
i
≠
j
i \neq j
i=j,但是实测数据中不存在
i
=
j
i = j
i=j 的情况。
下面是题解过程
I. 算法解析
- 可以很明显地注意到题中的合并和查询操作,所以理所当然地,我们想到使用并查集来实现题解,同时题中要求查询两艘战舰之间的战舰个数,所以我们想到要
用带权并查集来维护这种数量关系**
,我们可以指定权值dist[i]的含义是当前战舰 i 到最前方战舰 fi(也就是root节点)之间的战舰数量,具体的计算公式则容易推导出为:abs(dist[i] - dist[j]) - 1
。
- 然后另外一个特点是,我们在合并两个集合时,是把一个root节点挂到另一个root节点的最后一位,但是实际操作时肯定不会这样去操作,因为找到root很容易,我们有现成的模板,但是找到叶子节点就比较难实现了,所以我们还是
将 x 的根节点 fx 挂到 y 的根节点 fy 的下方成为一棵子树
,但是 fx 的 dist 值怎么更新呢?可以得知,fx 的 dist 值应为 fy 所在战舰队列的战舰总数
,因为是逻辑上是将 fx 挂到 fy 的末尾的,所以想到我们可以再创建一个数组 size 来维护各个战舰群的战舰总数,初始时全为 1
,这样,我们就解决了这两个问题,接下来就是代码实现了。
II. 算法实现
下面是代码实现,配有作者解题时的思路的注释呈现
#include <iostream>
#include <cmath>
const int N = 3e4 + 10;
int n;
int fa[N];
int dist[N], size[N];
//dist数组维护的是每一个元素到root节点的距离,实际意义则是包括二者以及二者间的元素个数减一
//size数组维护的是每一个root节点所在的队伍的元素总个数
int find(int x)
{
if(fa[x] == x) {//查找到root节点则返回
return x;
}
int fx = find(fa[x]);
dist[x] += dist[fa[x]];//修正距离
return fa[x] = fx;//路径压缩后返回
}
void uni(int x, int y)
{
int fx = find(x);
int fy = find(y);
if(fx != fy) {
fa[fx] = fy;//将fx挂到fy下方
dist[fx] = size[fy];//修改fx的dist值(dist[fx])为fy的原size大小(size[fy])
size[fy] += size[fx];//将size[fy]增加size[fx]
size[fx] = 0;//将size[fx]置0,其实这一步可加可不加,因为当前的fx的战舰清零后,不会再对这一行有任何操作,因为这是无意义的
}
}
int main(void)
{
std::cin >> n;
//初始化并查集
for(int i = 1; i <= N; i++) {
fa[i] = i;//初始时所有元素都是一棵孤立的树
size[i] = 1;//初始时所有位置都只有一个元素
}
char op;
int x, y;
while(n--) {
std::cin >> op >> x >> y;
if('M' == op) {//合并操作
//合并集合并修正dist和size值
uni(x, y);
} else if('C' == op) {//查询操作
//查找root节点同时修正dist值
int fx = find(x);
int fy = find(y);
//两者dist值相减后再减一即为二者距离
if(fx == fy) {
std::cout << std::abs(dist[y] - dist[x]) - 1 << std::endl;
} else {
std::cout << -1 << std::endl;
}
}
}
}
2. P2024 [NOI2001] 食物链
这道题我们在上一篇博客 -> 扩展域并查集初探和例题解析 中使用扩展域并查集做过详细的解答,有感兴趣的小伙伴可以前往查看。
同养,在这里可以使用带权并查集来解答
题面请自行点击链接前往查阅,这里只做算法解析和实现
I. 算法解析
- 作为带权并查集,我们可以当然可以用权值 dist 来维护动物之间的关系,为了方便算法实现,我们规定:
A 对 B 的权值为 0 时,代表二者是同类;权值为 1 时,代表 A 捕食 B;权值为 2 时,代表 A 被 B 捕食
,于是我们可以做到在一棵树中维护三种不同的关系,配合路径压缩,我们可以在此过程中修正对应的 dist 值来维护信息。
- 在一棵树中保存并维护了各种关系信息,但是我们如何提取任意两者之间的关系呢?因为我们使用 dist 值来维护关系,并且在查询这两者的根节点时,我们会通过路径压缩,将二者路径上的所有节点压缩到根节点下,所以此时我们对于 A 和 B 以及根节点 R,自然形成了三角关系,我们只需要判断二者分别与 R 的关系,就可以推导出 A 与 B 的关系及其所代表的值:
(dist[A] - dist[B] + 3) % 3
,这里 dist[A] - dist[B] 求的是二者的相对距离,也就是 A 对 B 的距离,而 + 3 则是因为不能保证前者一定大于后者,有可能会出现负数,而这种方式则能让负数(正数当然可以)对应到正确的数值上, % 3 则是因为三种动物之间的关系是一个循环,需要取模来限定数值范围。
- 最后,我们在 find 函数中路径压缩时,如何维护 dist 中的信息?很简单:合并两棵树时,我们只关注两个根节点以及在他们下面通过路径压缩实现直接连接的输入的节点,这时我们知道从 A 节点走到 fB 节点有两条路径
A -> fA -> fB 以及 A -> B -> fb
,我们通过输入得到了 A 和 B 之间的关系(即距离,设为weight)所以有等式:dist[A] + dist[fA] = weight + dist[B]
即可求出 dist[fA] 并赋给 fA 的 dist 值。
II. 算法实现
下面是详细的题解代码,配有作者解题时的思考的注释呈现
#include <iostream>
const int N = 5e4 + 10;
int n, m;
int fa[N];//存放父亲节点
int dist[N];//存放当前节点到root节点的距离,但是实际上合并时不会修改除了root节点之外的值
//所以实际存放的是到父亲节点的信息,但是该信息会在find的过程中通过路径压缩修正
int find(int x)
{
if(fa[x] == x) {
return x;
}
int fx = find(fa[x]);
dist[x] = (dist[x] + dist[fa[x]]) % 3;//修正距离
return fa[x] = fx;//路径压缩
}
void uni(int x, int y, int we)
{
int fx = find(x), fy = find(y);
//若已经在同一棵树中则无需额外操作
if(fx == fy) {
return;
} else {//不在一棵树中 -> 将左边的root节点fx挂到右边的root节点fy下,并修正fx到fy的dist距离
fa[fx] = fy;
dist[fx] = (dist[y] + we - dist[x] + 3) % 3;
}
}
int main(void)
{
std::cin >> n >> m;
//初始化并查集
for(int i = 1; i <= n; i++) {
fa[i] = i;
}
//读取每一句话中的信息并处理
int ret = 0;
while(m--) {
int op, x, y;
std::cin >> op >> x >> y;
//先特判数据范围和自己吃自己的情况
if(x > n || y > n || (2 == op && x == y)) {
ret++;
continue;
}
//找出root节点并修正dist数组中的值
int fx = find(x);
int fy = find(y);
//判断假话或处理关系
if(1 == op) {
//输入关系为同类,但是他们之间的关系已经存在并且不是同类关系
if(fx == fy && (dist[x] - dist[y] + 3) % 3 != 0) {
ret++;
continue;
}
//真话则维护信息
uni(x, y, 0);
} else if(2 == op) {
//输入关系为 x 捕食 y ,但是他们之间的关系已经存在并且不是 x 捕食 y的关系
if(fx == fy && (dist[x] - dist[y] + 3) % 3 != 1) {
ret++;
continue;
}
//维护信息
uni(x, y, 1);
}
}
std::cout << ret << std::endl;
return 0;
}
四. 总结
对于带权并查集,其最重要的特性就是权值所表达的含义,通过剖析不同权值之间所表示的关系,就可以窥探到带权并查集的灵活性,并将其运用到实际解决算法问题中。
如有不足之处欢迎各位指出,感谢!