用JS实现并查集

1,940 阅读7分钟

摘要

并查集是一种用于解决数据集之间是否连通的(是否有关联关系)的一种十分高效的数据结构。比如在一个网络中,不同的终端之间有可能存在连接关系。如有a,b,c三终端,a连接b,a连接c。那么可以判断a,b,c三者是属于同一个连通分量之中。类似这类的连通关系分析中并查集将是我们常用的工具。根据应用场景,并查集解决的问题有以下两点

  • 给出两个点,判断出这两点是否连通?(假如连通,并不需要给出具体路径,需要给出具体路径的情况需要基于dfs的算法)
  • 给出两个点,将两个点建立起连接关系

(结论请直接翻到文章底部)

关键词 :并查集,连通性分析,路径压缩

思路分析

给定一组数据共有6个不同点,如[[0,3],[1,2,5],[4]] 其中每一个子数组表示两个点之间具有连通关系。具体表示如下图

image.png 从图中可以容易看出这六个点按照连接关系分为了三个组a,b,c。这里先提出几个问题,

  • 在并查集中找到某个节点属于哪个分组
  • 判断两个节点是不是属于同一个分组
  • 将两个节点关联起来(也就是将两个节点的分组合并起来变为一个分组)
  • 获取总共分了多少 因此我们需要建立一个数据结构来解决以下问题
//用来解决不同节点间是否拥有相同根的问题
class UnionFind {
	constructor(n) {
		this.parent = []
		this.count = 0
        this.init(n)
	}
	//初始化一个大小为n的并查集
	init(n) {}
	// 在并查集中找到节点node的分组
	find(node) {}
	//判断两个节点是不是连通的
	same(left, right) {
		return this.find(left) === this.find(right)
	}
	// 将两个节点关联起来
	union(left, right) {
		let index_l = this.find(left)
		let index_r = this.find(right)
		if (index_l !== index_r) {
			/* 这里待补充将两个点连接起来的代码
            .......
            */
            this.count--
            return true
		}
        return false
	}
	//获取总共分了多少组
	getCount() {
		return this.count
	}
}

直观上来说我们用一个一维数组来表示各个点之间所属分组即可。也即['a','b','b','a','c','b']。我们也可以根据连通关系挑选一个点的下标作为分组的标志,如parent= [0,1,1,0,4,1]。也就是说i节点的父节点是parent[i]。表示i和parent[i]具有连接关系。因此我们的init()方法可以这样设计

//初始化一个并查集,每个节点和自身关联
init(n) {
	this.parent.length = 0
	for (let i = 0; i < n; i++) {
		this.parent[i] = i
	}
    this.count=n
}

接下来我们一步一步讨论该怎样设计该数据结构中的其他api。具体有以下几种设计方式

quick-find 算法

从上面提出的UnionFind类中我们可以看出,find()方法的使用频率非常之高,每个关键的方法都需要先使用find()方法。因此我们需要将find()设计的尽可能的高效。直观上看当我们每次调用find()方法时的时间复杂度都为O(1)时肯定最高效。因此find()我们可以设计如下。

find(node) {
		return this.parent[node]
	}

这样调用find()的时间复杂度都是O(1)。这时对应的union()方法为

union(left, right) {
		let index_l = this.find(left)
		let index_r = this.find(right)
		if (index_l !== index_r) {
			for(let i=0;i<this.parent.length;i++){
				// 将所有属于index_l分组的点的组别修改为	index_r
				if(this.parent[i]==index_l){
					this.parent[i]=index_r
				}
			}
			this.count--
			return true
		}
		reurn false
	}

上面的union()方法每次调用都会遍历整个并查集,找到需要修改的节点并修改节点所属的分组。因此假如当新增的路径数目为m,并查集大小为n时,那么时间复杂度为O(m*n)。当数据规模较大时平方阶的复杂度是不太理想的,因此我们需要探索更加高效的union()方法。

quick-union && weighted quick-union 算法

根据对quick-union算法的讨论,我们发现quick-union算法中的union()方法时间复杂度过高,因此这里提出另一种方法---quick-union。该算法设计主要借助树的概念,每个点存储他的父亲节点。当查找分组时不停往上查找直到父亲节点就是自己。如parent=[0,0,1,2]。当我们查找索引为2的节点所属分组的时候,我们先查到它的父亲节点索引为1.因为索引为1的节点的父亲节点不是自己本身,因此继续往上查找,直到找到索引为0的节点。这样我么就可以判断出索引2的节点是和索引0的节点是同一组的。

find(node){
    while(this.parent[node]!==node){
        node=this.parent[node]
    }
    return node 
}
union(left, right) {
	let index_l = this.find(left)
	let index_r = this.find(right)
	if (index_l !== index_r) {
		this.parent[index_l]=index_r
		this.count--
		return true
	}
	return false }

quick-union算法的设计中union()十分的高效,但是find()的效率显然是存在问题的。问题的起因在于如果树的高度很高,那么find()内部的while循环就会调用很多次。那么有没有什么方法能减少树的高度,使得树整体均衡。观察union()方法,我们发现代码存在硬编码的地方,即我们总是将index_l的父节点设置为index_r。也就是说,我们总是把左树给当做一颗子树给连接到右树上,考虑以下情况(左图)

union.jpg 当左树的数目比较大时,如果我们仍将左树当做子树连接到右树上。那么整棵树的高度是变高的。我们换个思路考虑,每次我们在进行合并的时候,根据每棵树的大小选择使用

this.parent[index_l]=index_r还是this.parent[index_r]=index_l。即我们总是将小的树作为子树连接到大的树上。如上图右所示。这样整棵树的高度将会比较均衡,这样有利于提升find()的效率。具体实现我们可以在初始化时使用一个size数组来存储每个节点下有多少个节点。初始值为1。int()和union()的编码如下


    //初始化一个并查集
    init(n) {
        this.parent.length = 0
        for (let i = 0; i < n; i++) {
            this.parent[i] = i
		}
		this.count=n
        this.size = new Array(n).fill(1)
    }
  

  // 将两个节点关联起来,即两个节点共有一个根节点.和并是将两个节点的根节点合并起来
    union(left, right) {
        let l = this.find(left)
        let r = this.find(right)
        if (l != r) {
            // 左边较小,因此将左边合并到右边的树上
            if (this.size[l] < this.size[r]) {
                this.parent[l] = r
                this.size[r] += this.size[l]
            } else {
                this.parent[r] = l
                this.size[l] += this.size[r]
            }
            this.count--
            return true
        }
        return false
    }

路径压缩

经过上面的分析后,weighted quick-union 算法已经是一个不错的设计。其不仅使得union()的复杂度更低,同时也使得节点之间的关系树尽量的扁平,以帮助find()更快的执行。基于此,我们应该想到最好的情况是每棵树最好最有两层,即扁平为一颗高度为2的树,这样的话每次find()的时候我们使用O(1)的复杂度很快就能得出结果。那么这个如何做到呢?其实非常简单。先看代码

  // 在并查集中找到节点node的根节点
  find(node) {
  	while (node != this.parent[node]) {
  		// 路径压缩,每次查找时都将子节点的父节点设置为父节点的父节点。这样能够不停的扁平化查询树。
  		this.parent[node] = this.parent[this.parent[node]]
        
  		node = this.parent[node]
  	}
  	return node
  }

重点是第三行,每次我们查找上级节点的时候就将当前节点的父亲节点设为他的爷爷节点。这样它就直接与爷爷相连接。这样就减少了一层高度。当执行到一定程度的时候。整棵树一定只有两层。这样的话对find()来说更有效率了。

结论代码,js的并查集

class UnionFind {
	//用来解决不同节点间是否拥有相同根的问题
	constructor(n) {
		this.parent = [] //并查集
		this.size = [] //每个节点下拥有的总节点数目
		this.count = 0
		this.init(n)
	}
	//初始化一个并查集
	init(n) {
		this.parent.length = 0
		for (let i = 0; i < n; i++) {
			this.parent[i] = i
		}
		this.count = n
		this.size = new Array(n).fill(1)
	}
         // 在并查集中找到节点node的根节点
     find(node) {
        while (node != this.parent[node]) {
            // 路径压缩,每次查找时都将子节点的父节点设置为父节点的父节点。这样能够不停的扁平化查询树。
         this.parent[node] = this.parent[this.parent[node]]
		 node = this.parent[node]
        }
        return node
    }
	//判断两个节点的根节点是不是同一个
	same(left, right) {
		return this.find(left) == this.find(right)
	}
	// 将两个节点关联起来,即两个节点共有一个根节点.和并是将两个节点的根节点合并起来
	union(left, right) {
		let l = this.find(left)
		let r = this.find(right)
		if (l != r) {
			// 左边较小,因此将左边合并到右边的树上
			if (this.size[l] < this.size[r]) {
				this.parent[l] = r
				this.size[r] += this.size[l]
			} else {
				this.parent[r] = l
				this.size[l] += this.size[r]
			}
			// 连通分量减1
			this.count--
			return true
		}
		return false
	}
    //获取总共分了多少组
	getCount() {
		return this.count
	}
}

参考资料

dm_vincent大佬的博文