代码和思路参考:并查集2个优化——按秩合并和路径压缩
,并查集讲解(按秩合并与路径压缩),模板与典型例题,并查集(按秩合并) 和《算法竞赛进阶指南》
并查集优化
一、路径压缩
当我们只关心每个集合的根是什么,而不关心它的具体形态时,就可以将每一个节点都指向根节点
get
均摊复杂度:
O
(
l
o
g
N
)
O(log N)
O(logN)
1.递归
(短小精悍好写但是可能会栈溢出)
int get(int x)
{
return fa[x] == x ? x : fa[x] = get(fa[x]);
}
2.非递归
int get(int x)
{
if(fa[x] == x) return x;
int r = x;
while(fa[r] != r)
{
r = fa[r];
}
//找到根节点
int k = x, f;
while(k != r)
{
f = fa[k];
fa[k] = r;
k = f;
}
//把经过的节点都指向根
return r;
}
/*
int get(int x)//循环实现的find和路径压缩
{
while(x != fa[x]) x = fa[x] = fa[fa[x]];//表示这句话很强...
return x;
}
*/
二、按秩合并
秩一般有两种定义:
- 集合的大小
- 未进行路径压缩时(即原本给出的树)树的深度
按秩合并,就是把集合的“秩”记录在根节点上,在合并时将“秩”较小的节点作为“秩”较大的节点的子节点
当“秩”定义为集合大小时,“按秩合并”也称为“启发式合并”,是数据结构中一种重要思想,不止局限于并查集
启发式合并原则:把“小的结构”合并到“大的结构中”,并只增加“小的结构”的查询代价
rank[x]
记录x这棵树的树高(就是到树根最长的链的长度)
树高最高为 log2(N)
如果令fa[x] = y
,那么y的树高最小为rank[x] + 1
所以rank[y] = max(rank[y], rank[x] + 1)
get
均摊复杂度:
O
(
l
o
g
N
)
O(log N)
O(logN)(因为树高范围)
1.思路如上,直观代码
void merge(int x, int y)
{
x = get(x), y = get(y);
if(x == y) return;
if(rank[x] <= rank[y])
{
fa[x] = y;
rank[y] = max(rank[x] + 1, rank[y]);
}
else
{
fa[y] = x;
rank[x] = max(rank[x], rank[y] + 1);
}
}
2.更简洁常用,其实和上面的一样
void merge(int x, int y)
{
x = get(x), y = get(y);
if(x == y) return;
if(rank[x] < rank[y])
fa[x] = y;
else
{
fa[y] = x;
if(rank[x] == rank[y]) rank[x]++;
}
}
例题:UVA11354 Bond
题目大意:有一张n个点m条边的无向图, 每条边有一个危险度,有q个询问, 每次给出两个点s、t,找一条路径, 使得路径上的最大危险度最小
思路:Kruskal + 按秩合并
题目并不保证为一棵树,但保证s到t存在路径,容易想到先建最小生成树,在这棵树上的路径的最大危险度一定是最小的
但是直接遍历的话每一次复杂度为
O
(
N
)
O(N)
O(N),n和q又大,过不了
于是想到在Kruskal的并查集上加优化
但常用的路径压缩又不行,那样会破坏树的结构,这时按秩合并就派上用场了
代码&解读
#include <iostream>
#include <cstdio>
#include <algorithm>
const int N = 50005, M = 100005;
int fa[N], rank[N], pre[N], vis[N];
struct Edge
{
int u, v, w;
bool operator <(const Edge &x) const
{
return w < x.w;
}
}e[M];
int n, m;
int max(int a, int b)
{
return a > b ? a : b;
}
int get(int x)
{
return fa[x] == x ? x : get(fa[x]);
}
void merge(int x, int y, int z)
{
x = get(x), y = get(y);
if(x == y) return;
if(rank[x] < rank[y])
{
fa[x] = y;
pre[x] = z;
}
else
{
fa[y] = x;
pre[y] = z;
if(rank[x] == rank[y]) rank[x]++;
}
}
int query(int x, int y)
{
int ans = 0, k;
k = x;
//从x走到根
while(1)
{
vis[k] = ans;
if(fa[k] == k) break;
ans = max(ans, pre[k]);
k = fa[k];
}
ans = 0;
k = y;
//从y走到根
while(1)
{
if(vis[k] >= 0)
{
ans = max(ans, vis[k]);
break;
}
if(fa[k] == k) break;
ans = max(ans, pre[k]);
k = fa[k];
}
k = x;
//x到根
while(1)
{
vis[k] = -1;
if(fa[k] == k) break;
k = fa[k];
}
return ans;
}
void init()
{
std::sort(e, e+m);
for(int i = 1; i <= n; ++i)
{
fa[i] = i;
rank[i] = 0;
vis[i] = -1;//vis初值要<0
}
for(int i = 0; i < m; ++i)
{
merge(e[i].u, e[i].v, e[i].w);
}
}
int main(){
int t = 0;
while(scanf("%d%d", &n, &m) == 2)
{
if(t) printf("\n");
else t++;
for(int i = 0; i < m; ++i)
{
scanf("%d%d%d", &e[i].u, &e[i].v, &e[i].w);
}
init();
int q, s, t;
scanf("%d", &q);
for(int i = 0; i < q; ++i)
{
scanf("%d%d", &s, &t);
printf("%d\n", query(s, t));
}
}
return 0;
}
变量:
pre[x]
表示最小生成树上由x的父节点走向x的那条边的边权
vis[x]
记录在某一次询问中树上从s到x的路径的最大危险度,同时可以判断x有没有被走过
merge()
读入边的数据后先按危险度从小到大排序,这样保证先插入的路径是更优的
也就是说如果现在x和y在同一集合中,那么连接x和y所在集合的边就不必加入
query()
注意前两个while()
只有第一个有vis值的改变
x到y,可分为三种情况
-
lca(x, y) = y
这种情况只需x向上走,就能遇到y
那么第二个while()
中,有vis[y] >= 0
,则ans = vis[y]
后立即退出 -
lca(x, y) = x
这种情况y向上遇到x,发现vis[x] = 0
,ans
不变,直接退出 -
lca(x, y) != x && lca(x, y) != y
(也就是x,y不在一条链上)
在第二个while()
中,走到lca(x, y)
,有vis[lca(x, y)] >= 0
,于是ans = max(vis[lca(x, y)](x到lca(x, y)的最大危险度), ans(y到lca(x, y)的最大危险度))
那么第三个while()
是干什么的呢
在第一个while()
中,从x到根经过的节点的vis值都改变了,但这只适用于起点为x的情况,所以要再走一遍将vis改回来
不得不说算法文化博大精深
为什么要一直走到根
为什么第二个while()
不改变vis
细思极恐啊我是不明白为什么会想到这样写QAQ