🥳每日一练--两种并查集的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 实现代码,例子详实,还有对应的树形结构供参考,方便学习。下篇文章聊聊并查集的优化。不想了解优化也可以直接跳过,并不影响其他算法的学习

相关推荐
用户新1 小时前
JS事件深度解析四 事件的循环和异步
前端·javascript·事件·event loop
wabs6666 小时前
关于贪心算法的思考
算法·贪心算法
社交怪人7 小时前
【判断大小】信息学奥赛一本通C语言解法(题号1043)
算法
Snasph7 小时前
GNU Make 用户手册(中文版)
服务器·算法·gnu
江澎涌7 小时前
拆解与 AI 的一次对话
人工智能·算法·程序员
sheeta19988 小时前
LeetCode 每日一题笔记 日期:2026.06.02 题目:3635. 最早完成陆地和水上游乐设施的时间 II
笔记·算法·leetcode
Lsk_Smion8 小时前
力扣实训 _ [102].层序遍历--前序--后续_递归与非递归的实现
数据结构·算法·leetcode
Lsk_Smion9 小时前
力扣实训 _ [25].K个一组链表
数据结构·链表
小欣加油9 小时前
leetcode3751 范围内总波动值I
java·数据结构·c++·算法·leetcode
Halo_tjn10 小时前
反射与设计模式1
java·开发语言·算法