算法之并查集

525 阅读3分钟

并查集是什么

计算机科学中,并查集(英文:Disjoint-set data structure,直译为不交集数据结构)是一种数据结构,用于处理一些不交集(Disjoint sets,一系列没有重复元素的集合)的合并及查询问题。并查集支持如下操作:

  • 查询:查询某个元素属于哪个集合,通常是返回集合内的一个“代表元素”。这个操作是为了判断两个元素是否在同一个集合之中。
  • 合并:将两个集合合并为一个。
  • 添加:添加一个新集合,其中有一个新元素。添加操作不如查询和合并操作重要,常常被忽略。

由于支持查询和合并这两种操作,并查集在英文中也被称为联合-查找数据结构(Union-find data structure)或者合并-查找集合(Merge-find set)。

简而言之,并查集是一种数据结构,主要用于如果存在多个集合中,快速可以判断一个元素属于哪个集合。并且对于集合的合并效率也十分之高。

123.gif

并查集的结构

并查集结构可以快速的将两个集合合并成一个集合,也可快速的查找两个元素是否属于一个集合。

而并查集为了达到这两个目的,可以采用链表的数据结构。即每个集合都有一个头节点,头节点的指针指向自己,该头节点的子孙节点都指向该头节点。

这种结构的好处在于:可以快速的查找每一个节点属于哪个头节点,并且可以通过改变头节点的指针来快速达到合并两个集合的目的。

每个集合的结构如下:

无标题-2021-10-13-0831.png

并查集的典型例题

并查集的典型应用是最小生成树 ———— 克鲁斯卡尔(kruskal)算法。

最小生成树就是给定一个连通图,如下图

2880px-Minimum_spanning_tree.svg.png

根据该图中各个边的权重进行选择,产生一个所有边加起来权重最小、各个点又可以相互到达的图。该图称为最小生成树。

具体思路

  • 将各个点都当成一个独立的集合
  • 以各个变的权重为根据,从小到大排序
  • 依次选取边,边相邻的两个点合并成一个集合(如果相邻两点已经在一个集合中了,则跳过该边)

过程如下:

无标题-2021-10-13-0831.png

流程图如下:

无标题-2021-10-13-0831.png

并查集应该实现的方法

综合上面的分析,可以得出并查集需要实现三个方法

  1. 设置每个节点的头部节点
  2. 查找每个节点的头部节点
  3. 合并两个集合

实现线如下:

//并查集类
interface UnionFind {
        //头节点映射map;里面存储每个节点对应的头节点
	fatherMap: Map<NodeEl, NodeEl>;
        //层级map,里面存储每个节点相对头节点需要进行的步数
 	rankMap: Map<NodeEl, number>;
        constructor(matrix: Array<Array<number>>);
}

 class UnionFind {
    fatherMap: Map<NodeEl, NodeEl>;
    rankMap: Map<NodeEl, number>;

    constructor () {
    	this.fatherMap = new Map();
    	this.rankMap = new Map();
    }
    
    //查找头节点
    findFather (node: NodeEl):NodeEl {
    	let fatherNode = this.fatherMap.get(node);

    	if (node !== fatherNode) {
    		fatherNode = this.findFather(fatherNode);
    	}
    	this.fatherMap.set(node, fatherNode);
        return fatherNode;
    }
    
    //初始化,设置每个节点的头节点为自己,自己相对于头节点的距离(最小是1)
    makeSets (nodes: Array<NodeEl>): void {
    	if (!(nodes instanceof Array)) return;

    	for(let i = 0; i < nodes.length; i++) {
    		this.fatherMap.set(nodes[i], nodes[i]);
    		this.rankMap.set(nodes[i], 1);
    	}

    };
    
    //根据头节点判断是否是同一个集合
    isSameSet (node1: NodeEl, node2: NodeEl): boolean {
    	return this.findFather(node1) === this.findFather(node2);
    }
    
    //合并两个集合
    //更改相应的头节点
    //更改节点到头节点的相对距离
    union (node1: NodeEl, node2: NodeEl) {   
    	if (node1 === null || node2 === null) return;

    	let fatherNode1: NodeEl = this.findFather(node1);
    	let fatherNode2: NodeEl = this.findFather(node2);

    	if (fatherNode2 !== fatherNode1) {
    		let rankNode1: number = this.rankMap.get(node1);
    		let rankNode2: number = this.rankMap.get(node2);

    		if (rankNode1 <= rankNode2) {
    			this.fatherMap.set(fatherNode1, fatherNode2);
    			this.rankMap.set(node1, rankNode1 + rankNode2);
    		} else {
    			this.fatherMap.set(fatherNode2, fatherNode1);
    			this.rankMap.set(node2, rankNode1 + rankNode2);
    		}
    	}
    }
}

根据上面并查集的结构可以简单的写出 kruskal 算法, 该算法种使用的图的结构NodeEl等见图算法

let data = [
	[1,2,7],
	[1,4,5],
	[2,4,9],
	[2,3,8],
	[2,5,7],
	[3,5,5],
	[4,5,15],
	[4,6,6],
	[5,6,8],
	[5,7,9],
	[6,7,11]
]

let {NodeEl, Edge, Graph, GraphGenerator} = require('./Graph');
let {UnionFind} = require("./UnionFind");


function kruskal(data) {
	let graph: Graph = new GraphGenerator(data).graph;
	graph.edges = graph.edges.sort((a, b) => a.weight - b.weight);

	let res: Edge[] = [];

	let unionFind = new UnionFind();
	unionFind.makeSets(graph.nodes);

	for(let i = 0; i < graph.edges.length; i++) {
		let from: NodeEl = graph.edges[i].from;
		let to: NodeEl = graph.edges[i].to;

		if (unionFind.findFather(from) !== unionFind.findFather(to)) {
			res.push(graph.edges[i]);
			unionFind.union(to, from);
		}

	}
	return res;
}

kruskal(data);

并查集的适合的问题

一个方法是否适用于某个问题,关键还是要看这个方法的特点是否适应于该问题。并查集对于分组管理有特别好的效果。如果遇到涉及分组管理的问题,可以考虑看看并查集这种结构。