[数据结构]可持久化并查集
#0.0 前置知识
#0.1 可持久化线段树
#0.2 并查集 - 按秩合并
在可持久化并查集中用到的优化并查集时间复杂度的方法是按秩合并,其实也就是启发式合并。
本质的思想就是在合并两个并查集的时候,将高度大的作为根,这样总体的高度不会增加;如果两个并查集个高度相等,那么作为根的并查集的高度为原本的高度加一。
如:
应当合并为
这样的操作可以保证寻找父亲的过程的最差时间复杂度是 \(O(\log n)\) 的。
#1.0 可持久化并查集
#1.1 经典问题
为了方便下文的讲解,这里还是引用一个 经典问题。
给定 \(n\) 个集合,第 \(i\) 个集合内初始状态下只有一个数,为 \(i\)。
有 \(m\) 次操作。操作分为 \(3\) 种:
-
1 a b
合并 \(a,b\) 所在集合; -
2 k
回到第 \(k\) 次操作(执行三种操作中的任意一种都记为一次操作)之后的状态; -
3 a b
询问 \(a,b\) 是否属于同一集合,如果是则输出 \(1\) ,否则输出 \(0\)。
#1.2 实现思路
其实对于并查集,我们需要实现可持久化的是指向父亲的 fa[]
和按秩合并需要的 dep[]
,而这两个东西都可以用可持久化线段树(可持久化数组)进行维护,所以下面主要来看如何完成上面的三种操作。
#1.2.1 建树
我们需要先初始化最初的可持久化线段树,所有结点的父亲为它本身,深度可以设为 \(0\);
void build(int k, int l, int r) {
p[k].l = l, p[k].r = r;
if (l == r) { p[k].fa = l; return;}
int mid = (l + r) >> 1;
p[k].ls = ++ cnt;
build(p[k].ls, l, mid);
p[k].rs = ++ cnt;
build(p[k].rs, mid + 1, r);
}
#1.2.2 寻找父亲
与一般的并查集一样,我们需要找到当前并查集的根节点,流程如下:
- 得到当前点
x
在由可持久化线段树上哪个节点t
进行维护; - 比较可持久化线段树上维护的
fa
是否是x
:- 如果是,那么传回
t
(注意传回的是在可持久化线段树上对应节点的编号); - 如果不是,那么令
x=fa
,重复上面的步骤。
- 如果是,那么传回
/*获取在可持久化线段树上的编号*/
int get_index(int k, int x) {
if (p[k].l == p[k].r) return k;
int mid = (p[k].l + p[k].r) >> 1;
if (x <= mid) return get_index(p[k].ls, x);
else return get_index(p[k].rs, x);
}
/*找父亲节点,注意不要习惯性的加上路径压缩*/
inline int find(int r, int x) {
int idx = get_index(r, x);
while (p[idx].fa != x) {
x = p[idx].fa;
idx = get_index(r, x);
}
return idx; //传回的是编号
}
#1.2.3 合并两个集合
与一般的并查集的合并的大体流程一致:
- 分别寻找 \(a,b\) 的父亲;
- 判断是否在同一集合:
- 是,不做操作。
- 否,将两个集合按秩合并。
重点是实现的具体细节。寻找父亲这一步,我们可以直接采用上面的代码,注意我们得到的是父亲在可持久化线段树上的对应节点的编号,如果相同,那么意味着在同一集合,否则不在同一集合。
注意到上面提到的按秩合并的操作是将深度小的合并到深度大的中,如果深度相同,作为根的需要深度加一。注意,我们需要在修改深度时创建副本,不然会修改到历史版本的 dep
,导致按秩合并的时间复杂度退化。
void modify(int t, int &k, int x) {
k = ++ cnt;
p[k].l = p[t].l, p[k].r = p[t].r;
if (p[k].l == p[k].r) {
p[k].dep = p[t].dep + 1;
p[k].fa = p[t].fa;
return;
}
int mid = (p[k].l + p[k].r) >> 1;
if (x <= mid) {
p[k].rs = p[t].rs;
modify(p[t].ls, p[k].ls, x);
} else {
p[k].ls = p[t].ls;
modify(p[t].rs, p[k].rs, x);
}
}
scanf("%d%d", &a, &b);
int posa = find(rt[i - 1], a);
int posb = find(rt[i - 1], b);
rt[i] = rt[i - 1]; //先将版本复制过来
if (posa == posb) continue; //在同一集合不必再次合并
if (p[posa].dep > p[posb].dep) swap(posa, posb);
/*选择其中深度大的作为根*/
merge(rt[i - 1], rt[i], p[posa].fa, p[posb].fa);
/*merge 函数下面会讲*/
if (p[posa].dep == p[posb].dep)
modify(rt[i], p[posb].fa);
/*modify 是单点修改,将 p[posb].fa 的深度加一*/
来看看上面的 merge()
究竟是何方神圣:
void merge(int lt, int &k, int x, int y) {
/*新建副本,进行修改*/
k = ++ cnt, p[k].l = p[lt].l, p[k].r = p[lt].r;
if (p[k].l == p[k].r) {
p[k].fa = y;
p[k].dep = p[lt].dep;
return;
}
int mid = (p[k].l + p[k].r) >> 1;
if (x <= mid) {
p[k].rs = p[lt].rs;
merge(p[lt].ls, p[k].ls, x, y);
} else {
p[k].ls = p[lt].ls;
merge(p[lt].rs, p[k].rs, x, y);
}
}
不难发现,这就是可持久化线段树的修改操作。只不过是维护了相应的 dep
和 fa
罢了。
那我们传入的参数为何是 p[posa].fa, p[posb].fa
呢?还记得我们找当前节点是不是并查集的根节点的判断依据就是自己的父亲是否是自己,这里也是同样的原理。
#1.2.4 版本回溯
只需要将当前版本的根节点设为对应版本的根节点即可。
scanf("%d", &a); rt[i] = rt[a];
#1.2.5 判断是否在同一集合
与一般并查集的操作没有区别。
scanf("%d%d", &a, &b); rt[i] = rt[i - 1];
int af = find(rt[i], a), bf = find(rt[i], b);
if (af == bf) printf("1\n");
else printf("0\n");
#2.0 完整代码
const int N = 200010;
const int INF = 0x3fffffff;
struct Node {
int l, r;
int ls, rs;
int fa, dep;
};
Node p[N << 5];
int cnt, n, m, rt[N];
void build(int k, int l, int r) {
p[k].l = l, p[k].r = r;
if (l == r) { p[k].fa = l; return;}
int mid = (l + r) >> 1;
p[k].ls = ++ cnt;
build(p[k].ls, l, mid);
p[k].rs = ++ cnt;
build(p[k].rs, mid + 1, r);
}
int get_index(int k, int x) {
if (p[k].l == p[k].r) return k;
int mid = (p[k].l + p[k].r) >> 1;
if (x <= mid) return get_index(p[k].ls, x);
else return get_index(p[k].rs, x);
}
inline int find(int r, int x) {
int idx = get_index(r, x);
while (p[idx].fa != x) {
x = p[idx].fa;
idx = get_index(r, x);
}
return idx;
}
void merge(int lt, int &k, int x, int y) {
k = ++ cnt, p[k].l = p[lt].l, p[k].r = p[lt].r;
if (p[k].l == p[k].r) {
p[k].fa = y;
p[k].dep = p[lt].dep;
return;
}
int mid = (p[k].l + p[k].r) >> 1;
if (x <= mid) {
p[k].rs = p[lt].rs;
merge(p[lt].ls, p[k].ls, x, y);
} else {
p[k].ls = p[lt].ls;
merge(p[lt].rs, p[k].rs, x, y);
}
}
void modify(int t, int &k, int x) {
k = ++ cnt;
p[k].l = p[t].l, p[k].r = p[t].r;
if (p[k].l == p[k].r) {
p[k].dep = p[t].dep + 1;
p[k].fa = p[t].fa;
return;
}
int mid = (p[k].l + p[k].r) >> 1;
if (x <= mid) {
p[k].rs = p[t].rs;
modify(p[t].ls, p[k].ls, x);
} else {
p[k].ls = p[t].ls;
modify(p[t].rs, p[k].rs, x);
}
}
int main() {
scanf("%d%d", &n, &m);
build(rt[0] = ++ cnt, 1, n);
for (int i = 1; i <= m; i ++) {
int op, a, b; scanf("%d", &op);
if (op == 1) {
scanf("%d%d", &a, &b);
int posa = find(rt[i - 1], a);
int posb = find(rt[i - 1], b);
rt[i] = rt[i - 1];
if (posa == posb) continue;
if (p[posa].dep > p[posb].dep) swap(posa, posb);
merge(rt[i - 1], rt[i], p[posa].fa, p[posb].fa);
if (p[posa].dep == p[posb].dep)
modify(rt[i], rt[i], p[posb].fa);
} else if (op == 2) {
scanf("%d", &a); rt[i] = rt[a];
} else {
scanf("%d%d", &a, &b); rt[i] = rt[i - 1];
int af = find(rt[i], a), bf = find(rt[i], b);
if (af == bf) printf("1\n");
else printf("0\n");
}
}
return 0;
}