首页 > 其他分享 >【数据结构】- 并查集

【数据结构】- 并查集

时间:2023-10-29 13:55:24浏览次数:46  
标签:val int fy 查集 fx 数据结构 find

并查集


简介

并查集是可以维护 元素集合 的数据结构。并查集通过把一个集合作为一棵树的方式,维护一个 森林(这暗含并查集可以维护连通块个数,如在 kruskal 中,通过并查集维护连通块个数就能快速判断循环退出条件),并使用树的 根节点代表各集合。这样一棵树的节点就对应该集合中的元素。可以方便地支持元素(合)并查(询)操作,我们会维护每个点指向的父亲节点。当然,根节点只能指向自己了。

image

有带权并查集、扩展域并查集、可撤销并查集和可持久化并查集等变体。

普通并查集

结构

维护一个数组记录父亲,并在初始化时指向自己。

int fa[N];

操作

查询

询问元素所在集合的根节点,通过递归不断上跳至父亲节点实现。不难发现当出现自环时我们就抵达了顶点,可以返回。

int find(int x) {return fa[x] == x ? x : find(fa[x]);}

合并

既然直接维护的是集合从属关系,要合并两个集合时直接将一棵树的根的父亲设为另一棵树的根节点即可。

void unite(int x, int y) {fa[find(x)] = find(y);}

并查集优化

使用最为广泛的是并查集的路径压缩技巧。大多数时候我们使用并查集并不关心元素间 严格的父子关系,只是想知道它们是否属于同一集合,此时普通并查集的时间复杂度就不可观了。想象一下,从一条链的底端爬到顶端后才能返回代表这一集合的元素,实在是太过耗时了。而一朵美妙的菊花图才是效率之王!针对查询的优化——路径压缩由此而来:把一条链的路径压成一条“直通边”岂不美哉?

image

注意:不难看出,在需要判定加入顺序等关心严格父子关系的情景时,路径压缩会致错。如:[可撤销并查集](P5787 二分图 /【模板】线段树分治 - 洛谷 | 计算机科学教育新生态 (luogu.com.cn))。同样,在节点维护了与路径有关信息时(如树高),路径压缩同样不能完全兼容,所以才有了按秩合并,详见知乎讨论

结构

我们完全可以改变此时并查集记录的信息:在加入时记录其父节点,而后对每个节点 在可能时 尽快更新成记录其所在集合的根节点,那么之后的查询我们就可以快速得到答案,省去一次次“爬楼”的过程了。

int rt[N];

操作

路径压缩

前文说到要尽可能更新,那么在查询时就 一路更新 节点信息自然是不二之选——反正这次经过的链上所有的点都可以,也需要更新,那一并维护即可。在代码层面,具体地,在递归后将返回值顺道更新为当前节点指向的根节点。

int find(int x) {return rt[x] == x ? rt[x] = find(rt[x]);}
//不压行的,
int find(int x) {
    if (x == rt[x]) return x;
    rt[x] = find(rt[x]);
    return rt[x];
}

启发式合并

合并操作也有其优化方法:启发式合并、按秩合并。启发式合并是一种常见的优化,对合并过程人为干涉,符合直觉地,一般将大小更小的并入更大的集合,这样未来查询时需要更新rt[]数组的节点自然更少。同理,也有按深度(树高)较矮的并入更高的集合。但是我们前面提到,使用路径压缩时会改变树高,故根节点存储的高度信息干脆不进行更新,直接使用这个称为“秩”的近似值进行求并优化。事实证明这样也能提升效率。

void unite(int x, int y) { //按大小合并
     x = find(x), y = find(y);
     if (x == y) return;
     if (sz[x] < sz[y]) swap(x, y);
     rt[y] = x; sz[x] += sz[y];
 }
void unite(int x, int y) { //按秩合并
	x = find(x); y = find(y);
    if (x == y) continue;
    if (dep[x] < dep[y]) swap(x, y);
    rt[y] = x; dep[x] += (dep[x] == dep[y]);
}

trick——关于合并的思考:我们都知道并查集支持的是合并而非删除操作,而当只有删边和询问时我们有个经典的处理方式:时光逆流,从而将删边改为加边。

带权并查集

有时候,我们需要维护 类似前缀和 的值,即加入点时给出其与父亲之间的 相对关系(通常可以转化成 边权),并实时维护从根节点到此节点的(异或)和等可以使用前缀和得到的相对信息。于是我们想要知道任意两个同集合的点之间的绝对关系就可以通过从根到这两点的相对信息得到。举个简单例子:每个人都有成绩,每次给出两个人的成绩之差。比如有关系 \(a-b=x、b-c=y、b-d=z\) ,那么可以将 \(a\) 作为根,维护其他人与他成绩的差值 \(val(b)=-x、val(c)=-x-y、val(d)=-x-z\)。通过 \(val(d)-val(c)\) 就能逆向得到 \(d\) 比 \(c\) 多 \(y-z\) 分。

这在普通并查集中不难理解和实现,收集树上根节点到此点的链的信息即可。

结构

int rt[N], val[N];

操作

以普通并查集合并时维护位次为例。

int find(int x) {
	if (fa[x] == x) return x;
	int rt = find(fa[x]);
	rnk[x] += rnk[fa[x]];
	return rt;
}
void united(int x, int y) {
	int fx = find(x), fy = find(y);
	rnk[fx] += sz[fy];
	fa[fx] = fy;
	sz[fy] += sz[fx];
	sz[fx] = 0;
}

那么路径压缩可否用于这种带权并查集呢?在路径压缩后或许感觉混乱无比,是不是直接接到根节点就会略过路径的其他边?其实我们剖析一下路径压缩的过程:在 find() 函数中一定是 先一路递归更新了父亲节点到根节点 的信息,并将其接在根上后 再更新自身到父节点 信息,与正常 Dfs 顺序无异。所以我们依然可以直接在父亲节点的基础上加上本点影响。不过注意,一定是在 进入递归前 就记录下父亲节点编号,不然赋过新值后通过 rt[] 找到的就是根节点了。以维护路径点权和或维护异或和为例:

int find(int x) {
    if (x == rt[x]) return x;
    int fa = rt[x];
    rt[x] = find(rt[x]);
    val[x] ^= val[fa]; || val[x] += val[fa];
    return rt[x];
}

合并时,我们遇到了棘手的问题:比如合并 \(x、y\) 所在集合时给出的条件是使 real[x] = real[y] + w (因为带权并查集内部维护的是相对信息,为了便于区分,这里使用"real"表示真实值),但我们合并时是将 \(x\) 的根 fx=find(x) 接到 \(y\) 的根 fy=find(y) 上。因此我们需要求出一个合适的新值(\(fx\) 相对 \(fy\) 的值)赋给 \(val(fx)\),以满足 \(fx\) 原本集合内各元素相对值仍不变,且更新原 \(fx\) 集合内元素的 \(val_{\to fx}\) 值为与 \(fy\) 的相对值 \(val_{\to fy}\) 后, \(val_{\to fy}(x)\) 与 \(val_{\to fy}(y)\) 做差能得到 \(w\)。 因为 find() 后分别得到了 \(x、y\) 与 \(fx、fy\) 的相对值 \(val_{\to fx}(x)、val_{\to fy}(y)\)。那么我们得到:

\[w=val_{\to fy}(x)-val_{\to fy}(y)=val_{\to fx}(x)+val_{\to fy}(fx)-val_{\to fy}(y) \\ \implies val_{\to fy}(fx)=val_{\to fy}(y)+w-val_{\to fx}(x) \]

所以使用解出的这个值,在之后更新原 \(fx\) 集合内元素的 \(val()\) 时就能满足条件。

void unite(int x, int y, int w) {
    int fx = find(x), fy = find(y);
    if (fx == fy) continue;
    rt[fx] = fy;
    val[fx] = val[y] + w - val[x];
}

直观地,将相对值反应成距离,find()后我们可以得到:

image

\(x\) 经由两条路径都到达了 \(fy\),因此两条路径长度也应相等,尽管我们只会保留绿 \(\to\) 红而非蓝 \(\to\) 绿一线,但也能验证上面等式的成立。

扩展域并查集

结构

扩展域并查集使用了与 \(2-SAT\) 类似的建反集思想。这时依然维护每个元素所属集合,但现在的集合之间有阵营之分,可能两个集合毫无关系,也可能互为敌人,集合内部则一定同为朋友。而所谓“反集”,就是假想出一个与元素 \(x\) “作对” 的影子 \(x^{'}\),这在处理敌人关系时可以方便地将敌对阵营都与 \(x^{'}\) 连接,使得 \(x^{'}\) 会联合一切 \(x\) 的敌人阵营,整合形成一个连通块,整个图就近似二分图了。实现时使用 \(x+n\) 可以方便地表示 \(x^{'}\)。

trick:合并时应注意顺序,一般只选择真实的元素(\(1\sim n\))作为集合代表(树的根)方便统计。

image

比如题目给出的是 \(x、y,y、z\) 互为敌人,那么 \(x、z\) 就会被 \(y^{'}\) 拉拢,形成同一阵营,实现快速整合连通块。

int rt[N*2];

操作

扩展域并查集合并时秉持的原则就是:1.朋友的朋友还是朋友;2.敌人的敌人就是朋友。当然,有些时候也会要求朋友的敌人也是敌人,特别注意题目要求。

对于两个朋友关系的 \(a、b\),将他们的阵营直接合并就行,unite(a,b)。而对于两个敌人关系的 \(a、b\),他们的反集 \(a^{'}、b^{'}\) 阵营会分别拉拢 \(b、a\),所以要 unite(a+n, b)、unite(b+n,a) 。朋友关系时用不用 unite(a+n,b+n) 取决于说没说朋友的敌人也是敌人。如果两者毫无瓜葛,这样合并了就相当于无故对其他人宣战,额外树敌使两者敌人联合只是减少了阵营数。

image

譬如上例中我们加入了 \(w、z\) 的友好关系,是否意味着 \(z\) 要对 \(w^{'}\) 保持敌对关系?不一定,题目没有允许朋友的敌人也是敌人时,同一阵营(绿)的不同元素(\(z、w\))有各自的敌人阵营(橙、蓝)也是合法的。

比如在团伙一题中,[不允许这种情况出现](关于种族并查集 - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)),但在食物链中,因为种族概念的影响,同一种族有着共同的天敌,就一定需要合并敌人。又比如程序自动分析中,给出的条件只有相等或不等,但仅靠这种关系, \(x\ne y,x\ne z\Longrightarrow y=z\) 显然不成立,也就是说不能简单地认为“敌人的敌人是朋友”就将他们归为一类。此时只有等式满足传递性,即“朋友的朋友是朋友”生效,故而应该将所有等式条件处理后再判断不等条件是否满足。

可撤销并查集

前文我们才说过并查集仅支持合并,怎么现在就可以撤销了?其实,已经一再强调:路径压缩会破坏原有父子关系,无法复原加入顺序,但普通并查集记录的是原来加入时的父亲,通过记录一个栈维护新加入集合的那个(原)根节点,我们可以方便地在退栈时将它的父亲重定向回自己,实现两个集合的分离。此时失去了路径压缩优化,就一定注意使用按秩合并保证复杂度(毕竟也好不容易完全兼容一次)。经典的使用场景是配合线段树分治判定二分图。

结构

因为会用到按秩合并,所以需要记录加入这棵子树后树高的改变值,stk要开一个pair。

stack<pair<int, int> > stk[N];
int fa[N], h[N];

操作

查询

int find(int x) {return x == fa[x] ? x : find(fa[x]);}

合并

void unite(int x, int y) {
    x = find(x), y = find(y);
    if (x == y) return;
    if (dep[x] < dep[y]) swap(x, y);
    stk.push({y, (dep[x] == dep[y])});
    fa[y] = x; dep[x] += (dep[x] == dep[y]);
}

撤销

在进行加入操作前记录要回退到的位置,在合并并操作完后回溯。回溯途中记得撤回加入时对父节点集合的树高贡献。

void work() {
    int tmp = stk.size();
    /*  unite(...)   */
    while (stk.size() > tmp) {
        int x = stk.top().first, derta = stk.top().second;
        dep[fa[x]] -= derta; fa[x] = x; stk.pop();
    }
}

可持久化并查集

通过主席树可以实现可持久化并查集,先挖坑。

标签:val,int,fy,查集,fx,数据结构,find
From: https://www.cnblogs.com/Arson1st/p/17795806.html

相关文章

  • C++---数据结构---队列(queue)
    queue容器queue基本概念概念:Queue是一种先进先出(FirstInFirstOut,FIFO)的数据结构,它有两个出口队列容器允许从一端新增元素,从另一端移除元素队列中只有队头和队尾才可以被外界使用,因此队列不允许有遍历行为队列中进数据称为—入队push队列中出数据称为—出队popque......
  • 数据结构之栈和队列
    一:物理结构和逻辑结构除了数组和链表之外,常用过的数据结构还有很多,但大对数* 都以数组或链表作为存储方式。数组和链表可以被看作数据存储* 地‘物理结构“**什么是数据存储的物理结构呢?*如果把数据结构比作活生生的人,那么物理结构就是人的血肉*和骨骼,看得见,摸得着,实......
  • 重学递归思想,体悟数据结构奥妙
       说来好笑,暑假一腔热血想进acm,在学插入排序,归并排序这两个玩意,耗费了我整整一个星期都没搞懂,一度让我想放弃,觉得自己刚开始学算法就被打败了,不配coding了,后面请教别人,才发现里面有个递归思想我还不会,所以很痛苦。。。暑假结束了,递归我还没那么懂,今天来复仇了     ......
  • Python数据结构——链表
    链表(LinkedList)是一种基本的数据结构,用于组织和管理数据。它是由一系列节点(Node)组成的数据结构,每个节点包含一个数据元素和指向下一个节点的引用。链表是一种非线性数据结构,与数组不同,它可以根据需要动态分配内存。什么是链表?链表是由节点组成的数据结构,每个节点包含两部分:数据元......
  • 数据结构与算法(LeetCode) 第二节 链表结构、栈、队列、递归行为、哈希表和有序表
    一、链表结构1.单向链表节点结构publicclassNode{ publicintvalue;publicNodenext;publicNode(intdata){value=data;}}2.双向链表节点结构publicclassDoubleNode{publicintvalue;publicDoubleNodelast;publicDouble......
  • 数据结构与算法(LeetCode)第一节:认识复杂度,对数器,二分法与异或运算
    一、认识复杂度1.评估算法优劣的核心指标:时间复杂度:当完成了表达式的建立,只要把最高阶项留下即可。低阶项都去掉,高阶项的系数也去掉,记为O(去掉系数的高阶项);​ 时间复杂度是衡量算法流程的复杂度的一种指标,该指标只与数据量有关,与过程之外的优化无关常见的时间复杂度(从好到坏)O......
  • [考研] 数据结构
    针对数据结构的部分学习笔记。栈出栈排列个数:\(C_{2n}^n\),卡特兰数栈模拟中缀转后缀原理:中缀转后缀的原理是单调栈(维护一个优先级递增的栈),从栈底到栈顶的优先级必然递增,输出时可以保证优先级高的先输出(出栈)。中缀表达式和后缀表达式的不同仅在于符号位置不同,数字之间相......
  • 评论功能的选择难题:数据结构如何选定?
    尊敬的小伙伴们,大家好!我是小米,一个热爱技术、热衷分享的90后程序员。今天,我要和大家一起探讨一个在软件开发中常见,却又充满深度的话题——"面试题:评论功能采用什么数据结构?"。在这个数字化时代,几乎每个应用程序都需要实现评论功能。无论是社交媒体、电子商务网站还是新闻阅读应用,评......
  • 西北电专大二电院_数据结构上机报告记录_第二次上机报告
    第二次上机报告只要求提交了顺序串和顺序栈的基本操作的实现,这里把剩下两个也补充上去 顺序栈——进制转换1.问题描述本程序基于栈功能实现一个进制转换程序。(用顺序栈完成此题)InitStack()函数用于构造一个空栈;StackEmpty()函数用于判断栈是不是空栈;Push()函数实现将......
  • 数据结构与算法 → 深入数据结构
    前置知识前端数据及结构-链表-单向链表......