等价类与并查集的原理和应用
并查集主要解决判断两个元素是否同属一个集合,和把两个不属同一集合的两个元素进行合并的问题。
(
想想最小生成树中的
kruskal
算法
:
关键是判别两个节点是否属同一连通分量,这里并查集可以以很好的时间复杂度解决它,几乎是线性时间!!
)
首先要知道关于等价类(就相当于前面说的同属一个集合)的基本性质。
等价类三大性质:(用X
º
Y表X与Y等价)
自反性:如X
º
X则X
º
X;
对称性:如X
º
Y则Y
º
X;
传递性:如X
º
Y且Y
º
Z则X
º
Z;
等价类应用:
设初始有一集合
s={0,1,2,3,4,5,6,7,8,9,10,11}
依次读若干事先定义的等价对
0º
4,3º
1,6º
10,8º
9,
7º
4,
6º
8,3º
5,2º
11,11º
0.
我们想把每次读入一个等价对后,把等价集合合并起来。
则每读入一个等价对后的集合状态是:(用{}表示一个等价集合)
初始
{0},{1},{2},{3},{4},{5},{6},{7},{8},{9},{10},{11}
0
º
4
{0,4},{1},{2},{3},{5},{6},{7},{8},{9},{10},{11}
3
º
1
{0,4}, {1,3},{2},{5},{6},{7},{8},{9},{10},{11}
6
º
10 {
0,4},{1,3},{2},{5},{6,10},{7},{8},{9},{11}
8
º
9
{0,4},{1,3},{2},{5},{6,10},{7},{8,9},{11}
7
º
4
{0,4,7},{1,3},{2},{5},{6,10},{8,9},{11}
6
º
8
{0,4,7},{1,3},{2},{5},{6,8,9,10},{11}
3
º
5
{0,4,7},{1,3,5},{2},{6,8,9,10},{11}
2
º
11 {
0,4,7},{1,3,5},{2,11},{6,8,9,10}
11
º
0 {
0,2,4,7,11},{1,3,5},{6,8,9,10}
怎样来实现这样的结构呢?
我们可建一个含S元素个数大小的指针数组
node *seq[N]
。其中每个元素是一个指向其下一个等价元素对应序号节点的头指针。每读入一个等价对后,修改链表。上例读完后的表状态是:
建表算法如下:
void creatlist()
{ int x,y,c;
node *P;
scanf("%d",&c); //读入等价对数
for(int i=0;i<c;i++)
{
scanf("%d %d",&x,&y);
P=new node;
P->data=y;
P->next=seq[x];
seq[x]=P;
P=new node; //为什么要两个节点呢?因为你建了个A的下一等价对B那么由对称性,应该再建一个节点表示B的下一等价对是A。
P->data=x;
P->next=seq[y];
seq[y]=P;
}//for
}
现在我们想把同属一个等价集合的元素分别输出出来。有什么办法呢?
先看图。Seq[0]它有两个等价类11,4。这样直接输出0,11,4。是否可行呢?答案是不行的,你看下4结点还有两个等价类7,0。当然已知0,4是等价的了,
但7你是忽略的了(因为传递性:0,4等价且4,7等价就必然有0,7等价。所以7也应该被输出)。还有
11
节点下的2你也没算……
我们可以采用类似DFS(深度优先搜索)的策略对每个集合的元素进行遍历。例如:
开始从0节点搜起,把它下面的两个直接等价类入栈,然后依次出栈,每次出栈后对出栈元素的直接等价类再进行一次类似的DFS,直到包含0节点的等价集合被全部输出。程序如下:
void display()
{
stack<int> S;
int t;node *p;
for(int i=0;i<N;i++)
{
if(!outed[i]) //是否输出过的标记
{
cout<<"new class"<<'{';
S.push(i);
cout<<i<<' ';
outed[i]=1;
while(!S.empty())
{
t=S.top();
S.pop();
p=seq[t];
while(p!=NULL)
{
if(!outed[p->data])
{
cout<<p->data<<' ';
S.push(p->data);
outed[p->data]=1;
}
p=p->next;
}//while
}//while
cout<<'}'<<endl;
}//if
}//for
}
其实有比这个方便快速的数据结构来实现这个问题,那就是并查集。
并查集对这个问题的处理思想是开始把每一个对象看作是一个单元素集合,然后依次按顺序(就是读入等价对)将属于同一等价类的元素所在的集合合并。在此过程中将重复地使用一个搜索运算,确定一个元素在哪一个集合中。当读入一个等价对AB时,先检测这两个等价对是否同属一个集合,如是,则不用合并。不是,则用个合并算法把这两个包含AB的集合合并,使两个集合的任两个元素都是等价的(由传递性)。
为了方便,我们通常用一个集合的根结点序号来表示这个集合。
来看下具体结构实现:
定义个Parent[N]的数组,作为结构。Parent中存的就是序号结点的父亲结点的序号,例:如果Parent[3]=4就是说3号结点的双亲是4号结点。如果一个结点的父结点是负数的话,我们就认为它是一个集合的根结点,因为数组中没有序号是负的。并且用负的绝对值作为这个集合中所含节点个数,如:
parent[6]=-4;
说明6号节点是个集合根结点,这个集合有4个元素。初始时,所有Parent赋为
-1
,说明每个结点都是根结点(N个独立节点集合),只包含一个元素(就是自己了)。
实现这个数据结构主要有三个函数:如下:
void UFset() //初始化
{
for(int i=0;i<N;i++)
parent[i]=-1;
}
int Find(int x) //返回第X节点所属集合的根结点
{
for(int i=x;parent[i]>=0;i=parent[i]);
while(i!=x) //优化方案――压缩路径
{
int tmp=parent[x];
parent[x]=i;
x=tmp;
}
return i;
}
void Union(int R1,int R2) //将两个不同集合的元素进行合并,使两个集合中任两个元素都连通
{
int tmp=parent[R1]+parent[R2];
if(parent[R1]>parent[R2]) //优化方案――加权法则
{
parent[R1]=R2;
parent[R2]=tmp;
}
else
{
parent[R2]=R1;
parent[R1]=tmp;
}
}
在Find函数中如果仅仅靠一个循环来直接得到节点的所属集合根结点的话。通过多次的Union操作就会有很多节点在树的比较深层次中,再Find起来就会很费时。通过加一个While循环(压缩路径)每次都把从
i
到集合根结点的路过结点的双亲直接设为集合的根结点。虽然这增加了时间,但以后的Find会快。平均效能而言,这是个高效方法。
两个集合并时,任一方可做为另一方的孩子。怎样来处理呢,现在一般采用加权合并,把两个集合中元素个数少的做为个数多的孩子。有什么优势呢?直观上看,可以减少集合树的深层元素的个数,减少Find时间。
如从0开始到N不断合并
i
和
i+1
结点会怎样呢?
这样Find任一节点所属集合的时间复杂度几乎都是O
(1)
!!
不用加权规则就会得到
这就是典型的退化树现象,再Find起来就会很费时(如找N-1节点看看)。
以下是读入等价对后的
parent[N]
查找合并过程状态:
再说一个并查集应用最完美的地方:最小生成树的
kruskal
算法:
算法基本思想是:
开始把所有节点作为各自的一个单独集合,以为每次从边信息中得到一个最短的边,如果这个边邻接了两个不同集合的节点,就把这两个节点的所属集合结合起来,否则继续搜索。直至所有节点都同属一个集合,就生成了一个最小生成树。int kruskal(int parent[],int N)
{
int i,j,k,x,y;
int min;
while(k<=N-1) //产生N-1条边
{
min=MAX_INT;
for(i=1;i<=N;i++)
for(j=1;j<=N;j++)
{
if(sign[i][j]==0&&i!=j) //sign[N][N]是标志节点是否被访问过或已被排除……
{
if(arcs[i][j]<min) //arcs[N][N]存放边的长度
{
min=arcs[i][j];
x=i;
y=j;
}//if
}//if
}//for
if(Find(parent,x)==Find(parent,y)) //如X,Y已经属同一连通分量,则不合并,排除……
sign[x][y]=1;
else
{
Union(parent,x,y);
Sign[x][y]=1;
}
k++;
}//while
}