数据结构:并查集解决方案的详解

mac2024-04-20  34

并查集解决方案的详解

在说并查集之前,我们需要先来了解使用并查集的背景知识:等价类。 我们先来看看等价关系的概念:假定有一个具有n个元素的集合U=1,2,3…,n和一个具有r个关系的集合R=(i1,j1)(i2,j2)(i3,j3)…(ir,jr),当满足: 1.自反: 对于所有的a属于U,有(a,a)属于R; 2.对称: 对于(a,b)属于R,当且仅当(b,a)属于R; 3.传递: 若(a,b)属于R且(b,c)属于R,则有(a,c)属于R。 我们说R是一个等价关系。而在一个等价关系中,若(a,b)属于R,则元素a,b等价。而所谓等价类是指相互等价的最大集合,而这就意味着不存在类以外的元素与类内部的元素等价。(一个元素只属于一个等价类)也就相当于把集合U划分为若干个不相交的等价类。 但是,等价类有两个不同的分类,分为离线等价类与在线等价类。离线等价类自不必说,每个元素只属于一个等价类,但是在线等价类问题中,初始时,每个元素都属于一个独立的等价类,那我们衍生出了一些操作:合并,查找,这就是我们的并查集问题的由来。

解决方案一:数组解决

数组是最简单的解决方法,我们创建一个数组,使得数组标号的等价类就是它本身,例如a[i]就属于等价类i,合并时就将其元素改成另一元素的值,查找时就输出数组内的元素。 代码也很简单:

int *equivClass;//等价类数组 int n;//元素个数 void initialize(int numberOfElement)//初始化 { n = numberOfElement; equivClass = new int[n+1]; for(int i = 1;i<=n;i++) equivClass[i] = i; } void unite(int classA,int classB)//合并 { for(int i = 1;i<=n;i++) if(equivClass[i] == classB) equivClass[i] = classA; } int find(int theElement)//查找 { return equivClass[theElement]; } int main() { initialize(10); unite(1,4); unite(2,7); unite(1,2); for(int i = 1;i<=10;i++) cout<<i<<"的等价类:"<<find(i)<<endl; }

程序运行的结果就在这里了,将1,2,4,7合并。 这种解决方法简单易懂,但是我们从复杂度上来讲,我们初始化数组复杂度为Q(n),合并的复杂度也为Q(n),find函数的复杂度为Q(1),但是综合来讲,复杂度未免有些大,那我们引入第二种解决方案。

解决方案二:模拟指针解决

如果将等价类作为一个链表,那么合并操作的复杂度就可大大降低为常数级,在一个等价类中,可以沿着链表的指针找的所有的元素,无需去检查所有的值。但是,如果用链表来解决,我们的find函数的复杂度就会提高,我们需要遍历链表。由此,我们想到了将数组的优势与链表的优势结合起来,通过使用整型指针来解决(也称之为模拟指针)。 那么首先我们来构造模拟指针的结构(struct): 首先,我们的结构中需要等价类的标识符,等价类的大小,以及指向下一个等价类元素的指针。 代码如下:

struct equivNode { int equivClass;//元素类标识符 int size;//类的元素个数 int next;//下一个元素指针 };

这样一来,我们就得到了比较简略的结构。 那么我们要实现数组与链表的结合,我们只需要构造节点类型的数组即可,我们就可以按索引来查找等价类。

equivNode *node;//节点的数组 int n;//元素个数

那么,基本的工作完成,接下来该考虑解决并查集的问题了。 当我们合并等价类是,我们为了尽量降低时间复杂度,我们采用将小的等价类合并到大的等价类中,并将小等价类插入到大等价类的头结点后(为了降低程序的时间复杂度,优化代码)。 接下来我们看看完整的代码:

struct equivNode { int equivClass;//元素类标识符 int size;//类的元素个数 int next;//下一个元素指针 }; equivNode *node;//节点的数组 int n;//元素个数 void initialize(int numberOfElements)//初始化 { n = numberOfElements; node = new equivNode[n+1]; for(int i = 1;i<=n;i++) { node[i].equivClass = e; node[i].next = 0; node[i].size = 1; } } void unite(int classA,int classB)//合并 { if(node[classA].size>node[classB].size) swap(classA,classB); int i; for(i = classA;node[i].next!=0;i=node[i].next) node[i].equivClass = classB; node[i].equivClass = classB; node[classB].size += node[classA].size; node[i].next = node[classB].next; node[classB].next = classA; } void find(int theElement)//查找 { return node[theElement].equivClass; }

至此我们所采用的模拟指针的方案告一段落,但是我们依旧不满意,我们合并操作的复杂度依然没有降低,所以,前两种方案虽然简单,但是效率不高,那么我们给出第三种方案。

解决方案三:树结构解决

这是一块不同于以上解决方案的知识点,我们先来看一看一个集合的树形的描述:我们将一个节点代表一个元素,任何一个元素都可以代表根节点,剩余元素的任何子集都可以作为根元素的孩子,在剩余的元素可以作为根元素的孙子… 我们如果要实现并查集,我们的求解策略是将每一个等价类表示为一个数。在这里,我们要用到上边用到的虚拟指针,,而且我们用到的是数的链表描述。但这里,我们的实现方法与树有些不同,我们是从某个节点得到其所在的等价类,也就是说从子节点指向父节点。我们的每个节点需要一个父域,域内的数字我们采用整型数。 我们来看看代码:

int *parent; void initialize(int numberOfElements) { parent = new int[numberOfElements+1]; for(int i = 1;i<=numberOfElements;i++)//初始化 parent[i] = i; } void unite(int rootA,int rootB) { parent[rootB] = rootA; } int find(int theElement) { while (parent[theElement] != 0) theElement = parent[theElement]; return theElement; }

在这种数据结构中,我们可以看到,合并函数unite()的复杂度降到了Q(1),查找函数find的复杂度为Q(h),h为树的高度,均为常数级的复杂度,效率相比前两种大大提高。

学艺不精,可能未完,待续…

最新回复(0)