引入
直观地说,并查集是一种合并不同树的方法。什么?你不知道树是啥?好吧,这里说点题外话(不看直接跳过到并查集部分),每回我要学什么东西的时候(记为知识点A),又会发现很多前置知识(知识点B、C等)没有学,而去搜那些前置知识,又只学得到一些对解决我现在要学的东西无关紧要的一些知识,虽然说总得学,但总觉得有那么一点不爽,看B、C半天也没看出什么名堂,还是得靠学好A来反过来加深对B、C的理解。所以!为了看的人更爽(虽然或许没什么人看),我首先介绍树,并在之后使用各种专有名词的时候直接加上注解,希望能有所帮助。
树是一种数据结构,表示每个点之间的关系。例如,下图是一种常见的树:
树包含节点和边。图中各个圆圈就是一个个节点,而把节点连起来线的叫做边。
树有几个重要特点:
- 不含环。环可以简单理解为死循环。树是图的特殊情况。而图则是可以含有环的树。没有环也就意味着,从树上随便选一节点,沿着路径一直走,如果不走回头路,就不可能回到原来经过过的节点。
- 恰好包含n - 1条边。
- 连通。连通是指对树上任何两个节点A,B,总有一条路径能从A走到B。
除开这些性质和结构外,树还有一个根节点。根节点由人确定,与根节点直接链接的节点成为根的子节点,然后这些子节点作为子根,再往下链接又可以确定很多子树……例如上图,A是整个树的根节点,而B和C是它的子节点(child),相应的,A是B、C节点的父节点(parent)。此时,把B,D,G,H,I单独拿出来,也是一个完整的树,我们称其为子树,B是子树的根节点。
好了,介绍完树的性质,我们想它的实现。如何在代码中实现树?首先,我们需要定义各个节点,其次,我们需要将各个节点联系起来,形成一棵树。
这里提供一个示例:
//首先建立节点结构体,里面有节点要存储的信息。
struct node{
int x;//例如,节点存某个数字.
}Node[1000];//将1000个节点存到node数组里
将节点联系起来,我们可以使用两种方法:
一是邻接矩阵,我们给一个矩阵,a[i][j]
表示第i个和第j个节点之间有一条边。例如对于下面的树:
其邻接矩阵表示为:
节点序号 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
---|---|---|---|---|---|---|---|---|---|
1 | true | true | true | false | false | false | false | false | false |
2 | true | true | false | true | false | false | false | false | false |
3 | true | false | true | false | true | true | false | false | false |
4 | false | true | false | true | false | false | false | false | false |
5 | false | false | true | false | true | false | false | true | true |
6 | false | false | true | false | false | true | false | false | false |
7 | false | false | false | true | false | false | true | false | false |
8 | false | false | false | false | true | false | false | true | false |
9 | false | false | false | false | true | false | false | false | false |
如上表,用邻接矩阵可以表示任意两个节点是否直接相连。
如果只有这个表,我们无法确定树的根节点。事实上,树的根节点本也是人为规定的,不影响树的形状。
事实上,n - 1个边信息可以由邻接矩阵确定~
另外还有一种联系方法,就是描述出这n - 1条边,每个边都有一个左端点和右端点。
struct edge//edge是边的英文
{
node* left, right;
//这里用node指针只是一种方法,如果节点用数组存储,也可以用整形表示下标。
};
之后,一个一个输入边的信息即可。
那么树的性质就介绍到这里。
并查集
实现
在关于树的问题中,我们或许会遇到有关树的连通性的问题。
例如,给若干个边的数据(左右节点),要你检验,这些边和节点是否构成一棵完整的树。
在并查集算法中,我们可以存储每个节点的前导节点(也就是谁与它相连),其利用结构体可以实现,利用数组也可以实现。而为了方便以下标做统计合并树,我们不妨使用数组:int pre[1001]
表示1~1000这些节点的父节点。
而如何将他们连通?
例如,现在有节点1,2,3,4,我们给出2条边的数据,让每行表示一条边的左端点和右端点:
1 3
2 4
于是我们的任务是:将属于一个边的节点加入一个连通块(连通块即一个子树)。而做到这点就要保证它们的根节点一定是一样的。
不妨定义两个函数:find函数和join函数。一个用于查询根节点,一个在根节点不同时将第二个节点的根节点b与第一个节点的根节点a相连,并指定b的根节点是a,这样就合并了树,连接了边。当然,根节点的父节点是它自己。
int find(int x)//输入下标,返回其根节点的下标
{
while(pre[x] != x)
x = pre[x];
return x;
}
void join(int x, int y)//判断二者是否由同一个根节点衍生,否则合并
{
int rootX = find(x), rootY = find(y);
if(rootX != rootY){
pre[rootY] = rootX;
}
}
初始的时候,所有的节点都没有连通,每个节点都在一个由自己构成的单点树,因此树根是它自己。所以设pre[i] = i.
将这些代码组合在一起,形成并查集的基本逻辑(假设有编号为1~m的m个节点,以及n条边):
int pre[m];
for(int i = 0; i < m; ++i) pre[i] = i;//初始化
int right[n], left[n];
for(int i = 0; i < n; ++i) cin >> right[i] >> left[i];//读取边数据
for(int i = 0; i < n; ++i)
join(right[i], left[i]);
由此,我们就实现了并查集!就是这么简单^ _ ^
例题
畅通工程的裸题,做完这题你就会并查集辣~
最小生成树
“村村通"问题
假设现在有一个村落,里面有很多村庄,各个村庄之间有的可以修路,有的因为地形原因无法修路。而各个可以修的路也都有各自的预计开销。现在要你设计一个方案,使得各个村庄之间连通起来(即各个村庄之间直接或者间接连接),并保证你的方案是预算最小的。
分析
实际上,一个树/图的边如果有一个权值(例如这里的修路开销)时,我们就称其为带权树/图。这里的各个村庄之间可以修的路和这些村庄就构成了一个带权图。另外,这里的各个村庄之间的路是可以来回走的,因此它是一个无向带权图。
问题可抽象化为:
给定一个无向带权图,求一个权值和最小的子图,使得各个节点都在这个子图中。
以贪心的思想,不难发现,要使得权值和最小,就利用最少的边构成这个子图。而构成一个子图需要的最少边是n - 1个,这个时候构成的就是一个树。我们称这个树为这个带权图的最小生成树。
下面我们来详细介绍最小生成树和求出它的算法。
概念
首先来看生成树的概念:
生成树:
一个有 n 个结点的连通图的生成树是原图的极小连通子图,且包含原图中的所有 n 个结点,并且有保持图连通的最少的边。
对于一个边大于n-1条的图,显然最小生成树不止有一个。
接着是最小生成树:
首先要有一个条件:图必须是带权图,不然各个生成树就没有比较的意义了,也就不存在最小生成树一说。
所有生成树中,权值和最小的那一棵就是最小生成树。
有些时候,最小生成树也不唯一。
Kruskal算法
Kruskal是利用边贪心思想求最小生成树的方法。Kruskal求最小生成树需要用到并查集合并子树。
首先将所有的边按照权值排序。
接下来,依次取出这些边。
分别查看取出边的左右节点的父节点。
如果不同的话,说明这两个节点目前并未连通,则利用并查集算法将它们合并。
如果相同的话,说明这两个节点已经连通,这个时候这条边就多余了,就可以不用连了。
按以上方法处理至连接n - 1次,我们的最小生成树就连好了。
伪代码:
sort edge
int count = 0
int ans = 0
for i from 1 to sizeOfEdge
if (edge[i].leftPoint.father != edge[i].rightPoint.father)
{
edge[i].rightPoint.father.father = edge[i].leftPoint.father
count = count + 1
ans = ans + edge[i].value
}
end if
end for
return ans
概括来说,就是先把边排序,再贪心取可以取的小边,并把小边所在的端点合并即可。
练习题:
Prim算法
未完待续