🥳每日一练--两种并查集的JS实现

前言

今天我们来用两种方法实现并查集,一种是 quick find,一种是 quick union。

并查集中,主要有三种操作:

  1. 查找元素所属的集合
  2. 判断两个元素是否是同一集合
  3. 合并两个集合中的元素

而实现并查集的方法不同,主要在于操作并查集的时间复杂度不相同。对于 quick find,查找和判断的时间复杂度是 O(1), 而合并的时间复杂度是 O(n)。对于 quick union, 上面三种操作的复杂度都是 O(log n)

并查集有什么用呢,举个简单的例子,在地图上有多个村庄,其中一些村庄是属于一个县,另一些村庄属于另一个县,如果要将两个县的村庄合并,应该如何用数据结构表示这种关系呢?

除了这个抽象例子,并查集还在其他的算法中有运用,比如在最小生成树 prim, 或者 Dijkstra算法中,都有运用。

下面来用代码实现并查集

Quick Find

javascript 复制代码
// 创建并查集,初始化每个元素的集合标识为自身
const unionArray = Array(8) // 创建一个包含8个元素的数组,表示8个节点
	.fill(-1)              // 用-1填充数组,初始化每个元素的集合标识为-1
	.map((item, index) => index); // 使用map函数将每个元素初始化为自身的索引

// 查找节点所属的集合的根节点
const findRoot = (unionArray, node) => {
	return unionArray[node]; // 返回节点的集合标识,这里就是节点自身
};

// 检查两个节点是否属于同一个集合
const checkSameUnion = (unionArray, node1, node2) => {
	return findRoot(unionArray, node1) == findRoot(unionArray, node2); //检查两个节点的根节点是否相同
};

// 合并两个节点所在的集合
const combineUnion = (unionArray, node1, node2) => {
	const union1 = findRoot(unionArray, node1); // 查找node1所属集合的根节点
	const union2 = findRoot(unionArray, node2); // 查找node2所属集合的根节点

	if (union1 == union2) return; // 如果两个节点已经属于同一个集合,无需合并

	// 将node2所在的集合标识为node1所在的集合
	unionArray[node2] = union1;

	// 更新其他节点的集合标识,将属于node2集合的节点标识为node1集合
	for (let i = 1; i < unionArray.length; i++) {
		if (unionArray[i] == union2) {
			unionArray[i] = union1;
		}
	}
};

代码中,用数组来表示一个个集合。数组的下标表示节点的值,数组的值表示该节点属于哪个集合。在初始化的时候,每个点属于各自的集合,所以数组每个值都是与数组的下标相同。

当我们要查找一个节点所属的集合,只需访问该节点在数组中的位置,并读取其值,即集合的标识。这样查找起来非常方便,只需要 O(1)时间复杂度就可以判断一个节点所属的集合。

当我们要对节点进行合并的时候,就会去改变数组的值。假设我们要合并节点 1 和 2 ,就会讲数组下标位为 2 的值设为 1,同时数组下标为 1 的值也是 1,也就意味着节点 1 和 2 同属于集合 1。

在代码中合并集合操作,有个需要注意的点。combineUnion是将 node1 和 node2 所属的集合合并,而不是仅仅将 node1 和 node2 放在一个集合里。所以在改变 node2 的集合之后,还需要将 node2 所属集合中其他节点的值都做修改。这就是为什么 quick find 方式在合并集合上的时间复杂度是 O(n)

测试代码:

javascript 复制代码
// 执行一系列的合并操作
combineUnion(unionArray, 1, 2);
combineUnion(unionArray, 2, 3);
combineUnion(unionArray, 4, 5);
combineUnion(unionArray, 6, 7);

// 打印并查集的状态
console.log(unionArray);
// 检查两个节点是否属于同一个集合
console.log(checkSameUnion(unionArray, 3, 6)); // false

// 合并两个集合
combineUnion(unionArray, 6, 3);

// 打印并查集的状态
console.log(unionArray);
// 再次检查两个节点是否属于同一个集合
console.log(checkSameUnion(unionArray, 3, 6)); // true

执行结果:

输出了两次,我们分别来看每一次执行combineUnion之后的情况:

原始的树形结构:

latex 复制代码
|__1
|__2
|__3
|__4
|__5
|__6
|__7

表示有 7 个集合,每个结点属于单独的集合

第一次执行 combineUnion(unionArray, 1, 2); 后的集合变化:

  • 原集合情况:[0, 1, 2, 3, 4, 5, 6, 7]
  • 合并节点1和节点2,将它们合并到同一个集合中。节点 2 的值将会指向 1,表示同属于集合 1
  • 新集合情况:[0, 1, 1, 3, 4, 5, 6, 7]
latex 复制代码
|__1
|  |__2
|__3
|__4
|__5
|__6
|__7

第二次执行 combineUnion(unionArray, 2, 3); 后的集合变化:

  • 原集合情况:[0, 1, 1, 3, 4, 5, 6, 7]
  • 合并节点2和节点3,将它们合并到同一个集合中。节点 3 的值将会指向 1,表示同属于集合 1
  • 新集合情况:[0, 1, 1, 1, 4, 5, 6, 7]
latex 复制代码
|__1
|  |__2
|  |__3
|__4
|__5
|__6
|__7

第三次执行 combineUnion(unionArray, 4, 5); 后的集合变化:

  • 原集合情况:[0, 1, 1, 1, 4, 5, 6, 7]
  • 合并节点4和节点5,将它们合并到同一个集合中。节点 5 的值指向 4,表示同属于集合 4
  • 新集合情况:[0, 1, 1, 1, 4, 4, 6, 7]
latex 复制代码
|__1
|  |__2
|  |__3
|__4
|  |__5
|__6
|__7

第四次执行 combineUnion(unionArray, 6, 7); 后的集合变化:

  • 原集合情况:[0, 1, 1, 1, 4, 4, 6, 7]
  • 合并节点6和节点7,将它们合并到同一个集合中。节点 7 的值将会指向 6,表示同属于集合 6
  • 新集合情况:[0, 1, 1, 1, 4, 4, 6, 6]
latex 复制代码
|__1
|  |__2
|  |__3
|__4
|  |__5
|__6
|  |__7

现在的共有三个集合,分别是集合 1,集合 4,集合 6

第五次执行 combineUnion(unionArray, 6, 3); 后的集合变化:

  • 原集合情况:[0, 1, 1, 1, 4, 4, 6, 6]
  • 合并节点6和节点3,将它们合并到同一个集合中。节点 1,2,3 的值都会指向 6,表示同一个集合
  • 新集合情况:[0, 6, 6, 6, 4, 4, 6, 6]
latex 复制代码
|__4
|	|__5
|__6
|	|__1
|	|__2
|	|__3
|	|__7

可以很明显的看到,合并之后的树结构的高度只有 2,这是 quick find 的法门所在。

打印树形结构的代码,在本文结尾有,可以 copy 直接执行

Quick Union

javascript 复制代码
// 创建并查集,初始化每个元素的集合标识为自身
const unionArray = Array(8)
    .fill(-1)
    .map((item, index) => index);

// 创建一个数组用于存储集合的秩(rank),用于优化合并操作
const unionRank = Array(8).fill(1);

// 查找节点所属的集合的根节点(快速 Union 版本)
const findRoot = (unionArray, node) => {
    // 使用循环找到根节点,直到根节点的集合标识等于自身
    while (unionArray[node] !== node) {
        node = unionArray[node];
    }
    return node; // 返回根节点的集合标识
};

// 检查两个节点是否属于同一个集合
const checkSameUnion = (unionArray, node1, node2) => {
    return findRoot(unionArray, node1) == findRoot(unionArray, node2);
};

const combineUnion = (unionArray, node1, node2) => {
	const union1 = findRoot(unionArray, node1);
	const union2 = findRoot(unionArray, node2);
	if (union1 == union2) return;

	unionArray[union2] = union1;
};

quick union 和 quick find 一致,都是用数组表示集合,也是数组的下标表示节点的值,不相同的是,在 quick find 中,数组的值不仅表示节点的所属集合,也表示父节点;而在 quick union 中只表示父节点,不表示所属的集合。

举个例子,[0, 1, 1, 2]中,节点 1 的父节点是它自己,也是节点 1;节点 2 的父节点是节点 1;节点 3 的父节点是节点 2;这三个节点是同一棵树,并且默认是以父节点为自己的节点作为这棵树的父节点。

这棵树的形状是这样:

latex 复制代码
|__1
|  |__2
|     |__3

默认从下标 1 开始,下标 0 直接忽略

因此在查找节点所属的集合过程,就不太一样,需要找到这棵树的父节点才行。这就是为什么查询节点所属集合的时间复杂度是O(log n)(树的高度和树的节点关系就是O(log n)

在集合的合并过程,也不一样,在查找了节点所属的集合后,也就是两棵树的父节点。合并就是将一个树的父节点连到另一颗树的父节点上

现在你知道为什么合并的时间复杂度也是O(log n)吗

测试代码

javascript 复制代码
/**
 * 进行一系列合并操作
 * 1,2
 * 2,3
 * 4,5
 * 6,7
 */
combineUnion(unionArray, 1, 2);
combineUnion(unionArray, 2, 3);
combineUnion(unionArray, 4, 5);
combineUnion(unionArray, 6, 7);
combineUnion(unionArray, 5, 6);

console.log(unionArray); // 打印并查集数组
console.log(checkSameUnion(unionArray, 3, 6)); // 检查节点3和节点6是否属于同一个集合

// 再次合并节点6和节点3所在的集合
combineUnion(unionArray, 6, 3);

console.log(unionArray); // 打印并查集数组
console.log(checkSameUnion(unionArray, 3, 6)); // 再次检查节点3和节点6是否属于同一个集合

结果没问题,正确

为了更好理解 unionArray 的内容,下面给出了两次 console.log时 unionArray 所表示的树形结构

  1. 第一次 console.log
latex 复制代码
|__1
|	|__2
|	|__3
|__4
|	|__5
|	|__6
|	   |__7

当前有两颗树,一颗树的根节点时 1,另一颗树的根节点是 4

  1. 第二次console.log
text 复制代码
|__4
|	|__1
|	|   |__2
|	|   |__3
|	|__5
|	|__6
|	   |__7

在第一次 console.log 之后就将 6 和 3 所属的集合合并了,所以现在只有一颗树了

完整代码

打印树形结构

javascript 复制代码
// 将 Quick Find 和 Quick Union 版本的并查集数组表示转换为树形结构的数据

// 函数用于将并查集数组转换为树形结构的数据
const unionArray2TreeData = (unionArray) => {
    // 创建一个空的对象来表示树形结构
    const tree = {};

    // 第一次遍历,创建树的节点
    unionArray.forEach((item, index) => {
        if (index == 0) return; // 忽略索引为 0 的元素
        // 创建一个节点对象,并赋予节点值和一个空的子节点数组
        tree[index] = { value: index, next: [] };
    });

    // 第二次遍历,建立树的连接关系
    unionArray.forEach((item, index) => {
        if (index == 0) return; // 忽略索引为 0 的元素
        if (item !== index) {
            // 如果元素值与索引不相等,表示有父子关系,将子节点添加到父节点的子节点数组中
            tree[item].next.push(tree[index]);
        }
    });

    // 第三次遍历,删除不属于根节点的节点
    unionArray.forEach((item, index) => {
        if (index == 0) return; // 忽略索引为 0 的元素
        if (index !== item) delete tree[index]; // 删除不属于根节点的节点
    });

    // 返回构建的树形结构数据
    return tree;
};

// 用于打印树形结构的辅助函数
const printTree = (data, deeps = [1]) => {
    // 如果输入数据为空,直接返回
    if (!data) return res;

    // 根据层级创建适当的缩进空格
    let space = deeps
        .slice(0, -1)
        .map((item) => {
            return item == 1 ? "|\t\t" : "\t\t";
        })
        .join("");

    space += "|__";

    // 添加当前节点的值到结果字符串
    res = res + space + data.value + "\n";

    // 递归处理子节点
    if (Array.isArray(data.next)) {
        for (let i = 0; i < data.next.length; i++) {
            printTree(data.next[i], [...deeps, i == data.next.length - 1 ? 0 : 1]);
        }
    } else {
        printTree(data.next, [...deeps, 0]);
    }

    // 返回结果字符串
    return res;
};

// 用于打印多棵树的辅助函数
const printMultiTree = (treeData) => {
    // 遍历所有树并调用打印树的函数
    Object.values(treeData).forEach((item, index, array) => {
        printTree(item);
    });
    // 打印结果字符串到控制台
    console.log(res);
    // 重置结果字符串,以备下次使用
    res = "";
};

// 导出函数供其他模块使用
module.exports = { unionArray2TreeData, printMultiTree };

unionArray2TreeData方法用来将 unionArray 转成对象结构,支持 quick find 和 quick union 两种数组结构。

Quick Find

javascript 复制代码
// 创建并查集,初始化每个元素的集合标识为自身
const unionArray = Array(8) // 创建一个包含8个元素的数组,表示8个节点
  .fill(-1)              // 用-1填充数组,初始化每个元素的集合标识为-1
  .map((item, index) => index); // 使用map函数将每个元素初始化为自身的索引

// 查找节点所属的集合的根节点
const findRoot = (unionArray, node) => {
  return unionArray[node]; // 返回节点的集合标识,这里就是节点自身
};

// 检查两个节点是否属于同一个集合
const checkSameUnion = (unionArray, node1, node2) => {
  return findRoot(unionArray, node1) == findRoot(unionArray, node2); // 检查两个节点的根节点是否相同
};

// 合并两个节点所在的集合
const combineUnion = (unionArray, node1, node2) => {
  const union1 = findRoot(unionArray, node1); // 查找node1所属集合的根节点
  const union2 = findRoot(unionArray, node2); // 查找node2所属集合的根节点

  if (union1 == union2) return; // 如果两个节点已经属于同一个集合,无需合并

  // 将node2所在的集合标识为node1所在的集合
  unionArray[node2] = union1;

  // 更新其他节点的集合标识,将属于node2集合的节点标识为node1集合
  for (let i = 1; i < unionArray.length; i++) {
    if (unionArray[i] == union2) {
      unionArray[i] = union1;
    }
  }
};


// 执行一系列的合并操作
combineUnion(unionArray, 1, 2);
combineUnion(unionArray, 2, 3);
combineUnion(unionArray, 4, 5);
combineUnion(unionArray, 6, 7);

// 打印并查集的状态
console.log(unionArray);
// 检查两个节点是否属于同一个集合
console.log(checkSameUnion(unionArray, 3, 6)); // false

// 合并两个集合
combineUnion(unionArray, 6, 3);

// 打印并查集的状态
console.log(unionArray);
// 再次检查两个节点是否属于同一个集合
console.log(checkSameUnion(unionArray, 3, 6)); // true

Quick Union

javascript 复制代码
// 创建并查集,初始化每个元素的集合标识为自身
const unionArray = Array(8)
    .fill(-1)
    .map((item, index) => index);

// 创建一个数组用于存储集合的秩(rank),用于优化合并操作
const unionRank = Array(8).fill(1);

// 查找节点所属的集合的根节点(快速 Union 版本)
const findRoot = (unionArray, node) => {
    // 使用循环找到根节点,直到根节点的集合标识等于自身
    while (unionArray[node] !== node) {
        node = unionArray[node];
    }
    return node; // 返回根节点的集合标识
};

// 检查两个节点是否属于同一个集合
const checkSameUnion = (unionArray, node1, node2) => {
    return findRoot(unionArray, node1) == findRoot(unionArray, node2);
};

const combineUnion = (unionArray, node1, node2) => {
	const union1 = findRoot(unionArray, node1);
	const union2 = findRoot(unionArray, node2);
	if (union1 == union2) return;

	unionArray[union2] = union1;
};


/**
 * 进行一系列合并操作
 * 1,2
 * 2,3
 * 4,5
 * 6,7
 */
combineUnion(unionArray, 1, 2);
combineUnion(unionArray, 2, 3);
combineUnion(unionArray, 4, 5);
combineUnion(unionArray, 6, 7);
combineUnion(unionArray, 5, 6);

console.log(unionArray); // 打印并查集数组
console.log(checkSameUnion(unionArray, 3, 6)); // 检查节点3和节点6是否属于同一个集合

// 再次合并节点6和节点3所在的集合
combineUnion(unionArray, 6, 3);

console.log(unionArray); // 打印并查集数组
console.log(checkSameUnion(unionArray, 3, 6)); // 再次检查节点3和节点6是否属于同一个集合

代码都经过测试,能够直接 copy 下来直接在 vscode 运行

总结

这篇文章介绍了两种并查集方式,并且介绍了并查集的三种基本操作:

  1. 查找元素所属的集合
  2. 判断两个元素是否是同一集合
  3. 合并两个集合中的元素

同时给出了对应的 JS 实现代码,例子详实,还有对应的树形结构供参考,方便学习。下篇文章聊聊并查集的优化。不想了解优化也可以直接跳过,并不影响其他算法的学习

相关推荐
Noah_aa6 分钟前
代码随想录算法训练营第五十六天 | 图 | 拓扑排序(BFS)
数据结构
KpLn_HJL1 小时前
leetcode - 2139. Minimum Moves to Reach Target Score
java·数据结构·leetcode
暴富的Tdy1 小时前
【CryptoJS库AES加密】
前端·javascript·vue.js
℘团子এ1 小时前
js和html中,将Excel文件渲染在页面上
javascript·html·excel
程序员老冯头2 小时前
第十五章 C++ 数组
开发语言·c++·算法
胡西风_foxww3 小时前
【es6复习笔记】Promise对象详解(12)
javascript·笔记·es6·promise·异步·回调·地狱
秃头女孩y3 小时前
【React中最优雅的异步请求】
javascript·vue.js·react.js
AC使者7 小时前
5820 丰富的周日生活
数据结构·算法
cwj&xyp7 小时前
Python(二)str、list、tuple、dict、set
前端·python·算法