并查集

513 阅读4分钟

本文已参与「新人创作礼」活动,一起开启掘金创作之路

并查集

本书详细阐述了并查集的提出与优化; 从提出到找到最优解, 并查集共经历了四个版本: quick-find, quick-union, 加权quick-union和使用路径压缩的加权quick-union;

1. 并查集概念等等

  1. 并查集的目的: 就像他的名字一样, 并(union)和查(find), 是要实现将两个集合快速并在一起, 以及快速从集合中查找到某两个元素是否相连的一种数据结构, 算法第四版中给出了一幅很具象的图, 即下图, 我们可以较为清楚地看到左下角有一个孤立起来的连通分量, 在图中也可以找到一个孤立的点, 这也许很轻松, 但是如果要判断某两个点是否连通, 则非常地困难, 并查集就是来解决这些问题的;
    image-20220203160545689

  2. 并查集的组成: 首先有两个基本的东西: 表示身份的数组id[], 表示当前有多少个集合的count(研究的对象的基本元素是上图的点, 在并查集的研究中, 用id[]来表示那些点); 其次就是几个方法: union(int p, int q), 负责将p和q背后的两个集合连通起来, find(int p), 负责找到p对应的代表节点, connected(int p, int q), 负责判断这两个点是否连通;

    所以最终的代码如下:

    public class UF {
        private int[] id;   
        private int count;   
        public UF(int N) {
            //并查集的初始化
            id = new int[N];   
            count = N;   
            for(int i = 0; i < N; i++) {
                id[i] = i;   
            }
        }
        
        public int size() {
            return count;   
        }
        
        public int find(int p) {}
        
        public boolean connected(int p, int q) {}
        
        public void union(int p, int q){}
    }
    

    而可以优化的就是那三个方法;

  3. 并查集版本的简述: 每一个并查集的版本的优点都展现在了他们的名字里, quick-find版本的find( )方法只需访问一次数组, quick-union版本的union( )方法也只需要访问一次数组, 加权quick-union版本对union( )方法进行了优化, 使其在合并集合时更加智能, 路径压缩使得find( )和connected( )接近常数项级别;

2. quick-find

  1. 算法实现: find(int p)方法只寻找到p节点的父亲节点, union(int p, int q)方法需要将那两个节点的父亲节点进行比较, 如果不同, 则把他们合并, 谁合并到谁无所谓, 比如要将p对应的集合合并到q对应的集合, 合并的方式就为遍历数组, 如果碰到节点的父亲节点为p的父亲节点, 即数组的值为id[p], 则把这个点的数组值改为id[q], 也就是改为q的父亲节点;

    public static class UF {
    	private int[] id;
    	private int count;
    	public UF(int N) {
    		id = new int[N];
    		count = N;
    		for (int i = 0; i < N; i++) {
    			id[i] = i;
    		}
    	}
    	public int size() {
    		return count;
    	}
    	// method of quick-find
    	public int find(int p) {
    		return id[p];
    	}
    	public boolean connected(int p, int q) {
    		return find(p) == find(q);
    	}
    	public void union(int p, int q) {
    		int pID = find(p);
    		int qID = find(q);
    		if (pID == qID) {
    			return;
    		}
    		for (int i = 0; i < id.length; i++) {
    			if (id[i] == pID) {
    				id[i] = qID;
    			}
    		}
    		count--;
    	}
    }
    
  2. 算法分析: find( )方法是很快的, 因为他只访问数组一次, 而union方法则每次都要遍历一次数组, 所以该版本无法处理大型问题; 具体而言, union方法要调用两次find方法, 并且在最差情况下, 要遍历并改变N - 1个数组元素, 在最好情况下, 要遍历并改变1个数组元素, 所以union方法访问数组的次数为(N + 3)~(2N + 1), 假如调用union方法直到最后只剩下一个连通分量, 至少调用N - 1次union方法, 则quick-find版本的时间复杂度为N2;

    这种算法面临的最坏情况就是挨个儿合并, 也就是0和1合并, 1和2合并, 到最后, 8和9合并时出现下面这种情况, 意味着要改变N - 1个值;

    image-20220203180044684

3. quick-union

  1. 算法实现

    该版本改变了find方法寻找的对象, 在上一个版本中, find(int p)方法找寻的对象为p节点的父亲节点, 或者称为pID更加达意, 而在这个版本中, 该方法找寻的对象为代表节点, 也就是说, 这个方法会不断往上寻找, 直到找到一个根节点, 而connected方法也正是对比的根节点, 而union(int p, int q)方法如果是把p对应的集合挂在q上, 则只将id[p]=q即可;

    public static class UF {
    	private int[] id;
    	private int count;
    	public UF(int N) {
    		id = new int[N];
    		count = N;
    		for (int i = 0; i < N; i++) {
    			id[i] = i;
    		}
    	}
    	public int size() {
    		return count;
    	}
    	// method of quick-union
    	public boolean connected(int p, int q) {
    		return find(p) == find(q);
    	}
    	public int find(int p) {
    		while (p != id[p]) {
    			p = id[p];
    		}
    		return p;
    	}
    	public void union(int p, int q) {
    		int pRoot = find(p);
    		int qRoot = find(q);
    		if (pRoot == qRoot)
    			return;
    		id[pRoot] = qRoot;
    		count--;
    }
    
  2. 算法分析

    在最好的情况下, find方法仅需访问数组一次, 而最坏的情况下, 保守估计需要2N + 1次; 而union()方法, 对于0-i整数对, 而言, 访问数组的次数为2i+1次;

    证明: union()需要两次find(), 而对于0-i整数对而言, find()方法访问数组的次数为i次, 因为根据下列代码, p在除了最后一个位置的每一个位置上看似都需要访问数组两次才可以跳到下一个位置, 但是由于while循环中经过编译的代码对id[p]的第二次引用通常都不会访问数组, 所以p在每一个位置上访问数组的次数都是1, find()方法访问数组的次数为i, 而union()方法, 对于不处于同一集合的两个根节点, 在最后是需要把他们合并在一起的, 所以又需要访问一次数组, union()方法总共访问数组2i+1次, 证毕;

    private int find(int p) {
        while(p != id[p]) p = id[p];   
        return p;
    }
    

    将0, 1, 2...N-1这N个数, 按照0-1, 0-2, 0-3...0-(N-1)的顺序, 也就是最坏的情况连接起来, 由于每次都是调用union()方法, 所以总共的访问数组次数为3 + 5 + 7 + ... + 2N-1 ~ N2; 而union方法和find方法本身的时间复杂度都为O(M), M为树的高度;

    该版本的最坏情况为挨个合并, 如下图;

4. 加权quick-union

  1. 算法实现

    quick-union版本的缺点在于, 集合合并时是随意的, 而加权的意思就是让合并这个过程是有选择的, 换句话说, 就是实现小集合合并到大集合; 而具体的实现是通过一个size[]数组来表示每个集合的大小;

    public static class WeightedQuickUnionUF {
    	private int[] id;
    	private int[] size;
    	private int count;
    	public WeightedQuickUnionUF(int N) {
    		id = new int[N];
    		size = new int[N];
    		count = N;
    		for (int i = 0; i < N; i++) {
    			id[i] = i;
    			size[i] = 1;
    		}
    	}
    	public int find(int p) {
    		while (p != id[p]) {
    			p = id[p];
    		}
    		return p;
    	}
    	public void union(int p, int q) {
    		int pRoot = find(p);
    		int qRoot = find(q);
    		if (pRoot == qRoot) {
    			return;
    		}
    		if (size[pRoot] >= size[qRoot]) {
    			id[qRoot] = pRoot;
    			size[pRoot] += size[qRoot];
    		} else {
    			id[pRoot] = qRoot;
    			size[qRoot] += size[pRoot];
    		}
    		
    		count--;
    	}
    	
    	public boolean connected(int p, int q) {
    		return find(p) == find(q);
    	}
    	
    	public int size() {
    		return count;
    	}
    }
    
  2. 算法分析

    由于在合并集合时会进行集合大小的判定, 所以该版本的最坏情况一定是合并的任意两个集合大小都相等, 如下图; 树的高度最大为log2N(根节点的高度为0;

5. 使用路径压缩的quick-union

只需要在find里使用一个容器装住向上查找根节点的那条路径上的点, 并把这些点的父亲节点设置为根节点即可;

public int find(int p) {
	iHelp = 0;
	while (p != id[p]) {
		help[iHelp++] = p;
		p = id[p];
	}
	while (iHelp >= 0) {
		id[help[iHelp--]] = p;
	}
	return p;
}