冰茶姬(一种饮料?)
并查集(正经
并查集是一种支持动态 link 的,能够快速判断两元素是否处于同一集合的数据结构。
(如果需要 cut,请出门右转 LCT)
那么其基本操作就包括:
-
连边。在两个点之间连一条边,使得两个点所在的两个集合合并为一个集合。
-
查询。查询两个点是否处在同一个集合中。
这也是普通并查集的所有操作了。
暴力并查集
核心
当连边的时候,如果两个点本来就在一个集合里,是不需要再连边的。因此整个并查集的形态便是森林。
一般而言,树都是有根的,那么确定两个点分别属于哪个集合的时候,就可以找到并判断它们的根是否相等。
而连边的时候,也仅需要将两个根连接起来,因此找根是并查集的核心操作。
既然要找根,那么也就需要知道点与点之间的父(子)关系(确定父关系足矣)。
初始的时候,每个点各自为营,那么其父亲就是他们自己(我当我的爹)。
for (int i = 1; i <= n; i++)
fa[i] = i;
每次操作的时候,不管是连边还是查询,都需要先找到两个点所在树的根。
一个很朴素的想法就是,既然知道父关系,那么就根据父关系一路往上跳。
根据初始化,直到当前节点的父亲为自己,就是找到根了。
代码如下:
int find(int x)
{
if (x == fa[x]) return x;
return find(fa[x]);
}
找到根之后,这两个操作也就很容易实现了:
for (int i = 1; i <= m; i++)
{
int op, u, v;
cin >> op >> u >> v;
int ru = find(u), rv = find(v);
if (op == 1)
fa[ru] = rv;
else
cout << (ru == rv ? "Y" : "N") << '\n';
}
时间复杂度
一般而言,时间复杂度都会有平均、最优、最差等几种。
最优一般是没有讨论意义的,因为总有好(du)人(liu)卡极限数据,那么此处就讨论一下极限数据时的最差情况。
在极限数据下,会出现这种情况:
每次将当前最大的树的根连在一个单节点树上,最终这棵树的深度则为 \(n\),此时最坏情况下的单次查询的复杂度为 \(O(n)\),总复杂度为 \(O(qn)\)。
这时候也就需要有一个优化。
路径压缩
由上可以发现,其实我们只期望知道根而不关心路径上通过了哪些点。
那么是否可以每次将我们所经过的点的父亲都更新成根呢?
当然可以,只需要将我们的 find 改成这样:
int find(int x)
{
if (x == fa[x]) return x;
return fa[x] = find(fa[x]);
}
相当于是一次记忆化搜索,核心就在于 fa[x] = find(fa[x]),在递归的过程中,不断更新经过的点的父亲,以达到路径压缩的目的,下次找 x 点的时候便能直接指向其根。
时间复杂度
这个优化计算时间复杂度是通过势能分析得到的均摊复杂度,证明在这(反正我是看不懂)。
按秩合并 / 启发式合并
除了路径压缩,还有一个很简单的想法,每次将小的合并到大的身上,这样就可以避免出现深度过深的情况了。
按秩合并和启发式合并是两种计算大小的方法,一种是计算树的大小,另一种是计算树的深度。
由于二者十分相似,一般不会太过细致的区分,不同的文章写的也不一样。
按大小(秩)的话比较简单,直接比较两个树的 siz 大小,将小的合并到大的上即可。
int find(int x)
{
if (fa[x] == x) return x;
return (find(fa[x]));
}
void merge(int x, int y)
{
x = find(x), y = find(y);
if (siz[x] > siz[y])
swap(x, y);
fa[x] = y;
siz[y] += siz[x];
}
但是实测按大小合并的复杂度并不优秀。
而按深度合并(启发式)则稍微复杂一些,因为如果两个树的深度不相等的话,小树合并到大树是不会影响大树的深度的,而两树深度相同的时候,合并才会使整体的深度 \(+1\)。
int find(int x)
{
if (fa[x] == x) return x;
return (find(fa[x]));
}
void merge(int x, int y)
{
x = find(x), y = find(y);
if (dep[x] > dep[y])
swap(x, y);
fa[x] = y;
if (dep[x] == dep[y])
dep[y]++;
}
这两种方法的 find 则不需要使用路径压缩。
当然,这两种优化方式是可以一起使用的,就是启发式合并的时候也使用路径压缩。但是对于大多数数据而言,单独使用两种优化也是足够优秀的,所以同时使用两种优化并不比单独使用其中一种优秀很多。
完整代码自己打(懒
一道好题。
需要用到离散化。
基础应用
并查集的应用有最小生成树,虽然一些其他算法也有用到并查集(比如某些 DP,左偏树),但是 kruskal 可以说是其代表了。
边带权
思路:
显然这是一道并查集判连通的题目,但是不同之处在于,这道题不仅需要判断两个点是否处于同一连通块,查询时需要知道两个点之间的距离。
如果用一个二维数组去储存两点之间的距离的话,需要的空间是 \(O(n^2)\) 的,显然不允许。
思考并查集有一个什么特点。没错,每个点都能很容易的找到自己的根节点。那么如果我们储存每个点到其根节点的距离,如果两个点在同一个集合里时,就可以通过到根节点的距离之差来代表两个点之间的距离,空间复杂度 \(O(n)\),可以接受。
这种需要考虑点之间距离的并查集,就叫做边带权。
用 dis 来代表当前结点到其父结点节点的距离,siz 代表当前并查集的大小。
找根时,除了需要更新 fa,还需要更新 dis:
int find(int x)
{
if (fa[x] == x) return x;
dis[x] += dis[fa[x]];
return fa[x] = find(fa[x]);
}
可以发现,让路径压缩之后,dis 便可认为是当前结点到并查集的根结点的距离。
合并的时候,同样需要更新 dis,不过只需要更新根结点。同时更新根结点的 siz:
void merge(int x, int y)
{
x = find(x), y = find(y);
fa[y] = x;
dis[y] = siz[x];
siz[x] += siz[y];
}
查询操作则最为简单,此处略。
拓展域
拓展域一般用于解决一个点有多种性质的问题,如上。
一个人可以分为两个域:朋友域和敌人域。
根据朋友的朋友是朋友,可以得到朋友域处于同一个集合代表这些人互为是朋友。
而根据敌人的敌人是朋友,如果小甲和小乙互为敌人,小乙和小丙互为敌人,那么小甲和小丙应该是朋友,但是小甲和小丙不能直接通过朋友域连接,而是应该通过小乙的敌人域进行连接。
所以敌人域不会直接相连,它仅仅起到对朋友域进行间接连接的作用。
理论知识比较简单,而代码实现时,一般用 i,i+n 等来代表第 \(i\) 个人的不同域。
完整代码:
vector<int> fa;
int find(int x)
{
if (fa[x] == x)
return x;
return fa[x] = find(fa[x]);
}
int main()
{
int n, m;
cin >> n >> m;
fa.resize(n * 2 + 7);
for (int i = 1; i <= n * 2; i++)
fa[i] = i;
for (int i = 1; i <= m; i++)
{
string op;
int u, v;
cin >> op >> u >> v;
if (op == "F")
fa[find(u)] = find(v);
else
{
fa[find(u)] = find(v + n);
fa[find(v)] = find(u + n);
}
}
vector<bool> vis(n * 2 + 7);
int ans = 0;
for (int i = 1; i <= n; i++)
{
int rot = find(i);
if (vis[rot])
continue;
vis[rot] = 1;
ans++;
}
cout << ans << '\n';
return 0;
}
(这个图可以点的啊喂)
可持久化
其实仅仅是用可持久化数组优化了并查集的 fa 和 dep 数组,使得并查集变得可持久化。


浙公网安备 33010602011771号