欢迎关注更多精彩
关注我,学习常用算法与数据结构,一题多解,降维打击。
文章目录
一、简介
并查集数据结构在平时的算法中非常常用,操作比较简单,代码少,好理解,好实现,学习成本低。
同时,也是很多复杂算法里的一个辅助算法。
二、定义
并查集是一种树(森林)型的数据结构。
将编号分别为1-N的N个对象划分为不相交集合。
在每个集合(树)中,选择其中1个元素(树的根)代表所在集合。
能够高效的实现对集合进行合并和查询集合中元素的关系。
三、作用
- 最常用的就是在图中判断2个点是否连通。
- 求解连通块数量
- 类似一些问题也是转化为图模型去使用并查集模板。
- 也有针对实际场景,添加(修改)并查集属性,来实现其他附属信息的查询。
四、数据定义及算法
并查集是一种树型结构,数据结构中可以用邻接表法,矩阵法,和父结点法来表示树,这里利用最简单的父结点表示法。
数据定义
UnoinFindSet {
father array // 数组 father[i]表示i结点的父亲,如果father[i]=i, 由表示i为自己所在集合的代表。
// 操作有3种:
InitSet(n): // 初始化数据
Find(x):// 查找x所在集合的代表
Union(x, y) // 将x, y所在集合合并
}
算法描述
InitSet(n): // 初始化数据
// 遍历所有结点并初始化所有点的父亲都是自己 如图1
for i <- 1 to n do
father[i]<-i
Find(x):// 查找x所在集合的代表
// 一直查找自己的父亲,直到father[x]=x, 返回x 图2
while father[x]!=x do
x <- father[x]
end
return x
Union(x, y): // 将x, y所在集合合并
// 注意这里是合并x, y所在「集合」,不是合并x,y
// 先找到x, y的集合代表,使其代表的父亲=另一个,如图3
x<-Find(x)
y<-Find(y)
// 由于并查集是一个树型,树型是有向无环图。
// 无环意味着已经连通的点不能再加边,所以x==y时,忽略。
if x!=y do
father[x] = y
五、具体实现
这里先用go实现一下,后续我会把C++版本整合到github上,地址:https://github.com/LightningBilly/ACMAlgorithms
package main
import "fmt"
/*
并查集,判连通用
*/
type UnionFindSet struct {
father []int // 存储结点的父亲
nodeNum int // 总结点个数
}
func (us *UnionFindSet) InitUnionSet(n int) {
us.nodeNum = n+1 // 不加也可以,有人喜欢以0开头
us.father = make([]int, us.nodeNum)
for i, _ := range us.father {
us.father[i] = i
}
}
// 查询父结点
func (us *UnionFindSet) Find(x int) int {
for us.father[x] != x {
x = us.father[x]
}
return x
}
//合并结点
func (us *UnionFindSet) Union(x, y int) bool {
x = us.Find(x)
y = us.Find(y)
if x == y {
return false
}
us.father[x] = y
return true
}
func main() {
us:= &UnionFindSet{}
us.InitUnionSet(11)
us.Union(1, 2)
us.Union(10, 9)
us.Union(7,8)
us.Union(8,3)
us.Union(8,9)
for i:=0;i<=10; i++ {
fmt.Println(i, us.Find(i))
}
}
六、牛刀小试
练习1 判连通应用
题目链接 http://hihocoder.com/problemset/problem/1066, https://vjudge.net/problem/HihoCoder-1066
题目大意
有2种操作,
0表示将2人划为同一阵营
1表示查询2人是否为同一阵营
注:同一阵营关系具有传递性:a与b同一阵,b与c同一阵营,那么a与c也是同一阵营。
要求遇到查询操作时,输出2人是否为同一阵营
输入:
第一行一个N表示以下会有N个操作
接下来n行,每行3个元素,一个数字,2个字符串,o a b。
o代表操作取值0,1如上所述。a b代表人员名字。
满足N<=10^5, 且数据中所有涉及的人物中不存在两个名字相同的人(即姓名唯一的确定了一个人),对于所有的操作,a和b是不同的两个人。
输出:
如果遇到查询操作(o=1)则输出a b是否为同一阵营,是则输出yes,否则输出no。
数据规模:
N<=10^5
题目解析
0操作就是Union操作
1操作就是判断2人是不是在同一集合,只要找到集合代表比较就可以了。
题目输入的不是数字,需要把名字编号。
由于每个人名不同,只要根据名字给定不同数字就可以了。我这里利用数字递增法生成,即每来一个新名字,编号+1.
AC代码
#include <iostream>
#include<vector>
#include<string>
#include<cstring>
#include<stdio.h>
#include <queue>
#include<map>
#include <algorithm>
using namespace std;
class UnionFindSet {
private:
vector<int> father; // 父结点定义,father[i]=i时,i为本集合的代表
int nodeNum; // 集合中的点数
public:
UnionFindSet(int n); // 初始化
bool Union(int x, int y); // 合并
int Find(int x);
};
UnionFindSet::UnionFindSet(int n) : nodeNum(n + 1) {
father = vector<int>(nodeNum);
for (int i = 0; i < nodeNum; ++i) father[i] = i; // 初始为自己
}
int UnionFindSet::Find(int x) {
while (father[x] != x) x = father[x];
return x;
}
bool UnionFindSet::Union(int x, int y) {
x = Find(x);
y = Find(y);
if (x == y)return false;
father[x] = y;
return true;
}
/*
编号生成器
原理:查看名字是否编号,有则直接返回,没有则将其编号为目前最大的数字。
*/
class IndexMaker {
private:
int curUsedIndex;
map<string, int> indMap;
public:
IndexMaker();
int GetIndByName(string name);
};
// 初始化当前编号为0
IndexMaker::IndexMaker() : curUsedIndex(0) {
indMap.clear();
}
int IndexMaker::GetIndByName(string name) {
// 如果没有编号,则添加编号
if (indMap.count(name) == 0) indMap[name] = curUsedIndex++;
return indMap[name];
}
int main() {
int n;
cin >> n;
UnionFindSet us(2 * n); // 最多有可能2n个人
string a, b;
int o;
IndexMaker indMaker;
for (int i = 0; i < n; ++i) {
cin >> o >> a >> b;
int indexa = indMaker.GetIndByName(a), indexb = indMaker.GetIndByName(b);
if (o == 0) us.Union(indexa, indexb);
else {
int roota = us.Find(indexa), rootb = us.Find(indexb);
string ans = roota == rootb ? "yes" : "no";
cout << ans << endl;
}
}
}
/**
10
0 Steven David
0 Lcch Dzx
1 Lcch Dzx
1 David Dzx
0 Lcch David
0 Frank Dzx
1 Steven Dzx
1 Frank David
0 Steven Dzx
0 Dzx Frank
样例输出
yes
no
yes
yes
*/
练习2 求解连通块应用
题目链接:https://leetcode-cn.com/problems/friend-circles/
题目大意
班上有 N 名学生。其中有些人是朋友。朋友关系是可以传递的。如果已知 A 是 B 的朋友,B 是 C 的朋友,那么 A 也是 C 的朋友。所谓的朋友圈,是指所有朋友的集合。
给定一个 N * N 的矩阵 M,表示学生之间的朋友关系。如果M[i][j] = 1,表示第 i 个和 j 个学生互为朋友关系。输出朋友圈总数。
题目解析
一个朋友圈内的同学是互为朋友关系,就是在同一个集合里。
那么我们只要对所有朋友关系的同学进行合并,然后查看有多少个不同的集合即可。
一个集的代表只有一个,只要能找出这个代表(标志就是Find(x)=x)就可以统计出不同集合的个数。
思路
func findCircleNum(M [][]int) int {
us.InitUnionFindSet(n)
for i<-1-n {
for j<- 1-n {
if i和j是朋友 : us.Union(i,j)
}
}
friendCircle <- 0
for i<-1-n {
if us.Find(i)==i : friendCircle <- friendCircle+1
}
return friendCircle
}
AC代码
/*
并查集,判连通用
*/
type UnionFindSet struct {
father []int // 存储结点的父亲
nodeNum int // 总结点个数
}
func (us *UnionFindSet) InitUnionSet(n int) {
us.nodeNum = n+1
us.father = make([]int, us.nodeNum)
for i, _ := range us.father {
us.father[i] = i
}
}
// 查询父结点
func (us *UnionFindSet) Find(x int) int {
for us.father[x] != x {
x = us.father[x]
}
return x
}
//合并结点
func (us *UnionFindSet) Union(x, y int) bool {
x = us.Find(x)
y = us.Find(y)
if x == y {
return false
}
us.father[x] = y
return true
}
func findCircleNum(M [][]int) int {
us := &UnionFindSet{}
us.InitUnionSet(len(M))
for i,row:=range M{
for j,isFriend :=range row {
if isFriend==1 {us.Union(i,j)} // 是朋友则合并
}
}
friendCircle :=0
for i:=0;i<len(M);i++ {
if us.Find(i)==i { friendCircle++} // 查看集合代表个数
}
return friendCircle
}
/*
[[1,1,0],[1,1,0],[0,0,1]]
[[1]]
[[1,0,0],[0,1,0],[0,0,1]]
[[1,0,1],[0,1,0],[1,0,1]]
[[1,0,1,0],[0,1,0,0],[1,0,1,1],[0,0,1,1]]
*/
七、问题及优化
压缩路径
Find(x):// 查找x所在集合的代表
// 一直查找自己的父亲,直到father[x]=x, 返回x 图2
while father[x]!=x do
x <- father[x]
end
return x
看Find操作,可以发现最坏情况下,操作的复杂度是O(n)。
通过观察可以发现在查找结点5的根时,其实结点2,3只是起到一个中间桥梁的作用,最后实际返回的是4。
其实我们最后想得到的是 find(5)=find(2)=find(3)=find(4)=4。
那么我们就可以对树进行以下操作。
把结点5到根的路径上所有的结点直接指向4,这个操作叫做"路径压缩"。
这样做的好处在于,这次查询是O(n)的,将来其他的2,3结点查询是o(1), 均摊下来的话,就是O(1)。
优化后的代码查询代码:
// 查询父结点
func (us *UnionFindSet) Find(x int) int {
root := x // 保存好路径上的头结点
for us.father[root] != root {
root = us.father[root]
}
/*
从头结点开始一直往根上遍历
把所有结点的father直接指向root。
*/
for us.father[x] != x {
// 一定要先保存好下一个结点,下一步是要对us.father[x]进行赋值
temp := us.father[x]
us.father[x] = root
x = temp
}
return root
}
这一步优化已经满足绝大多数场景,下面的平衡合并可以了解一下。
平衡合并
看这么一个例子,图6
可以看到极端情况下会退化成一个链表。对于查询效率也是有影响的。
还记得二叉查找树么,也有类似的情况。他的解决办法是让树平衡(左右子树高度差不大于1)。
对于并查集的解决办法是尽量让左右子树平衡(高度相差尽量小点)。
规则如下:
假设现在集合a(root是a), 集合b(root是b) 要合并。
集合a的高度是ha, 集合b的高度是hb。分3种情况讨论。
1)ha < hb: father[a]=b(让b成为集合a的根), hb不变,如图7
2)ha > hb: father[b]=a(让a成为集合b的根), ha不变,与上同理
- ha == hb: 需要判断 a==b ? 如果是就不操作(原来就在一个集合里,不需要操作),否则father[a]=b, hb++(反之亦可。)如图8,注意树的高度要加1
/*
需要加入height[]属性,初始化为1.
*/
//合并结点
func (us *UnionFindSet) Union(x, y int) bool {
x = us.Find(x)
y = us.Find(y)
if x == y {
return false
}
if us.height[x]<us.height[y] {
us.father[x]=y
} else if us.height[x]>us.height[y] {
us.father[y]=x
} else {
us.father[x] = y
us.height[y]++
}
return true
}
八、代码模板
package main
import "fmt"
/*
并查集,判连通用
*/
type UnionFindSet struct {
father []int // 存储结点的父亲
height []int // 存储树的高度
nodeNum int // 总结点个数
}
func (us *UnionFindSet) InitUnionSet(n int) {
us.nodeNum = n+1 // 不加也可以,有人喜欢以0开头
us.father = make([]int, us.nodeNum)
us.height = make([]int, us.nodeNum)
for i, _ := range us.father {
us.father[i] = i
us.height[i] = 1
}
}
// 查询父结点
func (us *UnionFindSet) Find(x int) int {
for us.father[x] != x {
x = us.father[x]
}
return x
}
//合并结点
func (us *UnionFindSet) Union(x, y int) bool {
x = us.Find(x)
y = us.Find(y)
if x == y {
return false
}
us.father[x] = y
return true
}
func (us *UnionFindSet) FindV2(x int) int {
root := x // 保存好路径上的头结点
for us.father[root] != root {
root = us.father[root]
}
/*
从头结点开始一直往根上遍历
把所有结点的father直接指向root。
*/
for us.father[x] != x {
// 一定要先保存好下一个结点,下一步是要对us.father[x]进行赋值
temp := us.father[x]
us.father[x] = root
x = temp
}
return root
}
/*
需要加入height[]属性,初始化为1.
*/
//合并结点
func (us *UnionFindSet) UnionV2(x, y int) bool {
x = us.Find(x)
y = us.Find(y)
if x == y {
return false
}
if us.height[x]<us.height[y] {
us.father[x]=y
} else if us.height[x]>us.height[y] {
us.father[y]=x
} else {
us.father[x] = y
us.height[y]++
}
return true
}
func main() {
us:= &UnionFindSet{}
us.InitUnionSet(11)
us.UnionV2(1, 2)
us.UnionV2(10, 9)
us.UnionV2(7,8)
us.UnionV2(8,3)
us.UnionV2(9,8)
for i:=0;i<=10; i++ {
fmt.Println(i, us.FindV2(i))
}
}
九、总结
主要内容:
- 并查集是一种树型结构,能够高效实现的对集合进行合并和查询集合中元素的关系。
- 作用:1)判断2个点是否连通;2)求解连通块数量;3)也有更深入的用法,要自己探索。
- 可以通过路径压缩和平衡合并来优化查询效率。
初学一个算法时最好在纸上把所有过程自己模拟一遍,理清思路,要对过程了然于心。当然,这也不是一蹴而就的,要在做题中不断应用。每次应用就相当于在心里过了一遍历。慢慢的就感觉对这个过程很了解,就算很久没用,偶尔让写一下还是可以写出来。
笔者水平有限,有写得不对或者解释不清楚的地方还望大家指出,我会尽自己最大努力去完善。
下面我精心准备了几个流行网站上的题目(首先AK F.*ing leetcode),给大家准备了一些题目,供大家练习参考。干他F.*ing (Fighting?)。
十、实战训练
代码基础训练题
光说不练假把式,学完了怎么也要实操一下吧,快快动用把刚才那2题A了。
- 连通性应用 http://hihocoder.com/problemset/problem/1066, https://vjudge.net/problem/HihoCoder-1066
- 连通块个数应用 https://leetcode-cn.com/problems/friend-circles/
AK leetcode
leetcode相关题目都在下面了,拿起武器挨个点名呗。
https://leetcode-cn.com/tag/union-find/ 并查集题目列表
做完以上还觉得不过瘾,我给大家还准备了一些。
大神进阶
也可以去vjudge https://vjudge.net/problem 搜索相关题号
poj
http://poj.org/problem?id=1988
以下将序号替换就是题目链接。
- 1182
- 1308
- 1417
- 1456
- 1611
- 1733
- 1861
- 1984
- 1986
- 1988
- 2236
- 2492
- 2524
- 2912
- 3038
hdu
http://acm.hdu.edu.cn/showproblem.php?pid=1198
以下将序号替换就是题目链接。
- 1198
- 1213
- 1232
- 1272
- 1299
- 1605 Navigation Nightmare
- 1612 Cube Stacking
- 3336 数据结构;并查集
其他
https://vjudge.net/problem/ZOJ-3261
本人码农,希望通过自己的分享,让大家更容易学懂计算机知识。