启发式合并(dsu-on-tree)

\(\text{codeforces-600e}\)

给你一棵以结点 \(1\) 为根的有根树,每个节点最开始都被涂上了颜色。

如果颜色 \(c\) 在以结点 \(v\) 为根的子树中出现次数最多,则称其在以结点 \(v\) 为根的子树中占重要地位。一棵树中可以有很多颜色同时占重要地位

\(v\) 为根的子树指结点 \(v\) 及其他到根结点的路径包含 \(v\) 的结点。

请输出对于每一个结点 \(v\),在其子树中占重要地位的颜色编号之和。

\(1 \le n \le 10 ^ 5\)\(1 \le c _ i \le n\)


树上启发式合并模板题。

以下部分题解来自于 题解 CF600E 【Lomsat gelral】 - 洛谷专栏

\(\text{dsu on tree}\) 一般用来解决一类不带修的子树查询问题。

其核心思想为:利用重链剖分的性质优化子树贡献的计算。

具体流程如下:

  • 跑一边搜索,预处理出每个点的重儿子,重儿子的定义顾名思义就是,所在子树节点个数最多的儿子。
  • 对于一个点 \(x\),先计算他所有轻儿子的贡献,而且计算完要清空贡献,接下来处理重儿子 \(son_x\) 的贡献,并保留 \(st(son_x)\)\(st(x)\) 表示 \(x\) 的子树。
  • 之后暴力加入 \(x\) 的轻儿子所在子树的贡献,此时即可得到 \(ans_x\)

该算法的时间复杂度为 \(O(n \log n)\),可用重链剖分的性质证明。

#include<iostream>
#include<cstdio>
#include<vector>
using namespace std;
#define MAXN 100005

long long read() {
	long long x = 0, f = 1;
	char c = getchar();
	while(c > 57 || c < 48) { if(c == 45) f = -1; c = getchar(); }
	while(c >= 48 && c <= 57) { x = (x << 1) + (x << 3) + (c - 48); c = getchar(); }
	return x * f;
}

long long n, a[MAXN], sz[MAXN], son[MAXN], cnt[MAXN];
long long ans[MAXN], p[MAXN], t, res, tp;
vector<long long> v[MAXN];

void dfs(long long x, long long fa) {
	sz[x] = 1;
	for(auto y : v[x]) if(y != fa) {
		dfs(y, x), sz[x] += sz[y];
		if(sz[y] > sz[son[x]]) son[x] = y;
	}
	return;
}

void Clear() {
	while(t) cnt[p[t --]] = 0;
	res = tp = 0; return;
}

void insert(long long x) {
	cnt[p[++ t] = a[x]] ++;
	if(cnt[a[x]] > tp) res = a[x], tp = cnt[res];
	else if(cnt[a[x]] == tp) res += a[x];
	return;
}

void add(long long x, long long fa) {
	insert(x);
	for(auto y : v[x]) if(y != fa) add(y, x);
	return;
}

void dsu(long long x, long long fa) {
	for(auto y : v[x]) if(y != fa && y != son[x]) dsu(y, x), Clear();
	if(son[x]) dsu(son[x], x);
	for(auto y : v[x]) if(y != fa && y != son[x]) add(y, x);
	insert(x), ans[x] = res;
	return;
}

int main() {
	n = read();
	for(int i = 1; i <= n; i ++) a[i] = read();
	for(int i = 1; i < n; i ++) {
		long long x = read(), y = read();
		v[x].push_back(y), v[y].push_back(x);
	} 
	dfs(1, 0), dsu(1, 0);
	for(int i = 1; i <= n; i ++) cout << ans[i] << " ";
	cout << "\n";
	return 0;
}

\(\text{luogu-5290}\)

给定一棵 \(n\) 个点的树,其中 \(1\) 为根节点,每个点有点权 \(a_i\)

你需要将这棵树上的节点划分到若干个段中,满足每个段中任意两个点不存在祖先-后代关系。

一个段的价值为段中点的点权最大值,求所有段的价值和的最小值。

\(1 \le n \le 2 \times 10^5\)\(1 \le \max a_i \le 10^9\)


我们从叶子节点开始考虑,对于只有一个叶子节点时,显然只能把叶子节点划分成一个段。

接着向上考虑,此时有一个节点和他的若干儿子,那么显然把所有儿子合并成一个段更优。

接着向上推广,此时一个节点有若干子树,那么子树间的段可以合并。

合并时不需要把一整个段合并,只需要合并段中的最大值即可。

但暴力合并每次是 \(O(\max(sz_x, sz_y))\) 的,总时间复杂度最坏 \(O(n^2)\)

考虑启发式合并,本质上就是把 \(sz_i\) 小的向大的里合并。

对于两个排好序的堆合并,我们只需要让 \(sz_i\) 小的和另一个的前 \(sz_i\) 个取 \(\max\) 合并。

剩下的不需要管,也就是不参与合并。

于是最多合并 \(O(n)\) 次,但因为是堆,所以时间复杂度为 \(O(n \log n)\)

#include<iostream>
#include<cstdio>
#include<queue>
#include<vector>
using namespace std;
#define MAXN 200005

long long read() {
	long long x = 0, f = 1;
	char c = getchar();
	while(c > 57 || c < 48) { if(c == 45) f = -1; c = getchar(); }
	while(c >= 48 && c <= 57) { x = (x << 1) + (x << 3) + (c - 48); c = getchar(); }
	return x * f;
}

priority_queue<long long> q[MAXN];
vector<long long> v[MAXN], t;
long long n, a[MAXN];

void merge(long long x, long long y) {
	if(q[x].size() < q[y].size()) swap(q[x], q[y]);
	while(!q[y].empty()) {
		t.push_back(max(q[x].top(), q[y].top()));
		q[x].pop(), q[y].pop();
	}
	while(!t.empty()) q[x].push(t.back()), t.pop_back();
	return;
}

void dsu(long long x) {
	for(auto y : v[x]) dsu(y), merge(x, y);
	q[x].push(a[x]); return;
}

int main() {
	n = read();
	for(int i = 1; i <= n; i ++) a[i] = read();
	for(int i = 2; i <= n; i ++) v[read()].push_back(i);
	dsu(1); long long ans = 0;
	while(!q[1].empty()) ans += q[1].top(), q[1].pop();
	cout << ans << "\n";
	return 0;
}

\(\text{codeforces-1709e}\)

给定一棵包含 \(n\) 个顶点的树。每个顶点上写有一个数字,第 \(i\) 个顶点上的数字为 \(a_i\)

我们称一条简单路径为每个顶点最多访问一次的路径。路径的权值定义为该路径上所有顶点的值的按位异或。我们称一棵树是“好”的,如果不存在权值为 \(0\) 的简单路径。

你可以进行如下操作任意次(也可以不进行):选择树上的一个顶点,将其上的值替换为任意正整数。请问,最少需要进行多少次操作,才能使这棵树变为“好”的?

\(1 \le n \le 2 \times 10^5\)\(1 \le a_i < 2^{30}\)\(1 \le x,y \le n\)\(x \ne y\)


以下部分题解来自于 CF1709E XOR Tree(set 启发式合并) - 洛谷专栏

\(a_u\) 表示 \(u\) 的点权,\(d_u\) 表示树上 \(1\)\(u\) 简单路径上所有点的点权异或和。

题目中 \(u\)\(v\) 的简单路径上的所有点点权异或和 \(=0\) 等价于 \(d_u\oplus d_v\oplus a_{\text{lca}(u,v)}=0\)

可以证明如果允许改变 \(a_u\),那么一定可以使以 \(u\) 为根的子树中不存在异或和为 \(0\) 的简单路径(一种构造方法是令 \(a_u=2^{u+11^{4514}}\))。

考虑对每个点开一个 set,记录其子树中(包括自身)所有点的 \(d\)。特别的,若一个点被改变,则其 set 为空 \(^{[1]}\)

在枚举一个点 \(rt\) 的所有儿子时,设现有集合为 \(S\),该儿子的集合为 \(T\),则枚举 \(T\) 中的所有元素。设当前枚举元素为 \(T_i\),在 \(S\) 中查找是否存在 \(a_{rt}\oplus T_i\),若存在,则子树中一定存在两个点 \(u,v\) 满足 \(d_u\oplus d_v\oplus a_{rt}=0\)。因此一定要改变 \(a_{rt}\)

\({[1]}\):若改变了 \(a_{rt}\),那么以 \(rt\) 为根的子树中的所有点就不必参与 \(rt\) 祖先点中的讨论,因为 \(a_{rt}=2^{u+11^{4514}}\),不可能有以 以 \(rt\) 为根的子树中的点 为端点的简单路径的异或和为 \(0\),因此清空 \(S_{rt}\)

复杂度 \(O(n^2\log n)\),用 启发式合并 可以使复杂度降低到 \(O(n\log^2n)\)

#include<iostream>
#include<cstdio>
#include<vector>
#include<set>
using namespace std;
#define MAXN 200005

long long read() {
	long long x = 0, f = 1;
	char c = getchar();
	while(c > 57 || c < 48) { if(c == 45) f = -1; c = getchar(); }
	while(c >= 48 && c <= 57) { x = (x << 1) + (x << 3) + (c - 48); c = getchar(); }
	return x * f;
}

long long n, a[MAXN], b[MAXN], ans;
vector<long long> v[MAXN];
set<long long> s[MAXN];

void dsu(long long x, long long fa) {
	s[x].insert(b[x]); bool fg = 0;
	for(auto y : v[x]) if(y != fa) {
		b[y] = b[x] ^ a[y], dsu(y, x); 
		if(s[x].size() < s[y].size()) swap(s[x], s[y]);
		for(auto t : s[y]) if(s[x].find(a[x] ^ t) != s[x].end()) fg = 1;
		for(auto t : s[y]) s[x].insert(t);
	}
	if(fg) ans ++, s[x].clear();
	return;
}

int main() {
	n = read();
	for(int i = 1; i <= n; i ++) a[i] = read();
	for(int i = 1; i < n; i ++) {
		long long x = read(), y = read();
		v[x].push_back(y), v[y].push_back(x);
	}
	dsu(1, 0); cout << ans << "\n";
	return 0;
}

\(\text{luogu-4556}\)

村落里一共有 \(n\) 座房屋,并形成一个树状结构。然后救济粮分 \(m\) 次发放,每次选择两个房屋 \((x, y)\),然后对于 \(x\)\(y\) 的路径上(含 \(x\)\(y\))每座房子里发放一袋 \(z\) 类型的救济粮。

然后深绘里想知道,当所有的救济粮发放完毕后,每座房子里存放的最多的是哪种救济粮。

\(1 \leq n, m \leq 10^5\)\(1 \leq a,b,x,y \leq n\)\(1 \leq z \leq 10^5\)


线段树合并模板题。

以下部分题解来自于 线段树合并 (学习笔记)(26.1.19) - Yuriha - 博客园

概述

可以将两棵线段树合并为一颗的算法,一般用在动态开点线段树和权值线段树中。

对于一些需要维护子树权值的题目中,父亲节点需要去合并自己的两个子树节点,对于普通的线段树,我们只需要去权值合并就行,但是对于动态开点线段树,如果我们没有一些优化,就会导致时间空间发生一些问题了,所以相应的线段树合并就诞生了。

思路

线段树合并面临的共有三种情况。

  1. 儿子都为空,我们直接不管这个节点即可。
  2. 儿子都不为空,我们就需要递归去加两个儿子。
  3. 有一个儿子为空,我们可以直接将这个儿子的信息加到当前线段树,另一个不管。

实现

这道题还用到了动态开点和权值线段树和树上差分。

对于动态开点,就是去正常的进行线段树操作,在进入的时候判断一下某个下标是否存在,如果不存在,就去新建一个下标,所以要专门储存左儿子和右儿子,不能直接去 p<<1 这样类似的操作。

对于权值线段树,这是一个把权值作为下标进行操作的数据结构,它维护了在值域 \([1, V]\) 区间内的所有权值计数,在第二次 \(dfs\) 操作,对于某个节点,遍历其子树将所有节点加至它自己,\(t_x\) 做的是以 \(x\) 为节点的权值线段树的根。

对于树上差分,主要就是可以用链表方式存储其对应的所有修改操作,其他无太多。

#include<iostream>
#include<cstdio>
#include<vector> 
#include<cmath>
using namespace std;
#define MAXN 100005

long long read() {
	long long x = 0, f = 1;
	char c = getchar();
	while(c > 57 || c < 48) { if(c == 45) f = -1; c = getchar(); }
	while(c >= 48 && c <= 57) { x = (x << 1) + (x << 3) + (c - 48); c = getchar(); }
	return x * f;
}

long long n, m, lg[MAXN], fa[MAXN][30], dep[MAXN], dn, hd[MAXN], ans[MAXN];
long long ls[MAXN * 80], rs[MAXN * 80], t[MAXN], mx[MAXN * 80], cnt;
struct node { long long w, nxt; } res[MAXN << 2];
vector<long long> v[MAXN];

void dfs(long long x, long long f) {
	fa[x][0] = f, dep[x] = dep[f] + 1;
	for(int i = 1; i <= lg[dep[x]]; i ++)
		fa[x][i] = fa[fa[x][i - 1]][i - 1];
	for(auto y : v[x]) if(y != f) dfs(y, x);
	return;
}

long long lca(long long x, long long y) {
	if(dep[x] < dep[y]) swap(x, y);
	while(dep[x] > dep[y]) 
		x = fa[x][lg[dep[x] - dep[y]] - 1];
	if(x == y) return x;
	for(int i = lg[dep[x]] - 1; i >= 0; i --) 
		if(fa[x][i] != fa[y][i]) x = fa[x][i], y = fa[y][i];
	return fa[x][0];
}

void add(long long x, long long w) {
	res[++ dn] = {w, hd[x]}, hd[x] = dn;
	return;
}

long long merge(long long x, long long y, long long l, long long r) {
	if(!x || !y) return x | y;
	if(l == r) { mx[x] += mx[y]; return x; }
	long long mid = (l + r) >> 1;
	ls[x] = merge(ls[x], ls[y], l, mid);
	rs[x] = merge(rs[x], rs[y], mid + 1, r);
	mx[x] = max(mx[ls[x]], mx[rs[x]]);
	return x;
}

void update(long long &x, long long l, long long r, long long p, long long w) {
	if(!x) x = (++ cnt);
	if(l == r) { mx[x] += w; return; }
	long long mid = (l + r) >> 1;
	if(p <= mid) update(ls[x], l, mid, p, w);
	else update(rs[x], mid + 1, r, p, w);
	mx[x] = max(mx[ls[x]], mx[rs[x]]);
	return;
}

long long query(long long x, long long l, long long r) {
	if(l == r) return l;
	long long mid = (l + r) >> 1;
	if(mx[x] == mx[ls[x]]) return query(ls[x], l, mid);
	return query(rs[x], mid + 1, r);
}

void dfs1(long long x, long long f) {
	for(auto y : v[x]) if(y != f) 
		dfs1(y, x), t[x] = merge(t[x], t[y], 1, 1e5);
	for(int i = hd[x]; i; i = res[i].nxt) 
		update(t[x], 1, 1e5, abs(res[i].w), (res[i].w >= 0) ? 1 : -1);
	if(mx[t[x]]) ans[x] = query(t[x], 1, 1e5);
	return;
}

int main() {
	n = read(), m = read();
	for(int i = 1; i < n; i ++) {
		long long x = read(), y = read();
		v[x].push_back(y), v[y].push_back(x);
	}
	for(int i = 1; i < MAXN; i ++) lg[i] = lg[i >> 1] + 1;
	dfs(1, 0);
	for(int i = 1; i <= m; i ++) {
		long long x = read(), y = read(), z = read();
		long long t = lca(x, y);
		add(x, z), add(y, z), add(t, -z), add(fa[t][0], -z);
	}
	dfs1(1, 0);
	for(int i = 1; i <= n; i ++) cout << ans[i] << "\n";
	return 0;
}

\(\text{luogu-3224}\)

永无乡包含 \(n\) 座岛,编号从 \(1 \sim n\) ,每座岛都有自己的独一无二的重要度,按照重要度可以将这 \(n\) 座岛排名,名次用 \(1 \sim n\) 来表示。某些岛之间由巨大的桥连接,通过桥可以从一个岛到达另一个岛。

现在有两种操作:

B x y 表示在岛 \(x\) 与岛 \(y\) 之间修建一座新桥。

Q x k 表示询问当前与岛 \(x\) 连通的所有岛中第 \(k\) 重要的是哪座岛,即所有与岛 \(x\) 连通的岛中重要度排名第 \(k\) 小的岛是哪座,请你输出那个岛的编号。

\(1 \leq m \leq n \leq 10^5\), \(1 \leq q \leq 3 \times 10^5\)


考虑用动态开点权值线段树,维护重要度。并查集维护连通性。

每次建边时把两个线段树合并就好了。

询问操作直接线段树上二分即可。

#include<iostream>
#include<cstdio>
using namespace std;
#define MAXN 200005

long long read() {
	long long x = 0, f = 1;
	char c = getchar();
	while(c > 57 || c < 48) { if(c == 45) f = -1; c = getchar(); }
	while(c >= 48 && c <= 57) { x = (x << 1) + (x << 3) + (c - 48); c = getchar(); }
	return x * f;
}

struct node { long long l, r, ls, rs, w; } t[MAXN << 5];
long long n, m, q, fa[MAXN], T[MAXN], ans[MAXN], cnt;

long long find(long long x) { return (fa[x] == x) ? x : fa[x] = find(fa[x]); }

long long insert(long long x, long long l, long long r) {
	long long p = (++ cnt);
	t[p].l = l, t[p].r = r, t[p].w = 1;
	if(l == r) return p;
	long long mid = (l + r) >> 1;
	if(x <= mid) t[p].ls = insert(x, l, mid);
	else t[p].rs = insert(x, mid + 1, r);
	return p;
}

long long merge(long long x, long long y, long long l, long long r) {
	if(!x || !y) return x | y;
	long long p = (++ cnt), mid = (l + r) >> 1;
	t[p].l = l, t[p].r = r, t[p].w = t[x].w + t[y].w;
	t[p].ls = merge(t[x].ls, t[y].ls, l, mid);
	t[p].rs = merge(t[x].rs, t[y].rs, mid + 1, r);
	return p;
}

long long query(long long x, long long k) {
	if(t[x].w < k) return -1;
	if(t[x].l == t[x].r) return ans[t[x].l];
	if(t[t[x].ls].w >= k) return query(t[x].ls, k);
	return query(t[x].rs, k - t[t[x].ls].w);
}

int main() {
	n = read(), m = read();
	for(int i = 1; i <= n; i ++) {
		long long x = read(); 
		T[i] = insert(x, 1, n), ans[x] = fa[i] = i;
	} 
	for(int i = 1; i <= m; i ++) {
		long long x = find(read()), y = find(read());
		if(x != y) fa[x] = y, T[y] = merge(T[x], T[y], 1, n);
	}
	q = read();
	while(q --) {
		char c; cin >> c; 
		long long x = read(), y = read();
		if(c == 'B') {
			x = find(x), y = find(y);
			if(x != y) fa[x] = y, T[y] = merge(T[x], T[y], 1, n);
		}
		else cout << query(T[find(x)], y) << "\n";
	}
	return 0;
}

\(\text{codeforces-208e}\)

有一个家族关系森林,描述了 \(n\) 人的家庭关系,成员编号为 \(1\)\(n\)

如果 \(a\)\(b\) 的父亲,那么称 \(a\)\(b\)\(1\) 级祖先;如果 \(b\) 有一个 \(1\) 级祖先,\(a\)\(b\)\(1\) 级祖先的 \(k-1\) 级祖先,那么称 \(a\)\(b\)\(k\) 级祖先。

家庭关系保证是一棵森林,树中的每个人都至多有一个父母,且自己不会是自己的祖先。

如果存在一个人 \(z\) ,是两个人 \(a\)\(b\) 共同的 \(p\) 级祖先:那么称 \(a\)\(b\)\(p\) 级表亲。

\(m\) 次询问,每次询问给出一对整数 \(v,p\),求编号为 \(v\) 的人有多少个 \(p\) 级表亲。

\(1 \le n,m \le 10^5\)


首先一个很显然的转换:

  • \(x\)\(k\) 级表亲个数,等同于 \(x\)\(k\) 级祖先的 \(k\) 级子代的个数减一。

于是我们考虑怎么计算一个点的 \(k\) 级子代个数。

把询问离线,用 \(\text{dsu on tree}\) 处理。

仔细思考一下 \(\text{dsu}\) 处理了个什么东西,其实就是 \(x\) 的子树信息,那么知道了子树信息就可以更新答案了。于是继续思考怎么转移这个信息。

考虑保留每个点的重儿子的子树信息,其他的轻儿子则暴力统计。

这其实就是 \(\text{dsu on tree}\) 的核心思想,于是我们就搞完了。

#include<iostream>
#include<cstdio>
#include<vector>
using namespace std;
#define MAXN 100005
#define pii pair<long long, long long>
#define fi first
#define se second

long long read() {
	long long x = 0, f = 1;
	char c = getchar();
	while(c > 57 || c < 48) { if(c == 45) f = -1; c = getchar(); }
	while(c >= 48 && c <= 57) { x = (x << 1) + (x << 3) + (c - 48); c = getchar(); }
	return x * f;
}

long long n, q, fa[MAXN][30], dep[MAXN], sz[MAXN];
long long ans[MAXN], son[MAXN], t[MAXN];
vector<long long> v[MAXN];
vector<pii > Q[MAXN];
bool vis[MAXN];

void dfs(long long x, long long f) {
	fa[x][0] = f, dep[x] = dep[f] + 1, sz[x] = 1;
	for(int i = 1; i <= 20; i ++) fa[x][i] = fa[fa[x][i - 1]][i - 1];
	for(auto y : v[x]) {
		dfs(y, x), sz[x] += sz[y];
		if(sz[y] > sz[son[x]]) son[x] = y;
	}
	return;
}

long long getk(long long x, long long k) {
	for(int i = 0; i <= 20; i ++) 
		if((k >> i) & 1) x = fa[x][i];
	return x;
}

void f(long long x, long long tg) {
	t[dep[x]] += tg;
	for(auto y : v[x]) if(!vis[y]) f(y, tg);
	return;
}

void dfs1(long long x, bool fg) {
	for(auto y : v[x]) if(y != son[x]) dfs1(y, 0);
	if(son[x]) dfs1(son[x], 1), vis[son[x]] = 1;
	f(x, 1), vis[son[x]] = 0;
	for(auto it : Q[x]) ans[it.se] = t[it.fi + dep[x]] - 1;
	if(!fg) f(x, -1);
	return;
}

int main() {
	n = read() + 1; // 编号都加一,让 1 作为虚点。
	for(int i = 2; i <= n; i ++) v[read() + 1].push_back(i);
	dfs(1, 0), q = read();
	for(int i = 1; i <= q; i ++) {
		long long x = read() + 1, k = read();
		long long y = getk(x, k);
		if(y > 1) Q[y].push_back({k, i});
	}
	dfs1(1, 0);
	for(int i = 1; i <= q; i ++) cout << ans[i] << " ";
	cout << "\n";
	return 0;
}
posted @ 2026-01-15 21:51  So_noSlack  阅读(17)  评论(0)    收藏  举报