Python数据结构与算法(十七、并查集)

保证一周更两篇吧,以此来督促自己好好的学习!代码的很多地方我都给予了详细的解释,帮助理解。好了,干就完了~加油!
声明:本python数据结构与算法是imooc上liuyubobobo老师java数据结构的python改写,并添加了一些自己的理解和新的东西,liuyubobobo老师真的是一位很棒的老师!超级喜欢他~
如有错误,还请小伙伴们不吝指出,一起学习~
No fears, No distractions.

一、什么是并查集(Union-find)

  1. 并查集是一种很不一样的数据结构!表现为若干个多叉树,但是每个树都是由孩子指向父亲!是的,你没有听错。由孩子指向父亲,一直到最终的根节点。
  2. 并查集的使用:
    (1) 判断网络中节点间的连接状态,注意是广义的网络,并非特指互联网。。
    (2) 数学中集合的应用,尤其是求并集。注意是数学中的集合~
    (3) 能够解决路径问题。
  3. 主要支持两个操作:
    (1) union(p, q) --------------将p与q所处的两个集合连通。
    (2) isConnected(p, q)------判断p与q所处的两个集合是否是连通的。
  4. 图示:
  5. 看到了吧,图里面有两个集合。集合1中[1, 3, 5, 7, 9]是连通的,它们有共同的根1,。集合2中[2, 4, 6, 8, 10]是连通的,它们有共同的根6。一个显著的特点就是都是由孩子指向父亲的!而根节点指向自己,这样找根节点就很方便了:只要父亲节点不是自己就继续往根节点的方向去找就完事了。
  6. 虽然并查集表现上是一棵树,但是我们在这里用数组就能表示它了!这样就方便了太多太多。数组的位置信息(也就是index)代表了孩子,其值(array[index])为父亲节点的index。好了,话不多说,让我们一点一点深入的去了解并查集吧!

二、并查集的实现

版本1:最直观、简单的版本(quick find)

这种表现方法就是每个元素的根就是最终的根节点,如果本身就是根节点,那根节点就是自己。这种情况判断两个元素是否是连通的只需O(1)时间,很快,因为父亲节点就是根节点。但是连通元素的时候就需要遍历一遍全部元素,将属于这个集合的元素的根全部设为另外一个元素的根,时间复杂度为O(n)。正是因为查找到根节点很快,所以也叫quick find。

# -*- coding: utf-8 -*-
# Author:           Annihilation7
# Date:             2019-01-13   10:44 am
# Python version:   3.6 


class Union_Find1:
    def __init__(self, length):
        # 初始化的时候让所有元素的集合都不是连接的,指向自己就可以了,毕竟自己就是根嘛。
        self.id = [i for i in range(length)] 
        self.length = length

    def getSize(self):
        return self.length

    def find(self, p): 
        """
        查找元素p所对应的集合的编号
        时间复杂度:O(1)
        Params:
            -p: 输入的索引p
        Returns:
            所属集合的编号
        """
        assert 0 <= p < self.length, 'p is out of bound'  # 索引合法性检查
        return self.id[p]   # 返回p所在集合的编号

    def isConnected(self, p, q):
        """
        查看元素p和元素q是否处于同一集合中
        时间复杂度:O(1)
        Params:
            -p: 输入的索引p
            -q: 输入的索引q
        Returns:
            若处于同一集合则返回True,否则返回False。
        """
        return self.find(p) == self.find(q)  # 直接调用find函数找各自所属集合,然后判断是否相等。合法性不用检查了,find里面有检查的功能。

    def unionElements(self, p, q):
        """
        将元素p和元素q两者所属的集合并起来。
        时间复杂度:O(n)
        Params:
            -p: 输入的索引p
            -q: 输入的索引q
        """
        pid = self.find(p)
        qid = self.find(q)

        if pid == qid:
            # 如果本身就是处于同一集合,则直接返回
            return 

        for i in range(self.length):
            if self.id[i] == pid:   # 至于到底是全变成0还是1不重要,只要联通了即可。
                self.id[i] = qid  # 我这里是将集合pid中的元素全部指向qid,也可以反过来,思想都是一样的。

    def printUnion(self):
        for i, root in enumerate(self.id):
            if i != self.length - 1:
                print('{}->{}'.format(i, root), end=',', flush=True)
            else:
                print('{}->{}'.format(i, root))


if __name__ == '__main__':
    test_union1 = Union_Find1(10)
    test_union1.printUnion()
    test_union1.unionElements(0, 9)
    test_union1.printUnion()
    print(test_union1.isConnected(0, 9))

改进2:改进unionElements函数

前面我们看到了unionElements需要O(n)的时间复杂度,下面我们来对上面的数据结构进行小幅改进。

此时find操作的时间复杂度为O(h),h为树的高度,因此unionElements的时间复杂度也为O(h)了。复杂度降低。

# -*- coding: utf-8 -*-
# Author:           Annihilation7
# Date:             2019-01-13   11:27 am
# Python version:   3.6 


class Union_Find2:
    def __init__(self, length):
        self.parent = [i for i in range(length)]
        # 初始自己指向自己。
        self.length = length 

    def getSize(self):
        return self.length

    def find(self, p):
        """
        查找元素p所对应的集合编号。
        时间复杂度:O(h),h为树的高度
        Params:
            -p:输入的索引p
        Returns:
            元素p多处树的根节点
        """
        assert 0 <= p < self.length, 'p is out of bound'  # 索引合法性检查
        
        while self.parent[p] != p:  # 如果没到根节点
            p = self.parent[p]   # 就继续往根节点的方向走
        return p   # 这时p已经到达根节点,返回p就好

    def isConnected(self, p, q):
        """
        判断元素p和q是否处于同一集合中
        时间复杂度: O(h),h为树的高度
        Params:
            -p: 输入的索引p
            -q: 输入的索引q
        Returns:
            若处于同一集合则返回True,否则返回False。
        """
        pRoot = self.find(p)  # 找到各自的根节点
        qRoot = self.find(q)

        return pRoot == qRoot  # 判断是否是同一个根节点

    def unionElements(self, p, q):
        """
        将元素p和元素q两者所属的集合并起来。
        时间复杂度:O(h)
        Params:
            -p: 输入的索引p
            -q: 输入的索引q
        """
        pRoot = self.find(p)
        qRoot = self.find(q)

        if pRoot == qRoot:  # 如果是同一个根的话直接返回
            return 

        self.parent[pRoot] = qRoot  # 此时将元素p的根节点指向q的根节点,从而完成连接操作。
        # 也可以:self.paraent[qRoot]=pRoot,是一样的。只不过指向发生改变,又q的根节点指向了p的根节点。

    def print(self):
        for i in range(self.length):
            print(i, self.parent[i])


if __name__ =='__main__':
    tests = Union_Find2(10)
    a = [(1, 2), (3, 4), (5, 6), (7, 8), (2, 6), (4, 7), (3, 9)]
    for elem in a:
        tests.unionElements(elem[0], elem[1])
    tests.print()

改进3:基于size的改进

前面的改进版本确实对unionElements进行了一定程度的改进,但是在执行最后的根节点指向操作的时候并不对两个集合进行区分,这样做可能会导致一个问题就是将一棵树高度很高的树的根节点指向了一棵树高度很低的根节点,这样新的集合的树高增加了1。那么在find的时候又会多一点时间。所以该版本先基于集合的size进行改进。

每次在unionElements的时候都会判断两个集合的size,完成合理的根节点指向。

# -*- coding: utf-8 -*-
# Author:           Annihilation7
# Date:             2019-01-13   12:14 pm
# Python version:   3.6 


class Union_Find3:
    def __init__(self, length):
        self.parent = [i for i in range(length)]
        # 这里的for循环和前面的Union_Find1虽然一样,但是意义不同了,这里的意思是初始化的时候其父亲节点就是本身,也就是
        # 自己指向自己。
        self.sz = [1 for i in range(length)] 
        # 新增成员变量sz,记录self.parent中每个元素所在树的元素个数,由于self.parent在初始化的时候所有元素都没有交集,
        # 所以self.sz的初始化就全为1,即每个self.parent中每个元素都是独立的。
        self.length = length 

    def getSize(self):
        return self.length

    def find(self, p):
        """
        查找元素p所对应的集合编号。
        时间复杂度:O(h),h为树的高度
        Params:
            -p:输入的索引p
        Returns:
            元素p多处树的根节点
        """
        assert 0 <= p < self.length, 'p is out of bound'  # 索引合法性检查
        
        while self.parent[p] != p:  # 如果没到根节点
            p = self.parent[p]   # 就继续往根节点的方向走
        return p   # 这时p已经到达根节点,返回p就好

    def isConnected(self, p, q):
        """
        判断元素p和q是否处于同一集合中
        时间复杂度: O(h),h为树的高度
        Params:
            -p: 输入的索引p
            -q: 输入的索引q
        Returns:
            若处于同一集合则返回True,否则返回False。
        """
        pRoot = self.find(p)  # 找到各自的根节点
        qRoot = self.find(q)

        return pRoot == qRoot  # 判断是否是同一个根节点

    def unionElements(self, p, q):
        """
        将元素p和元素q两者所属的集合并起来。这里就会用到self.sz成员变量了
        时间复杂度:O(n)
        Params:
            -p: 输入的索引p
            -q: 输入的索引q
        """
        pRoot = self.find(p)
        qRoot = self.find(q)

        if pRoot == qRoot:  # 如果是同一个根的话直接返回
            return 

        # 在这里我们不再是随便的将两个根节点任意的指向了,而要根据self.sz来判断。
        if self.sz[pRoot] < self.sz[qRoot]:  # 如果p所在树的元素数目小于q所在树的元素数目
            self.parent[pRoot] = qRoot    # 将小树的根指向大树的根
            self.sz[qRoot] += self.sz[pRoot]  # 此时要维护self.sz,由于pRoot指向了qRoot,所以self.sz[qRoot]要加上pRoot树的全部元素个数
        else:
            self.parent[qRoot] = pRoot      # 反之同理
            self.sz[pRoot] += self.sz[qRoot]

    def print(self):
        for i in range(self.length):
            print(i, self.parent[i])


if __name__ =='__main__':
    tests = Union_Find3(10)
    a = [(1, 2), (3, 4), (5, 6), (7, 8), (2, 6), (4, 7), (3, 9)]
    for elem in a:
        tests.unionElements(elem[0], elem[1])
    tests.print()

改进4:基于rank的改进

前面我们讲了基于size的改进,但是size有一个缺点就是万一有一个集合的形式像链表一样,size是5。而两一个集合有10个元素,即size为10.合并的时候就把这个像链表一样的集合的根节点指向了另一个,此时合并后的树高右增加了1,为了避免这种情况(虽然概率很小),我们来基于rank改进,可以理解成是树高,但是为什么不说基于height的改进,后面会提到的。

每次在unionElements的时候都会根据两个集合的rank进行合理的根节点指向。

# -*- coding: utf-8 -*-
# Author:           Annihilation7
# Date:             2019-01-13   12:32 pm
# Python version:   3.6 


class Union_Find4:
    def __init__(self, length):
        self.parent = [i for i in range(length)]
        # 这里的for循环和前面的Union_Find1虽然一样,但是意义不同了,这里的意思是初始化的时候其父亲节点就是本身,也就是
        # 自己指向自己。
        self.rank = [1 for i in range(length)]
        # 新增成员变量rank,记录self.parent中每个元素所在树的高度,由于self.parent在初始化的时候所有元素都没有交集,
        # 所以self.sz的初始化就全为1,即所有树的高度都为1,每个self.parent中每个元素都是独立的
        self.length = length 

    def getSize(self):
        return self.length

    def find(self, p):
        """
        查找元素p所对应的集合编号。
        时间复杂度:O(h),h为树的高度
        Params:
            -p:输入的索引p
        Returns:
            元素p多处树的根节点
        """
        assert 0 <= p < self.length, 'p is out of bound'  # 索引合法性检查
        
        while self.parent[p] != p:  # 如果没到根节点
            p = self.parent[p]   # 就继续往根节点的方向走
        return p   # 这时p已经到达根节点,返回p就好

    def isConnected(self, p, q):
        """
        判断元素p和q是否处于同一集合中
        时间复杂度: O(h),h为树的高度
        Params:
            -p: 输入的索引p
            -q: 输入的索引q
        Returns:
            若处于同一集合则返回True,否则返回False。
        """
        pRoot = self.find(p)  # 找到各自的根节点
        qRoot = self.find(q)

        return pRoot == qRoot  # 判断是否是同一个根节点

    def unionElements(self, p, q):
        """
        将元素p和元素q两者所属的集合并起来。这里就会用到self.sz成员变量了
        时间复杂度:O(n)
        Params:
            -p: 输入的索引p
            -q: 输入的索引q
        """
        pRoot = self.find(p)
        qRoot = self.find(q)

        if pRoot == qRoot:  # 如果是同一个根的话直接返回
            return 

        # 在这里我们不再是随便的将两个根节点任意的指向了,而要根据self.rank来判断
        if self.rank[pRoot] < self.rank[qRoot]:  # 如果pRoot树的深度小于qRoot树,就将pRoot指向qRoot,注意此时不用维护self.rank,因为合并后的树的高度并没有增加!
            self.parent[pRoot] = qRoot
        elif self.rank[qRoot] < self.rank[pRoot]: # 同理,不再赘述
            self.parent[qRoot] = pRoot
        else:
            self.parent[pRoot] = qRoot  # 此时两棵树的高度是一样的,那么谁指向谁都行,这里我是让pRoot指向qRoot了。
            self.rank[qRoot] += 1 # 此时要维护self.rank了,因为两个高度相同的树其根节点合并后高度比加1!画个图就知道啦。 

    def print(self):
        for i in range(self.length):
            print(i, self.parent[i])


if __name__ =='__main__':
    tests = Union_Find4(10)
    a = [(1, 2), (3, 4), (5, 6), (7, 8), (2, 6), (4, 7), (3, 9)]
    for elem in a:
        tests.unionElements(elem[0], elem[1])
    tests.print()

改进5:路径压缩

前面基于rank的改进性能已经非常可观,但是我们只要在前面实现的基于rank的并查集的代码中加一条语句,就能更大程度的改进并查集的性能!那就是路径压缩。它改进的地方在于find函数。

核心代码就1句:parent[p] = parent[parent[p]],即跳元素向根节点前进,前进的同时还把爷爷节点变成p的父亲节点,即调整了树的结构,使树的高度降低。而且边界条件也不用检查,因为根节点是指向自己的,不存在越界神马的。有人会问了:那rank还是否有效?代码中有解释~看代码就完事了嗷。

# -*- coding: utf-8 -*-
# Author:           Annihilation7
# Date:             2019-01-13   1:20 pm
# Python version:   3.6 


class Union_Find5:
    def __init__(self, length):
        self.parent = [i for i in range(length)]
        # 这里的for循环和前面的Union_Find1虽然一样,但是意义不同了,这里的意思是初始化的时候其父亲节点就是本身,也就是
        # 自己指向自己。
        self.rank = [1 for i in range(length)]
        # 新增成员变量rank,记录self.parent中每个元素所在树的高度,由于self.parent在初始化的时候所有元素都没有交集,
        # 所以self.sz的初始化就全为1,即所有树的高度都为1,每个self.parent中每个元素都是独立的
        self.length = length 

    def getSize(self):
        return self.length

    def find(self, p):
        """
        查找元素p所对应的集合编号。
        时间复杂度:O(h),h为树的高度
        Params:
            -p:输入的索引p
        Returns:
            元素p多处树的根节点
        """
        assert 0 <= p < self.length, 'p is out of bound'  # 索引合法性检查
        
        while self.parent[p] != p:  # 如果没到根节点
            self.parent[p] = self.parent[self.parent[p]]   # 路径压缩代码,就2行!!
            # 代码很简单,就是先判断到没到根节点,然后将爷爷节点设为父亲节点,然后来到更新后的父亲节点,
            # 树高减一继续判断,如果还能压缩,就继续压缩,树高继续减一。。可以画个图,很直观
            # 这里没有什么边界条件神马的,也不需要,因为根节点都是指向自己的,不会出bug!
            # 另外还有一个问题,就是self.rank的维护方法是不是要变复杂了呢?因为find的过程中都把树的深度改变了呀。。
            # 答案是不需要对self.rank进行维护,第一维护成本变高,第二也是为什么叫它rank而不是height的原因。
            # 在有路径压缩的时候,rank更多的是在union的时候提供一种策略,而非代表树的真正高度!但是它的逻辑
            # 还是对的。也就是说现在的rank并不是某个集合的真实深度,但是能够反应出这个集合的深度的相对大小。
            p = self.parent[p]   # 就继续往根节点的方向走
        return p   # 这时p已经到达根节点,返回p就好

    def isConnected(self, p, q):
        """
        判断元素p和q是否处于同一集合中
        时间复杂度: O(h),h为树的高度
        Params:
            -p: 输入的索引p
            -q: 输入的索引q
        Returns:
            若处于同一集合则返回True,否则返回False。
        """
        pRoot = self.find(p)  # 找到各自的根节点
        qRoot = self.find(q)

        return pRoot == qRoot  # 判断是否是同一个根节点

    def unionElements(self, p, q):
        """
        将元素p和元素q两者所属的集合并起来。这里就会用到self.sz成员变量了
        时间复杂度:O(n)
        Params:
            -p: 输入的索引p
            -q: 输入的索引q
        """
        pRoot = self.find(p)
        qRoot = self.find(q)

        if pRoot == qRoot:  # 如果是同一个根的话直接返回
            return 

        # 在这里我们不再是随便的将两个根节点任意的指向了,而要根据self.rank来判断
        # 前面讲过了,在有路径压缩的时候,向以前一样的方式维护self.rank就好。
        if self.rank[pRoot] < self.rank[qRoot]:  # 如果pRoot树的深度小于qRoot树,就将pRoot指向qRoot,注意此时不用维护self.rank,因为合并后的树的高度并没有增加!
            self.parent[pRoot] = qRoot
        elif self.rank[qRoot] < self.rank[pRoot]: # 同理,不再赘述
            self.parent[qRoot] = pRoot
        else:
            self.parent[pRoot] = qRoot # 此时两棵树的高度是一样的,那么谁指向谁都行,这里我是让pRoot指向qRoot了。
            self.rank[qRoot] += 1 # 此时要维护self.rank了,因为两个高度相同的树其根节点合并后高度比加1!画个图就知道啦。 

    def print(self):
        for i in range(self.length):
            print(i, self.parent[i])


if __name__ =='__main__':
    tests = Union_Find5(10)
    a = [(1, 2), (3, 4), (5, 6), (7, 8), (2, 6), (4, 7), (3, 9), (1, 7)]
    for elem in a:
        tests.unionElements(elem[0], elem[1])
    tests.print()

改进6:压缩的更狠一点。。。

话不多说,直接上图。

find后直接让树高变为1!但是需要借助递归函数来实现,性能上具体表现怎么样还是要看当时的计算机的环境的,因为递归栈的调用太频繁性能可能还不如改进5呢。但是也是一种很好的路径压缩思路嘛!这种方法一般是用不到的,改进5已经很优秀了。改进6有时候的性能还没有改进5好呢。所以这种了解就好。

# -*- coding: utf-8 -*-
# Author:           Annihilation7
# Date:             2019-01-13   1:20 pm
# Python version:   3.6 


class Union_Find6:
    def __init__(self, length):
        self.parent = [i for i in range(length)]
        # 这里的for循环和前面的Union_Find1虽然一样,但是意义不同了,这里的意思是初始化的时候其父亲节点就是本身,也就是
        # 自己指向自己。
        self.rank = [1 for i in range(length)]
        # 新增成员变量rank,记录self.parent中每个元素所在树的高度,由于self.parent在初始化的时候所有元素都没有交集,
        # 所以self.sz的初始化就全为1,即所有树的高度都为1,每个self.parent中每个元素都是独立的
        self.length = length 

    def getSize(self):
        return self.length

    def find(self, p):
        """
        查找元素p所对应的集合编号。
        时间复杂度:O(h),h为树的高度
        Params:
            -p:输入的索引p
        Returns:
            元素p多处树的根节点
        """
        assert 0 <= p < self.length, 'p is out of bound'  # 索引合法性检查

        if self.parent[p] != p:
            self.parent[p] = self.find(self.parent[p])   # 递归得到p的根节点,然后赋值给p的父亲节点。
        return self.parent[p]  

    def isConnected(self, p, q):
        """
        判断元素p和q是否处于同一集合中
        时间复杂度: O(h),h为树的高度
        Params:
            -p: 输入的索引p
            -q: 输入的索引q
        Returns:
            若处于同一集合则返回True,否则返回False。
        """
        pRoot = self.find(p)  # 找到各自的根节点
        qRoot = self.find(q)

        return pRoot == qRoot  # 判断是否是同一个根节点

    def unionElements(self, p, q):
        """
        将元素p和元素q两者所属的集合并起来。这里就会用到self.sz成员变量了
        时间复杂度:O(n)
        Params:
            -p: 输入的索引p
            -q: 输入的索引q
        """
        pRoot = self.find(p)
        qRoot = self.find(q)

        if pRoot == qRoot:  # 如果是同一个根的话直接返回
            return 

        # 在这里我们不再是随便的将两个根节点任意的指向了,而要根据self.rank来判断
        # 前面讲过了,在有路径压缩的时候,向以前一样的方式维护self.rank就好。
        if self.rank[pRoot] < self.rank[qRoot]:  # 如果pRoot树的深度小于qRoot树,就将pRoot指向qRoot,注意此时不用维护self.rank,因为合并后的树的高度并没有增加!
            self.parent[pRoot] = qRoot
        elif self.rank[qRoot] < self.rank[pRoot]: # 同理,不再赘述
            self.parent[qRoot] = pRoot
        else:
            self.parent[pRoot] = qRoot  # 此时两棵树的高度是一样的,那么谁指向谁都行,这里我是让pRoot指向qRoot了。
            self.rank[qRoot] += 1 # 此时要维护self.rank了,因为两个高度相同的树其根节点合并后高度比加1!画个图就知道啦。 

    def print(self):
        for i in range(self.length):
            print(i, self.parent[i])


if __name__ =='__main__':
    tests = Union_Find6(10)
    a = [(1, 2), (3, 4), (5, 6), (7, 8), (2, 6), (4, 7), (3, 9), (1, 7)]
    for elem in a:
        tests.unionElements(elem[0], elem[1])
    tests.print()

三、总结

  1. 并查集一步一步的优化就这么过来啦,其实都很简单,最重要的是改进5版本哦,一定要回,一点也不难。。
  2. 下期做一个他们6个之间的性能测试吧。
  3. 关于O(h)的时间复杂度,这里面的证明是非常困难的,大家记住性能接近O(1)就可以了,比O(logn)性能要好很多。具体证明我这个菜逼也不会哦。

若有还可以改进、优化的地方,还请小伙伴们批评指正!

你可能感兴趣的:(python数据结构与算法,Python数据结构与算法)