图论专题-学习笔记:树上启发式合并(dsu on tree)
1. 前言
树上启发式合并(dsu on tree),是一种类似于启发式合并的方式解决关于部分子树内问题的算法,一般都是什么子树内颜色个数等等的。
前置知识:求重儿子(就是树剖那个重儿子)。
2. 详解
树上启发式合并的一般步骤如下:
- 求出重儿子。
- 遍历整棵树,对于一个点 \(x\) 按照如下方式求出其答案:
1.1 首先直接遍历其所有轻儿子求出其所有轻儿子的答案,但是不保留轻儿子对父亲的贡献。
1.2 遍历重儿子,求出重儿子答案,并保留重儿子对父亲的贡献。
1.3 再次通过 dfs 序(或别的方法,但不要直接遍历)遍历轻儿子,求出轻儿子对父亲的贡献。
1.4 如果当前节点是其父亲的轻儿子,删除子树所有节点对答案的影响。
这么说不是特别好懂,上例题:CF600E Lomsat gelral
首先默认都会求重儿子,下记 \(cnt_i\) 表示颜色 \(i\) 的出现次数,\(sum\) 表示答案,\(Maxn\) 表示 \(cnt_i\) 最大值。
考虑 1.1 步遍历轻儿子,对于每个轻儿子算一次答案,那么所有轻儿子都会在 \(cnt_i\) 上有贡献,但是因为是轻儿子所以 1.4 步中要删除。
考虑 1.2 步遍历重儿子,但是因为是重儿子所有 1.4 步不会删除重儿子对当前节点的贡献。
考虑 1.3 步遍历轻儿子,这里使用 dfs 序遍历,因为一个点子树内 dfs 序是连续的(实现看代码),重新计算轻儿子的贡献,颜色加入到 \(cnt_i\) 中。
通过 \(sum,Maxn\) 获取答案,然后如果是轻儿子那么有 1.4 步删除影响,体现到这题中就是 \(sum,Maxn\) 重置为 0。
发现上述过程中我们只遍历了两次轻儿子一次重儿子,是非常不错的选择。
复杂度是 \(O(n\log n)\),但是我不会证()
关于 \(sum,Maxn\) 删除时重置为 0,原因是算答案是一定是先算轻儿子再算重儿子最后重新算轻儿子,因此如果当前点是轻儿子,1.4 步直接重置一定是对的,因为前面没有任何节点在 \(cnt_i\) 上有贡献。
至于为什么 1.3 步说不要直接遍历轻儿子,主要考虑重新遍历后此时轻儿子的贡献就要被保留,然后你还要重新计算一遍轻儿子的答案,此时轻儿子会被遍历多次,并且是否保留贡献会混乱,没准什么时候不该算的贡献就算进去了,时间和正确性都没有保证。
Code:
/*
========= Plozia =========
Author:Plozia
Problem:CF600E Lomsat gelral
Date:2022/10/5
========= Plozia =========
*/
#include <bits/stdc++.h>
typedef long long LL;
const int MAXN = 1e5 + 5;
int n, a[MAXN], Head[MAXN], cntEdge, Size[MAXN], l[MAXN], Son[MAXN], ys[MAXN], fa[MAXN], cnt[MAXN], Maxn, r[MAXN];
LL ans[MAXN], sum;
struct node { int To, Next; } Edge[MAXN << 1];
int Read()
{
int sum = 0, fh = 1; char ch = getchar();
for (; ch < '0' || ch > '9'; ch = getchar()) fh -= (ch == '-') << 1;
for (; ch >= '0' && ch <= '9'; ch = getchar()) sum = (sum << 3) + (sum << 1) + (ch ^ 48);
return sum * fh;
}
void add(int x, int y) { ++cntEdge; Edge[cntEdge] = (node){y, Head[x]}; Head[x] = cntEdge; }
void Add(int p) { ++cnt[a[p]]; if (cnt[a[p]] == Maxn) sum += a[p]; else if (Maxn < cnt[a[p]]) { Maxn = cnt[a[p]]; sum = a[p]; } }
void Del(int p) { --cnt[a[p]]; }
void dfs1(int now, int f)
{
Size[now] = 1, l[now] = ++l[0]; ys[l[0]] = now; fa[now] = f;
for (int i = Head[now]; i; i = Edge[i].Next)
{
int u = Edge[i].To; if (u == f) continue ;
dfs1(u, now); Size[now] += Size[u]; if (Size[u] > Size[Son[now]]) Son[now] = u;
}
r[now] = l[0]; // 通过这一步操作可以直接利用 dfs 序遍历子树
}
void dfs2(int now, int f, int opt) // opt 表示 now 是不是 f 的重儿子
{
for (int i = Head[now]; i; i = Edge[i].Next)
{
int u = Edge[i].To; if (u == f || u == Son[now]) continue ;
dfs2(u, now, 0);
} // 计算轻儿子答案
if (Son[now]) dfs2(Son[now], now, 1); // 计算重儿子答案和贡献
for (int i = Head[now]; i; i = Edge[i].Next)
{
int u = Edge[i].To; if (u == f || u == Son[now]) continue ;
for (int j = l[u]; j <= r[u]; ++j) Add(ys[j]);
} // 重新计算轻儿子贡献
Add(now); ans[now] = sum;
if (opt == 0)
{
for (int i = l[now]; i <= r[now]; ++i) Del(ys[i]);
sum = Maxn = 0;
} // 如果是轻儿子删除贡献
}
int main()
{
n = Read(); for (int i = 1; i <= n; ++i) a[i] = Read();
for (int i = 1; i < n; ++i) { int x = Read(), y = Read(); add(x, y); add(y, x); }
dfs1(1, 1); dfs2(1, 1, 0);
for (int i = 1; i <= n; ++i) printf("%lld%c", ans[i], " \n"[i == n]);
return 0;
}
3. 总结
树上启发式合并,考虑先遍历轻儿子不保留贡献,再遍历重儿子保留贡献,再通过 dfs 序等遍历轻儿子保留贡献,最后若是轻儿子就删除贡献即可。
实际上,树上启发式合并对于子树内统计类问题还是有不错的发挥的,而且比隔壁线段树合并好写多了。
练习:CF741D,CF1709E。