【数据结构之并查集】图论的序言

840 阅读7分钟

到这里,我准备讲的所有基础数据结构都介绍完了,上一篇文章我们介绍了红黑树,我们说红黑树有着非常广泛的应用,是20世纪最有影响力的数据结构之一。

但是,树这种数据结构更擅长于处理随机的数据,不擅长处理关系和连通这类数据。而对于连通和关系这类数据使用图这种数据结构会更加合适,甚至在某些情况下只能使用图来解决。

这篇文章我们来做一下预热,先介绍一种解决连通性的数据结构,也就是并查集。那么并查集到底可以解决什么问题呢?如果你使用过qq就一定对qq的好友推荐功能有所了解,假如你和令狐冲是qq好友,令狐冲又有一个qq好友叫任盈盈,但你和任盈盈并不是qq好友,假如你现在要设计一个功能,需要将令狐冲的好友推荐给你自己,应该怎么做?

我们今天讲的并查集其实就可以解决这个问题。当然,在后面你会发现这种场景使用图来解决可能会更加优雅,我们在下一节讲图论的时候再具体讨论。

假如现在有两个元素,我们要知道这两个元素是否是连通的,我们可以先将这两个元素进行一次并的操作,然后我们就可以查看这两个元素是否连通,这种数据结构就是并查集,这样可能还是有点抽象,下面我们一步一步来实现一个并查集。

我们用数组来作为并查集的底层数据结构,如下图:

图片

在一开始,我们声明一个数组,value值和下标一样,然后我们修改value,如果value相等表示连通。例如我们要将下标1和2连通起来,我们就可以将下标为2的值改为1,如下图:

图片

接下来,如果我们要将0和1进行连通,我们可以将下标为0的value修改为1,也可以将下标1和2的value修改为0,如下图:

图片

这样,我们就将0和1连通了,并且1和2也联通了,这个操作就是并查集中的并操作,代码如下:

public void union (int p, int q) {    
    int pIN = find(p);    
    int qIN = find(q);   
    
    if (pIN == qIN)         
        return;    
    for (int i=0; i<id.length; i++)         
        if (id[i] == pIN)             
            id[i] = qIN;
}

private int find(int i) {    
    if (i < 0 || i >= id.length)         
        throw new IllegalArgumentException("Index out of range.");    
    return id[i];
}

我大概解释一下这段代码,find函数表示查找当前下标对应的值,union函数是将两个下标连通,连通的方法是先找到两个下标对应的值,如果相等说明已经连通了,直接返回。如果不相等,我们只需要将value赋值成其中一个下标对应的value就可以了。要注意的是,如果被赋值的下标有和它相连通的值也要一并修改。所以,我们看到并操作修改value是一个循环。

下面我们来看一下判断两个元素是否相连通,我们先看图:

图片

我们需要找到两个下标对应的值,如果相等,说明连通否则不连通。代码非常简单:

public boolean isConnect(int p, int q) {    
    return find(p) == find(q);
}

好了,到此一个简单的并查集就实现完成了。但是,我们分析一下上面的实现其实在效率上是有问题的。比如,上面我们将0和1连通的时候,更新了1和2下标的值,但其实更好的方法应该是只更新下标0的值,将其更新为1效率是最高的。

我们可以考虑一下,两个元素相连通实际上并不一定要将所连通的所有元素的值都更新一遍,我们下面介绍另一种更高效的实现方式。

我们还是使用数组来作为底层的数据结构,一开始,我们还是假设所有元素都指向自己,如果对两个元素进行并操作,就将其中一个元素指向另一个元素,就像下面这样:

图片

所有元素一开始都指向自己,此时value值保存的是对应元素的下标。例如上面我们将3和4连通起来,我们可以将3的下标改为4,此时3和4就是连通的了。下面我们看下复杂一些的情况,如下图:

图片

我们陆续进行了多次并操作,此时,我们发现相互连通的元素形成了一颗树,我们看一下这种实现方式的并操作相关的代码:

public void union (int p, int q) {    
    int pIN = find(p);    
    int qIN = find(q);
    if (pIN == qIN)         
        return;        
    parent[qIN] = parent[pIN];
}

注意这里的并操作和我们上面的并操作的区别,代码少了一次循环,这是我们提升效率的关键,此时的并操作我们可以将时间复杂度稳定在O(1)的时间复杂度。

接着我们看优化之后的查操作:

private int find(int i) {    
    if (i < 0 || i >= parent.length)         
        throw new IllegalArgumentException("Index out of range.");    
    while(i != parent[i])        
        i = parent[i];    
    return i;
}

此时,判断两个元素是否连通是找到两个元素是否有相同的根节点,查操作的时间复杂度是O(h),h是我们这颗最大的那个高度,想象一下,在极端情况下我们的树会变得极度不平衡,也就是h的值会变得很大,那么还有没有更好的方法去优化呢? 找到了瓶颈也就相应的找到了解决方法,我们要解决的是降低h的高度。

我们使用一个size数组来记录h的高度,如下:

public UnionFindv(int size) {    
    parent = new int[size];    
    sz = new int[size];    
    for (int i=0; i<size; i++) {        
        parent[i] = i;        
        sz[i] = 1;    
    }
}

我们定义了个sz的成员变量,是一个数组,一开始所有下标的h都为1,然后我们并操作的时候就可以去维护对应的h了,最新的并操作代码如下:

public void union (int p, int q) {    
    int pIN = find(p);    
    int qIN = find(q);
    
    if (pIN == qIN)         
        return;
        
    // size优化, 尽量处理成一个平衡树,避免退化成链表    
    if (sz[pIN] > sz[qIN]) {        
        parent[qIN] = parent[pIN];        
        sz[pIN] += sz[qIN];    
    } else {        
        parent[pIN] = parent[qIN];        
        sz[qIN] += sz[pIN];    
    }
}

当被指向之后我们要维护一下h,就是这个元素原有的h加上指向自己元素的h,并且我们在指向对应元素之前会先判断h,将h比较小的元素指向上h比较大的元素。

我们再回顾一下优化后的查操作,我们说找到相同的根说明是连通的,上面我们基于size的优化其实还是会有查找h的问题,h有可能是大于1的,那么我们是否可以进一步优化呢?答案是可以的,我们可以基于rank进行优化,也就是在并操作的时候我们查一下h的值,根据h的值来进行接下的操作,我们直接贴代码:

public void union (int p, int q) {    
    int pIN = find(p);    
    int qIN = find(q);
    
    if (pIN == qIN)         
        return;
        
    // rank优化, 尽量保持深度一致    
    if (rank[pIN] > rank[qIN]) {        
        parent[qIN] = parent[pIN];    
    } else if (rank[pIN] < rank[qIN]){        
        parent[pIN] = parent[qIN];    
    } else {        
        parent[pIN] = parent[qIN];        
        rank[qIN]++;    
    }
}

有了上面基于size的优化之后基于rank的优化相对来说比较简单,你可以对照代码思考一下。

那么除了在并操作基于rank代码我们还有优化空间吗?答案是:有的。我们看一下上面基于rank的优化,其实可能还是会存在h大于1的情况。具体你可以对照代码观察一下。

此时,我们可以在查操作的时间进行一次路径压缩,可以让我们的查操作达到O(1)级别的时间复杂度,代码如下:

// 时间复杂度为h, h表示树的高度
private int find(int i) {    
    if (i < 0 || i >= parent.length)         
        throw new IllegalArgumentException("Index out of range.");
    
    while(i != parent[i]) {        
        // 路径压缩        
        parent[i] = parent[parent[i]];        
        i = parent[i];    
    }   
    
    return i;
}

可以看到,我进行了一次while循环还压缩路径,但此时的查操作时间复杂度依然还是O(h),是因为我们要进行路径的压缩操作。但是,这个时候的并查集效率已经是非常高的了。

我们回答一下文章开一开始我们提出的问题,如何实现将我的qq好友令狐冲的qq好友推荐给自己。我们可以维护一层根节点到子节点的关系,然后找到我和令狐冲的根节点,然后从根到叶子遍历这颗树,就可以得到所以有和我连通以及和令狐冲连通的所有好友了,但这样其实是有一个小缺陷,就是我自己的好友也会算进去,而实际推荐好友这种场景不应该让已经是好友的人出现在推荐好友的列表,这个在后面学完图之后就可以完美解决了。

总结一下,并查集是一种高级的数据结构,但是你看到了,其实相对来说是比较简单的。同时,我们使用了数组作为实现并查集的底层数据结构,这再次说明了基础数据结构的重要性,很多时候一些高级的数据结构无一例外都是由基础的数据结构堆积而成的,包括我们下一篇文章要讲到的图这种数据结构。