图论专题-学习笔记:树上启发式合并(dsu on tree)
1. 前言
树上启发式合并(dsu on tree),是一种类似于启发式合并的方式解决关于部分子树内问题的算法,一般都是什么子树内颜色个数等等的。
前置知识:求重儿子(就是树剖那个重儿子)。
2. 详解
树上启发式合并的一般步骤如下:
- 求出重儿子。
- 遍历整棵树,对于一个点 按照如下方式求出其答案:
1.1 首先直接遍历其所有轻儿子求出其所有轻儿子的答案,但是不保留轻儿子对父亲的贡献。
1.2 遍历重儿子,求出重儿子答案,并保留重儿子对父亲的贡献。
1.3 再次通过 dfs 序(或别的方法,但不要直接遍历)遍历轻儿子,求出轻儿子对父亲的贡献。
1.4 如果当前节点是其父亲的轻儿子,删除子树所有节点对答案的影响。
这么说不是特别好懂,上例题:CF600E Lomsat gelral
首先默认都会求重儿子,下记 表示颜色 的出现次数, 表示答案, 表示 最大值。
考虑 1.1 步遍历轻儿子,对于每个轻儿子算一次答案,那么所有轻儿子都会在 上有贡献,但是因为是轻儿子所以 1.4 步中要删除。
考虑 1.2 步遍历重儿子,但是因为是重儿子所有 1.4 步不会删除重儿子对当前节点的贡献。
考虑 1.3 步遍历轻儿子,这里使用 dfs 序遍历,因为一个点子树内 dfs 序是连续的(实现看代码),重新计算轻儿子的贡献,颜色加入到 中。
通过 获取答案,然后如果是轻儿子那么有 1.4 步删除影响,体现到这题中就是 重置为 0。
发现上述过程中我们只遍历了两次轻儿子一次重儿子,是非常不错的选择。
复杂度是 ,但是我不会证()
关于 删除时重置为 0,原因是算答案是一定是先算轻儿子再算重儿子最后重新算轻儿子,因此如果当前点是轻儿子,1.4 步直接重置一定是对的,因为前面没有任何节点在 上有贡献。
至于为什么 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。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南
· 开源Multi-agent AI智能体框架aevatar.ai,欢迎大家贡献代码
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· AI技术革命,工作效率10个最佳AI工具