举一个最简单的例子,非连通图G的每一个连通区域的点都可以联合组成一个并查集——每一块连通区域之间互不相通,但是连通区域内的点可以到达该连通区域的任何一个点,符合并查集的特点。
并查集所维护的最经典的例子就是连通子图,
并查集在路径压缩后可以以
一般并查集主要维护的就是上述综述中的 连通子图,
事实上,并查集更多是作为一个工具出现,单纯考察并查集的题目并不多。并查集维护最多的实例就是从属关系:牵一发而动全身,操作一个元素会连带着把所有的元素全部挪动( AT-20 Takahashi's Solitaire,虽然这个好像应该出现在DS-TR-3带权并查集 ) ;亦或是给定一个元素,询问其属于哪个元素的代表集合( (模板)并查集 )。
甚至,某一些奇奇怪怪的广搜都可以用并查集( AT-19 Ladder Takahashi )
事实上,并查集实现的过程很简单,关键的函数一共就两个——查询根节点以及集合元素合并。
对于初始化的时候,直接让每个
对于查询函数,实现流程如下:
int getfather(int x)
{
if(x!=father[x])return getfather(father[x]);
return father[x];
}
这个是递归版本的,卡爆栈的非递归如下:
int getfather(int x)
{
while(x!=father[x])
{
x=father[x];
}
return father[x];
}
其实不难理解这个行为。在这里,每一个
这一点很关键,也是有时候不能路径压缩的原因,father数组可以完整的保存下来集合初始时的样子以及每个元素的前驱指向。
对于集合元素的合并,就更简单了
void Union(int x,int y)
{
x=getfather(x),y=getfather(y);
if(x!=y)
{father[x]=y;}
return;
}
相当干脆的合并。
对于查询的优化可以采用压缩路径的方式,这样只有第一次查询时是
int getfather(int x)
{
if(x!=father[x])return father[x]=getfather(father[x]);
return father[x];
}
现在,经过了 DS-TR-1 的教学,你已经可以熟练地管理哥伦比亚的南方监狱了。各个囚犯的档案和资料以及他们犯的什么类型的罪你都可以通过一般并查集解决了。
但是你现在还有一个问题解决不了:
杰斯顿来了之后煽动了这里的暴乱,哥伦比亚和莱茵生命用了很大的力气才把这群人收拾了一通。而作为新任监狱长的你不想出现上一次的情况,你想把之间有仇恨的囚犯分开。但是,你只知道谁仇恨谁,谁的朋友是谁,怎么样把互相不仇恨的人尽可能的放在一起呢?要知道,敌人的敌人就是朋友,朋友的敌人就是敌人,这帮人之间的关系错综复杂又相互纠缠,这个怎么办?
又或者,在一个生态系统中,
上述的一般并查集只能描述集合之间相近关联(亲戚的亲戚还是亲戚,朋友的朋友还是朋友),却无法建立两个集合之间的对立关联(敌人的敌人是朋友,朋友的敌人是敌人)。如果并查集能把朋友都放在一边,把敌人都放在一边,那么上述两个题就会迎刃而解。
敌人的敌人就是朋友,很循环对称,没毛病。
回到监狱的问题,假设说有
区间
注意这个强调过的相对关系,后面要考。
那么假如说这
那么此时,你又考察到了11号和4号是敌人,你重复了
这个时候你有没有发现,4号作为朋友关系通过11号的敌人连接到了1号的并查集下面,是不是实现了敌人的敌人就是朋友?
那么此时,你维护出来的每一个并查集都是作为一个关系链出来的:
对于
下面说
那么开一个
这样的时候,对于任意一个
(
所以维护
Union(a,b+n);
Union(a+n,b+2n);
Union(a+2n,b+3n);
确实挺不好理解的,但是这步必不可少。因为只有这样才能完整的串联起
的关系。
如果用下面的方式做,会累死的,因为边界条件真的很多,这种小规模的循环关系种类并查集会很精巧的用空间换时间。
#include<bits/stdc++.h>
using namespace std;
int n,m;
vector<int>father(10000001);
void init()
{
for(int i=1;i<=3*n;i++)father[i]=i;
}
int getfather(int x)
{
if(x!=father[x])
{
int fa=father[x];
father[x]=getfather(father[x]);
}
return father[x];
}
void Union(int x,int y)
{
x%=(3*n);
y%=(3*n);
x=getfather(x),y=getfather(y);
if(x!=y)
{
father[x]=y;
}
return;
}
//[1,n]同类,[n+1,2n]捕食,[2n+1,3n]天敌
int main()
{
cin>>n>>m;
init();
int ans=0;
for(int i=1;i<=m;i++)
{
int t,a,b;
cin>>t>>a>>b;
if((t==2&&a==b)||(a>n||b>n))
{
ans++;continue;
}
switch(t)
{
case 1:
if(getfather(a+n)==getfather(b)||getfather(a)==getfather(b+n))//跨单个n区间相连表明捕食关系
{
ans++;continue;
}
else
{
Union(a,b);
Union(a+n,b+n);
Union(a+2*n,b+2*n);
}
break;
case 2:
if(getfather(a)==getfather(b)||getfather(b+n)==getfather(a))
{
ans++;continue;
}
else
{
Union(a+n,b);
Union(a+2*n,b+n);
Union(a+3*n,b+2*n);
}
}
}
cout<<ans<<endl;
system("pause");
return 0;
}
带权并查集相较于普通并查集多了一步权值的操作。定义
按照之前的向量说法理解,如果说普通并查集表达的是一堆的单位向量,侧重于指向性说明,那么 带权并查集表达的的就是任意长度的向量,是真正意义的向量,
按照向量来理解,集合合并过程中的权值操作问题也就便于理解了:
void Union(int x,int y,int val)
{
int fx=getfather(x),fy=getfather(y);
if(fx!=fy)
{
father[fx]=fy;
value[fx]=val-value[y]+value[x];//画两个向量式子极易理解
return;
}
}
查询问题加路径压缩
int getfather(int x)
{
if(x!=father[x])
{
int fa=father[x];//father[x]会被压缩,所以需要手动记录前驱叠加
father[x]=getfather(fatner[x]);
value[x]+=value[fa];
}
return father[x];
}
一定要注意的是,权值信息
给定一段区间的和为x,问当前区间的和与前面的区间有没有冲突,如果有则是错误答案。最后输出错误答案数。
eg:
[1, 10] = 5
[1, 3] = 2
[4, 10] = 4
很显然第三个区间就是错误的,因为[1,3]+[4,10] != [1,10].
涉及到区间信息查询以及冲突判定的一般都是通过并查集快速维护,本题也不例外,这就是一道经典的带权并查集问题。但是本题的坑点在于,之前一直强调的权值不包括根节点的信息,事实上在本题的表现就是并查集必须维护的是一个左闭右开的区间,所以需要对右端点加一操作才能维护完整。如果两端点的根节点不合并,就可以出现;如果根节点重合后val还对不上,那就是错误的信息。
#include <bits/stdc++.h>
using namespace std;
#define int long long
int n, m;
int ans = 0;
vector<int> valuerem(200001);
vector<int> father(200001);
vector<int> value;
void init()
{
iota(father.begin() + 1, father.end(), 1L);
value.clear();
value = valuerem;
}
int getfather(int x) // 路径压缩的时候权值同时压缩
{
if (x != father[x])
{
int fa = father[x];
father[x] = getfather(fa); // 先定father后压缩
value[x] += value[fa]; // 路径权值压缩,压缩的时候要注意保留原始记录,因为
// father[x]此时已经完成压缩,不再是原值,中间会有错
}
return father[x];
}
void Union(int x, int y, int val) // 明确x向y压缩,就一定是x向y合并,不要根据父节点大小来调整压缩目标,明确向量特点
{
int fx = getfather(x);
int fy = getfather(y);
if (fx != fy)
{
father[fx] = fy;
value[fx] = val + value[y] - value[x];
}
else
{
if (value[x] - value[y] != val)
ans++;
}
}
signed main()
{
while (cin >> n >> m)//原题没说明白,每次输入多组数据实例
{
ans = 0;
init();
int u, v, d, fa, fb;
for (int i = 0; i < m; i++)
{
cin>>u>>v>>d;
Union(u,v+1,d);
}
printf("%d\n", ans);
}
system("pause");
return 0;
}
另一个比较典的就是 AT-20 Takahashi's Solitaire ,虽然暴力扫区间是可以的,但是这种带权值的思想非常可取。
再深层的以及例题留坑,待处理。
请我喝杯咖啡吧~