极限挑战之一命速通并查集
在上一篇探讨二叉平衡树(AVL树) 的博客中,我们深入分析了如何通过绝妙的旋转操作维护树的平衡,从而保证查询效率。AVL树的核心在于动态维持数据的严格有序与结构平衡,以适应高效的查找场景。然而,在计算机科学中,我们常常需要解决另一类关键问题:如何快速判断和连接元素之间的动态关系 ?此时,一种名为 “并查集” 的数据结构便展现出其独特的魅力。它不再聚焦于元素的排序,而是专注于关系的连通性,以近乎常数的操作时间,成为处理动态连通性问题的利器
1. 并查集概述:关系管理的思维转变
如果说二叉平衡树是维护数据秩序的“时间管理大师”,那么并查集就是处理元素关系的“空间管理大师”。它是一种树形的数据结构,但其设计目标与二叉平衡树截然不同:并查集的核心功能是高效管理一个由多个不相交集合构成的动态系统
当然,如果你是第一次接触并查集的概念的话,它没有之前的链表、树等数据结构名称的通俗易懂,你可能会对其有些许疑问。那么接下来,就跟着我一起把并查集这三个字拆开来理解吧~
拆字理解核心:
- 并(Union): 把两个不相交的集合合并成一个整体。比如把 “1所在的小组” 和 “2所在的小组” 合并为同一个小组
- 查(Find): 查询某个元素属于哪个集合,关键是找到集合的 “根节点”(唯一代表这个集合的元素)。比如查3属于哪个小组,就是找3所在集合的根节点
- 集(Set): 指被管理的元素群体,每个集合有唯一的根节点,元素之间通过树形结构关联
所以,我们可以总结出其核心概念,即:并查集是一种高效管理 “集合” 的树形数据结构,核心就是 “合并集合” 和 “查询元素所属集合” 两大操作
因此,并查集特别擅长处理“动态”的连通性问题。例如,在网络连接中,随着新线路的铺设,原本不连通的区域变得连通;在社交网络中,随着好友关系的建立,不同的小圈子可能会合并。这种动态性正是并查集大显身手的舞台
2. 并查集数据结构设计:QF与QU的爱恨情仇
与之前我们学习的链表和树不同,在这些数据结构中我们只需要设计一种便可以满足插入、删除等等一系列操作。但并查集不一样,它的查找和合并我们一般会分别定义两种不同的数据结构
并查集的 “查找” 和 “合并” 操作之所以会衍生出不同的实现结构,本质是时间复杂度的权衡和操作场景的适配,我们可以通过两个经典实现来理解:
Quick Find:为 “查找” 量身定制的结构
- 数据结构: 用数组groupID直接记录每个元素的 “组编号”(比如groupID[2] = 5 表示元素2属于组5)
- 查找: 判断两个元素是否同组,只需比较groupID,时间复杂度O(1)
- 合并: 将组 A 合并到组 B 时,需要把所有组 A 的元素的 groupID 改成组 B 的编号,时间复杂度O(n)(n是元素总数,很慢)
- 适用场景: 如果业务中 “合并极少、查找极多”,比如只需批量判断元素归属,这种结构就很高效
Quick Union:为 “合并” 量身定制的结构
-
数据结构: 用 “父数组” parent 构建树形结构(比如 parent[2] = 5 表示元素2的父节点是5,根节点是树的 “组标识”)
-
合并: 把一个树的根节点挂到另一个树的根节点下,时间复杂度近似O (1)
-
查找: 找元素的根节点需要向上遍历父节点,最坏情况O(n)(比如树退化成链表)
-
优化(路径压缩): 查找时把沿途节点直接挂到根节点下,让树变 “矮”,最终查找复杂度降到O(α(n))(α是阿克曼函数的反函数,近似常数)
-
适用场景: 如果业务中 “合并频繁、查找也频繁”,这种结构加路径压缩后,整体效率远超Quick Find
而导致这一切的核心原因是因为并查集的两个操作(查找、合并)对性能的需求是矛盾的
- 查找快: 让每个元素直接记录组信息(但合并时要改大量数据)
- 合并快: 必须使用树形结构(但查找时可能要遍历多节点)
再举个例子,就好比你打王者,你不会用韩信去抗塔,也不会用瑶去单带偷家一样,英雄的定位永远服务于对局的核心需求~
3. Quick-Find并查集算法详解:从代码到思想
通过刚刚对并查集数据结构设计的学习后,相信大家肯定知道该如何设计了吧?我们先来设计一个为 “查找” 量身定制的结构
3.1 基本数据结构设计
typedef struct {Element* data; // 存放具体数据,利用索引来建立数据元素的关系int* groupID; // 每个元素的组编号,利用索引值找到组ID信息int n; // 并查集中元素的个数
} QuickFindSet;
这种设计的巧妙之处在于,每个元素在data数组中的索引与其在 groupID数组中的索引一一对应,通过索引就能快速确定任意元素的组别
3.2 findIndex函数:基础的索引查找工具
static int findIndex(const QuickFindSet* setQF, Element e) {// 遍历整个数据数组for (int i = 0; i < setQF->n; ++i) {// 比较当前元素与目标元素if (setQF->data[i] == e) {return i;}}return -1;
}
这是最简单的查找算法,时间复杂度为O(n)。但是在元素数量大时可能成为性能瓶颈
3.3 isSameQF函数:高效的连通性判断
int isSameQF(QuickFindSet* setQF, Element a, Element b) {// 分别获取a、b的索引int aIndex = findIndex(setQF, a);int bIndex = findIndex(setQF, b);// 检查元素是否存在,如果任一元素不存在,返回不连通if (aIndex == -1 || bIndex == -1) {return 0;}// 检查组ID是否相同return setQF->groupID[aIndex] == setQF->groupID[bIndex];
}
这段函数首先检查元素是否存在,避免后续操作出现异常,然后再通过索引直接访问groupID数组,使得其操作时间复杂度为O(1),最后将比较结果立即返回,无额外计算开销
3.4 unionQF函数:代价较高的集合合并
void unionQF(QuickFindSet* setQF, Element a, Element b) {// 把b的老大管理的人,都合并到a上面int aIndex = findIndex(setQF, a);int bIndex = findIndex(setQF, b);// 保存b的原组ID(因为b的组ID即将被修改)int gID = setQF->groupID[bIndex];// 遍历所有元素,更新组关系for (int i = 0; i < setQF->n; ++i) {// 找到所有属于b原组的元素if (setQF->groupID[i] == gID) {// 重新标记为a的组setQF->groupID[i] = setQF->groupID[aIndex];}}
}
3.5 算法优劣与适用场景
3.5.1 优势
- 查找极快: isSame操作是真正的O(1)时间复杂度
- 实现简单: 逻辑直观,易于理解和调试
- 确定性: 结果明确,无随机性或复杂状态
3.5.2 劣势
- 合并代价高: 每次union都需要遍历整个数组
- 不适合动态数据: 频繁的合并操作在大型数据集上性能差
- 无法支持复杂操作: 如查找集合大小等扩展功能
3.5.3 适用场景
通过unionQF函数不难看出,Quick-Find算法最适合查找操作远多于合并操作的场景,或者数据规模较小的情况
4. 并查集进阶:Quick-Union算法与优化策略深度解析
在上文讲解Quick-Find算法时,我们看到其核心特点是查找极快但合并代价高昂。现在,我们迎来了一种更加智能的并查集实现——Quick-Union算法,它通过树形结构和路径压缩技术实现了查找与合并操作的高效平衡。并且,Quick-Union的设计理念发生了根本性转变:不再直接维护每个元素的组标识,而是通过父指针构建树结构,让查找操作通过追溯父节点来寻找根节点
4.1 基本数据结构设计
typedef struct {Element* data;// 父指针数组:parent[i]表示第i个元素的父节点索引// 替代了Quick-Find中的groupID数组,通过指针链构建树形结构int* parent;int* size;int n;
} QuickUnionSet;// 由于是链式栈,所以需要定义节点
typedef struct _set_node {int index;// 指向下一个栈节点struct _set_node* next;
} SetNode;
4.2 基础查找实现:简单的向上追溯
在没有路径压缩的情况下,查找操作通过循环向上追溯父节点直到找到根节点
#ifndef PATH_COMPRESS
static int findRootIndex(const QuickUnionSet* setQU, Element e) {// 先找到元素索引int curIndex = findIndex(setQU, e);if (curIndex == -1) {return -1;}// 沿着父指针向上追溯,直到找到根节点while (setQU->parent[curIndex] != curIndex) {// 移动到父节点curIndex = setQU->parent[curIndex];}// 返回根节点索引return curIndex;
}
#else
但基础查找由于每次都需要遍历遍历完整路径导致在某些情况下查找效率会严重降低,所以为了解决基础实现的性能问题,我们引入了路径压缩技术
路径压缩就是在查某个节点的根时,顺手把沿途经过的所有节点都直接挂到根节点上;这么一来,后面再查这些节点,就不用再绕弯路,一步到位找到根,既省事儿又提速
#ifdef PATH_COMPRESS
static SetNode* push(SetNode* stack, int index) {SetNode* node = malloc(sizeof(SetNode));node->index = index;// 新节点指向原栈顶node->next = stack; // 返回新栈顶return node;
}static SetNode* pop(SetNode* stack, int* index) {// 保存原栈顶SetNode* tmp = stack;// 返回索引值*index = stack->index;// 栈顶下移stack = stack->next; // 释放原栈顶内存 free(tmp); // 返回新栈顶 return stack;
}// 为什么要先定义两个入栈和出栈的函数?
// 因为其后进先出的特性完美匹配路径压缩的需求!!!static int findRootIndex(const QuickUnionSet* setQU, Element e) {int curIndex = findIndex(setQU, e);if (curIndex == -1) return -1;// 使用栈记录查找路径上的所有节点SetNode* path = NULL;// 第一阶段:追溯路径并记录所有经过的节点while (setQU->parent[curIndex] != curIndex) {// 当前节点入栈path = push(path, curIndex); // 向上移动curIndex = setQU->parent[curIndex]; }// 此时curIndex是根节点// 第二阶段:路径压缩,将路径上所有节点直接指向根节点while (path != NULL) {int pos;// 出栈获取节点位置path = pop(path, &pos); // 直接指向根节点 setQU->parent[pos] = curIndex; }return curIndex;
}
#endif
路径压缩通过 “先记路径再统一压缩” 的两阶段处理,借助栈的后进先出特性保证操作正确,以单次稍慢的代价实现后续操作的极大提速,是典型的摊销复杂度优化思路
4.3 isSameQU函数:高效的连通性判断
int isSameQU(const QuickUnionSet* setQU, Element a, Element b) {int aRoot = findRootIndex(setQU, a);int bRoot = findRootIndex(setQU, b);// 检查元素是否存在,如果任一元素不存在,返回不连通if (aRoot == -1 || bRoot == -1) {return 0;}// 核心判断:根节点相同则属于同一集合return aRoot == bRoot;
}
4.4 unionQU函数:智能的集合合并
/* 将元素a和元素b进行合并* 1. 找到a和b的根节点,原本是b的父节点指向a的父节点* 2. 引入根节点的size有效规则,谁的元素多,让另外一个接入元素多的组*/
void unionQU(QuickUnionSet* setQU, Element a, Element b) {int aRoot = findRootIndex(setQU, a);int bRoot = findRootIndex(setQU, b);if (aRoot == -1 || bRoot == -1) {return;}// 加权合并:小树合并到大树下(只有不同集合才需要合并)if (aRoot != bRoot) { // 根据根节点的索引找到对应size空间里保存的根所在树的总节点数int aSize = setQU->size[aRoot];int bSize = setQU->size[bRoot];// 小树合并到大树下if (aSize >= bSize) { // 将b的根指向a的根setQU->parent[bRoot] = aRoot;// 更新合并后的大小setQU->size[aRoot] += bSize;}else {// 同理setQU->parent[aRoot] = bRoot;setQU->size[bRoot] += aSize;}}
}
这段代码能始终将小树合并到大树下以控制树高,并结合路径压缩实现性能优化,是空间换时间的高效合并策略
4.5 算法优劣与适用场景
通过对比Quick-Find和Quick-Union两种实现,我们可以看到算法设计的不断演进
4.5.1 Quick-Find的优势与局限
- 优势: 查找操作极快(O(1)),实现简单
- 局限: 合并操作代价高(O(n)),不适合动态数据
4.5.2 Quick-Union的突破与创新
- 突破: 通过树形结构将合并操作优化到O(log n)级别
- 创新: 路径压缩和加权合并技术进一步优化到O(α(n))
(α(n)是反阿克曼函数,它增长极慢,使路径压缩与加权合并的并查集操作时间复杂度近似常数,实现极致高效。意味着无论数据规模n有多大,路径压缩 + 加权合并后的并查集操作,效率都和 “一次简单的数组访问” 差不多
4.5.3 Quick-Union的实际应用场景
- 动态连通性问题: 网络连接管理、社交关系分析
- 图论算法: Kruskal最小生成树算法的核心组件
- 图像处理: 连通区域标记和分割
5. 并查集的其它接口
5.1 Quick-Find并查集的接口
5.1.1 创建
QuickFindSet* createQuickFindSet(int n) {QuickFindSet* setQF = malloc(sizeof(QuickFindSet));if (setQF == NULL) {printf("setQF malloc failed!\n");return NULL;}setQF->data = malloc(sizeof(Element) * n);setQF->groupID = malloc(sizeof(int) * n);setQF->n = n;return setQF;
}
5.1.2 初始化
void initQuickFindSet(const QuickFindSet* setQF, const Element* data, int n) {for (int i = 0; i < n; ++i) {setQF->data[i] = data[i];setQF->groupID[i] = i;}
}
5.1.3 释放
void releaseQuickFindSet(QuickFindSet* setQF) {if (setQF) {if (setQF->data) {free(setQF->data);}if (setQF->groupID) {free(setQF->groupID);}free(setQF);}
}
5.2 Quick-Union并查集的接口
5.2.1 创建
QuickUnionSet* createQuickUnionSet(int n) {QuickUnionSet* setQU = malloc(sizeof(QuickUnionSet));if (setQU == NULL) {return NULL;}setQU->data = malloc(sizeof(Element) * n);setQU->parent = malloc(sizeof(int) * n);setQU->size = malloc(sizeof(int) * n);setQU->n = n;return setQU;
}
5.2.2 初始化
void initQuickUnionSet(QuickUnionSet* setQU, const Element* data, int n) {for (int i = 0; i < n; ++i) {setQU->data[i] = data[i];setQU->parent[i] = i;setQU->size[i] = 1;}
}
5.2.3 释放
void releaseQuickUnionSet(QuickUnionSet* setQU) {if (setQU) {if (setQU->data)free(setQU->data);if (setQU->parent)free(setQU->parent);if (setQU->size)free(setQU->size);}
}
6. 小结
从AVL树的严格平衡,到并查集的灵活连通,我好像有点领悟到了数据结构设计里那种“权衡”的艺术。并查集让我明白,有时候放手让结构“乱”一点,用路径压缩和加权合并这种“事后优化”的策略,反而能在整体上获得更高效的操作。 它不像AVL树那样时刻保持紧绷的平衡,而是选择在查询和合并的过程中动态调整,这种“以退为进”的思路真的很妙。同时学习并查集的过程,也让我对“抽象”有了新感受。用数组parent和size就能表示复杂的树形关系,用简单的find和union操作就能解决网络连接、社交关系这种大问题。 这让我更加坚信,扎实掌握基础结构,深刻理解问题本质,往往比盲目追求复杂结构更重要~
