启发式合并算法
本文在线更新中。
学树链剖分的时候看到了一些树上启发式合并的题,故记录一下启发式合并。
这是一种极其美丽的算法。但是其实本质还是暴力。
接下来的内容分别是朴素启发式合并与树上启发式合并。
朴素启发式合并
介绍:
我们考虑一堆算好答案的小集合,如果要暴力把两个小集合合并成一个大集合,明显把 siz 小的集合插入到 siz 大的集合更优。
看起来和暴力的 \(O(n ^ 2)\) 没有什么区别,那么具体的复杂度是多少呢?
我们从最开始还没有合并的状态考虑。
一个小集合只有在被合并到其他集合的时候才会对时间复杂度有贡献。而在前面的黑体字条件下,只有这个小集合处在大集合的 siz 将要与一个 siz 比这个大集合的 siz 更大的集合合并时,这个小集合才会做贡献。
问题也就转而成为了,一个小集合每次要和一个比自己 siz 大的集合合并,一共能合并几次?显然答案为 \(O(\log n)\)。
最开始的小集合有 \(n\) 个,那么复杂度为 \(O(n \log n)\)。
有一道例题可以加深理解,即洛谷P3201。
大体上和我们的思路无疑,主要是实现上有问题。这道题可以更好的让大家理解这个“合并”的过程。
#include <bits/stdc++.h>
using namespace std;
constexpr int N = 1e6 + 7;
int ans , n , m , opt , x , y , head[N] , nxt[N] , End[N] , siz[N] , c[N] , belong[N];
inline void merge(int a , int b) {
for(register int i = head[a]; i; i = nxt[i]) {
ans -= (c[i - 1] == b) + (c[i + 1] == b);
}
for(register int i = head[a]; i; i = nxt[i]) {
c[i] = b;
}
//这里说一下,上面这两个for循环顺序是不可变的,因为当且仅当修改之前连续的两个元素不相等才会对答案产生影响
nxt[End[a]] = head[b] , head[b] = head[a] , siz[b] += siz[a];
siz[a] = head[a] = End[a] = 0;
}
int main() {
ios :: sync_with_stdio(0) , cin.tie(0) , cout.tie(0);
cin >> n >> m;
for(register int i = 1; i <= n; ++i) {
cin >> c[i];
belong[c[i]] = c[i];
if(c[i] ^ c[i - 1]) {
++ ans;
}
if(!End[c[i]]) {
End[c[i]] = i;
}
nxt[i] = head[c[i]];
++ siz[c[i]];
head[c[i]] = i;
}
while(m--) {
cin >> opt;
switch(opt) {
case 1 : {
cin >> x >> y;
if(x == y) {
continue;//自己合并自己会导致数组错误清空
}
if(siz[belong[x]] > siz[belong[y]]) {
swap(belong[x] , belong[y]);
}
if(!siz[belong[x]]) {
continue;//如果是已经不存在的情况也会导致数组错误清空
}
merge(belong[x] , belong[y]);
break;
}
case 2 : {
cout << ans << '\n';
break;
}
}
}
return 0;
}
没错,合并就真的是很暴力的一位一位修改。
答案单调不增,所以你模拟就行了。用一个疑似队列,但是有多个队头的数组做。
然后有个要注意的点是什么捏?题目中的要求是你把 x 集合合并到 y 集合上,如果你 y 集合的 siz 小于 x 集合的 siz 你就不符合前面介绍中黑体字的原则了。
那么你就把 y 集合往 x 集合上合并,然后把x , y的含义颠倒就好啦。用一个数组(即 belong 数组)记录。
树上启发式合并
额这个最好先学一些重链剖分的基础理论再看。
介绍:
说实话上面听懂了朴素版,这个很好想。
我们的策略就是每个点继承它重儿子/长儿子的答案,然后对于轻儿子/短儿子暴力计算。
复杂度的话,我可以直接甩一张图嘛qwq?
KH说很好想,我不这样认为,所以还是讲解一下(其实是怕自己忘了)。
重链剖分情况
由于重儿子继承,所以复杂度就是我们暴力统计的所有节点的轻儿子的子树大小之和。然后由于每个轻儿子节点都是一条重链的链头,我们就可以改成枚举重链的链头然后算它子树的大小。
然后这步就可以改成了枚举每个点,然后算他在到根节点的路径上有多少重链,也就是有多少重链链头,也就是会被算多少次。
任何路径都只有 \((\log n)\) 条重链,复杂度为 \(O(n\log n)\)。
长链剖分情况
额前面都是一样的,还更简单些,连改变枚举成点都不需要,直接就能算。每个长链的链头加上高度(即长链的长度)是 \(O(n)\)的。