树上问题(虚树,点分治,基环树,树上重编号)

虚树

引入

先来看一道例题【模板】虚树 / [SDOI2011] 消耗战

  • 题意:
    给定一棵 \(n\) 个节点的树,每条边有一个切断代价 \(w_i\)\(m\) 次询问,第 \(i\) 次询问给定 \(k_i\) 个点,你需要切断若干条边使得 \(1\) 号点不能到达这 \(k_i\) 个点,询问最小代价。
  • 数据规模:\(2 \leq n \leq 2.5 \times 10^5,1 \leq m \leq 5 \times 10^5,\sum k_i \leq 5 \times 10^5, 1 \leq k_i \leq n - 1\)

朴素做法:称给定的点为 关键点。设状态 \(dp_i\) 表示 \(i\) 号点不能到达子树中的关键点的最小代价,转移很显然。复杂度 \(O(mn)\)

考虑优化,我们发现一次 \(dp\) 过程中有很多点是没有用的,举个例子:
对于下面一棵树:
在这里插入图片描述
如果我们选取的关键点是 \(\{2, 4\}\)
在这里插入图片描述
那么显然 \(1\) 的右子树的转移都是没用的。注意到 \(\sum k_i \leq 5 \times 10^5\),我们能不能每次询问只保留与关键点数量同阶的点的信息,将一棵大树浓缩成一棵小树,从而优化复杂度呢?

概念(虚树 Virtual Tree)

我们先来直观看一看虚树的形态。下图中,红色结点是我们选择的关键点.红色和黑色结点都是虚树中的点.黑色的边是虚树中的边.

1c59bcd59c374dd1b3f80108df68e27e

2

3

4

5

由于任意两个关键点的 \(lca\) 都保留有重要信息,因此我们将这些点归入虚树中。

  • 虚树中的点:关键点,任意两个关键点的 \(lca\)\(1\) 号点(根)
  • 虚树中的边:两个虚树中的点在原树上简单路径上边的信息压缩。

有重要结论:若关键点个数为 \(k\),那么虚树的大小不超过 \(2k - 1\)。(从构造方法中看出)
显然不能 \(O(k^2)\) 枚举任意两点的 \(lca\),实际上有两种常见的构造方法。

构造方法

二次排序 + LCA连边

非常直观。

将关键点按照 \(dfs\) 序从小到大排序,然后将排序后相邻两个点的 \(lca\) 加入点集,去重后就能得到虚树中的所有点。

可以用上面的图模拟一下。

如何连边:从前往后扫描按照 \(dfs\) 序排列的点集,对于相邻的两个点 \(x,y\),求出它们的 \(lca\),然后连一条 \((y, lca)\) 的边,\(y\) 在虚树上的 父亲就是 \(lca\)

为什么不重不漏:

  • \(dfs\) 序最小的一定是根,所以它没有父亲。
  • \(lca = x\),那么由 \(dfs\) 的性质可知 \(x,y\) 的简单路径上没有虚树的其它点,因此 \(x\) 就是 \(y\) 的父亲。
  • \(lca\) 不是 \(x\),那么由 \(dfs\) 序的性质可以知道 \(y\)\(lca\) 的路径上没有别的虚树中的点,(如果有一定排在 \(x\)\(y\) 的中间,那么 \(x\)\(y\) 就不相邻了) 因此 \(lca\) 就是 \(y\) 的父亲。

我们给每个点都找到了它的父亲,所加边数为点数 \(-1\),因此不重不漏。

\(lca\) 可以 \(O(1)\)。 因为有排序,所以单次构造复杂度为 \(O(k \log k)\)
注意:不涉及求加边信息的复杂度。这一部分一般用 倍增树剖 处理,复杂度另外计算,一般求一条边 \(O(\log n)\)

struct edge {
   int head[N], tot;
   struct EDGE {
   	    int v, last; LL w;
   }E[N * 2];
   void add(int u, int v, LL w) {
   	    E[++ tot].v = v; 
   	    E[tot].last = head[u];
   	    head[u] = tot;
   	    E[tot].w = w;
   }
} R, V;

void build_virtual_tree() { // 采用二次排序 + lca 的方式建虚树 
    V.tot = 0;
	len = 0; node[++ len] = 1; // 先把1放进去 
	sort(pos + 1, pos + cnt + 1, cmp);
	for(int i = 1; i <= cnt; i ++ ) node[++ len] = pos[i];
	int tmp = len;
	for(int i = 1; i < tmp; i ++ ) {
		int lc = Lca(node[i], node[i + 1]);
		node[++ len] = lc;
	}
	sort(node + 1, node + len + 1, cmp);
	len = unique(node + 1, node + len + 1) - (node + 1);
	for(int i = 1; i < len; i ++ ) {
		int lc = Lca(node[i], node[i + 1]);
	    LL w = Find_edge(node[i + 1], lc);
		V.add(node[i + 1], lc, w);
		V.add(lc, node[i + 1], w);
	}
}

单调栈

思想:用单调栈维护虚树上的一条链。
从栈顶到栈底 \(dfs\) 序单调递减,认为 目前 栈中的一个点在虚树中的父亲就是栈中它下面的那个点。
注意这里是 目前, 也就是后续可能会调整。实际上处理过程中只有一个点出栈时我们才会确定它的父亲并连一条边。
具体流程:

  1. 首先我们将根节点(\(1\) 号点) 加入栈中,然后将 关键点 按照 \(dfs\) 序从小到大依次考虑。
  2. 设当前节点为 \(x\),栈顶节点为 \(y\),求出 \(x, y\)\(lca\)。若 \(lca = y\),那么 \(x\) 就在目前维护的链上,将 \(x\) 加入栈中。
  3. \(lca\) 不为 \(y\),那么进入判断阶段:
  4. 设栈顶的 下一个 节点为 \(z\)。若 \(dfn[lca] < dfn[z]\),那么可以确定 \(y\)\(z\) 之前没有虚树中的点了,并且 \(y\) 不在当前的链中,那么把栈顶 \(y\) 弹出,并加入虚树一条边 \((y, z)\)。然后重新进入判断过程。
  5. \(dfn[lca] = dfn[z]\),那么也能确定 \(y\) 的父亲是 \(z\)。加入一条边 \((y, z)\) ,然后将栈顶弹出,并将点 \(x\) 压入栈中,结束判断。
  6. \(dfn[lca] > dfn[z]\),也就是虚树中还需要加入 \(x, y\)\(lca\),并且由 \(dfs\) 序的性质可以知道 \(y\) 的父亲就是 \(lca\)。那么我们加入一条边 \((y, lca)\),并将栈顶弹出。然后依次将 \(lca\)\(x\) 加入栈中。 结束判断过程。

因为要排序,复杂度还是 \(O(k\log k)\),可能会少一些常数。

int stk[N]; 
void build_virtual_tree() { // 采用单调栈的方式建虚树 
    V.tot = 0; int Top = 0; stk[++ Top] = 1;
	sort(pos + 1, pos + cnt + 1, cmp);
	for(int i = 1; i <= cnt; i ++ ) {
		int lc = Lca(pos[i], stk[Top]);
		if(lc == stk[Top]) stk[++ Top] = pos[i];
		else {
			while(dfn[lc] < dfn[stk[Top - 1]]) {
				LL w = Find_edge(stk[Top], stk[Top - 1]);
				V.add(stk[Top], stk[Top - 1], w);
				V.add(stk[Top - 1], stk[Top], w);
				Top --;
			}
			if(dfn[lc] == dfn[stk[Top - 1]]) {
				LL w = Find_edge(stk[Top], stk[Top - 1]);
				V.add(stk[Top], stk[Top - 1], w);
				V.add(stk[Top - 1], stk[Top], w);
				Top --;
			}
			else {
				LL w = Find_edge(stk[Top], lc);
				V.add(stk[Top], lc, w);
				V.add(lc, stk[Top], w);
				stk[Top] = lc;
			}
			stk[++ Top] = pos[i];
		}
	}
	for(int i = 1; i < Top; i ++ ) {
		LL w = Find_edge(stk[i], stk[i + 1]);
		V.add(stk[i], stk[i + 1], w);
		V.add(stk[i + 1], stk[i], w);
	}
}

例题

【模板】虚树 / [SDOI2011] 消耗战

分析:
考虑朴素 \(dp\) 的优化,每次建出关键点的虚树后跑 \(dp\),虚树上一条边的边权为所连接两点 \(x, y\)原树对应路径中的最小一条边代价,用树上倍增 可在 \(O(\log_2 n)\) 求出一条边权,总时间复杂度 \(O(\sum k_i \log_2 n)\)

「HEOI2014」大工程

题面
题意:

  • 给定一棵 \(n\) 个节点的树,边权都为 \(1\)\(q\) 次询问,每次给定 \(k\) 个点 \(a_1 \dots a_k\),你需要求出 \(\sum\limits_{i = 1}^{k}\sum\limits_{j = i + 1}^{k} dis(a_i, a_j),\min\limits_{i \ne j} dis(a_i, a_j),\max\limits_{i \ne j} dis(a_i, a_j)\)\(dis(x,y)\) 表示树上 \(x, y\) 两点之间最短路径的长度。
  • 数据规模: \(1 \leq n \leq 10^6,1 \leq q \leq 5 \times 10^4, \sum k_i \leq 2n\)

分析:
先来考虑一次询问怎么做:
对于求和,可以拆成边贡献,也就是一条边会被经过边两侧关键点个数乘积次。
对于 \(\min,\max\),是一个简单的 \(dp\),在路径最上方的点将两条半路径拼起来即可。
显然一遍 \(dfs\) 即可,复杂度 \(O(n)\)
对于多次询问,建出关键点的虚树,虚树边权就是路径上边数,然后 \(dfs\) 一遍即可。
复杂度 \(O(\sum k_i)\)

「HNOI2014」世界树

题面
题意:

  • 给定一棵 \(n\) 个点, 边权为 \(1\) 的树。 \(q\) 次询问,每次给定 \(k\) 个关键点,定义一个关键点 \(a\) 管辖 树上一个点 \(b\) 当且仅当 所有关键点\(b\) 距离 \(a\) 最近(若有多个相同的最近距离则 \(a\) 的编号最小)。每次询问你需要回答每个关键点 管辖 的节点数量。
  • 数据规模:\(1 \leq n \leq 3 \times 10^5, q \leq 3 \times 10^5, \sum k_i \leq 3 \times 10^5\)

分析:
先来考虑单次询问怎么做。
对于一个关键点而言,如果它周围的关键点分布如下图:

6
拿出具体的两个关键点对来看:

7

观察到路径存在一个分界点,分界点点靠近蓝色点的一侧的路径上的点及它的子树都属于蓝点管辖,右侧则属于红点管辖。

这启示我们实际上很多点的归属实际上有一个领队,也就是领队去哪里它们就去哪里,我们可以把这样的点浓缩到领队一个点上,从而简化复杂度。很自然的想到虚树

不妨以 \(1\) 为根节点。建出关键点的虚树,\(dijkstra\) 跑一遍 多源最短路 求出虚树上每个点距离最近的关键点编号以及距离。注意这里虚树上每个点 \(x\) 都代表了若干个点,具体的一个点 \(x\) 代表了它子树中的所有点去掉虚树中的所有儿子对应其原树中的儿子子树。如下图:
8

黑点代表虚树中的点,红点是原树中的点。那么 \(x\) 就代表了 \(a,b\) 子树中的点和自己(去掉 \(c,d,e\) 子树)。

多源最短路染色后我们可以确定虚树上每个点所代表的点的管辖点,但是还没完,因为虚树的一条边代表了一条路径及其剩余子树,我们还需要考虑它们的管辖点。

显然也是简单的,由于我们知道路径两个端点的管辖点和对应距离,那么只需要简单计算即可得到分界点,然后预处理根到某个节点一条路径上的点的子树大小和即可通过容斥 \(O(1)\) 算出答案。

总时间复杂度 \(O(\sum k_i \times \log_2 n)\)

「NOI2021」庆典

题面
题意:

  • 给定一张 \(n\) 个点 \(m\) 条边的有向图 \(G\),若点 \(x\) 可达 \(y\),记作 \(x \Rightarrow y\)。该图满足特殊性质: 对于任意一点 \(z\),若 \(x \Rightarrow z\)\(y \Rightarrow z\),则有 \(x \Rightarrow y\)\(y \Rightarrow z\)。 给定参数 \(k\)\(q\) 次询问,每次给定起点 \(s\) 和 终点 \(t\),同时会暂时加入 \(k\) 条有向边(新加入的边不一定满足特殊性质),你需要求出此时 \(s\) 到达 \(t\) 的路径中可能经过几个点。
  • 数据规模: \(1 \leq n,q \leq 3 \times 10^5,n - 1 \leq m \leq 6 \times 10^5,0 \leq k \leq 2\)。满足将有向边换成无向边后 \(G\) 是一张联通图。

分析:
首先肯定要缩点,得到一张 \(DAG\)。由于我们只需要考虑可达性,手玩性质可以发现在不改变可达性的基础上可以通过删掉若干条边将 \(DAG\) 转化成一棵叶向树。可以通过 拓扑排序 保留必要的边。

此时如果 \(k\) 等于 \(0\),那么判断 \(s\) 能否到达 \(t\) 以及可能经过几个点都是简单的,只需要判断 \(s\) 是否是 \(t\) 的祖先以及求出 \(s \rightarrow t\) 路径上的点数即可。

注意到 \(k \leq 2\),显然出题人是想让我们分情况讨论,但是可以用 虚树 避免讨论。

具体的,将 \(s, t\),所有加入边的两个端点看作关键点,建出关键点的虚树,虚树的边是祖先指向子孙的有向边,边权为路径去掉两端点的点数,然后将加入边看成边权为 \(0\) 的有向边加入虚树中。那么问题转化成了:给定一张有向图,求从起点到终点所有可能经过的边的边权和。由于图的规模很小,我们在原图和反图分别以 \(s\)\(t\) 为起点跑一遍可达性统计,如果一条边 \((u, v)\) 中起点可以到 \(u\) 且终点可以到 \(v\),那么答案就加上 \((u, v)\) 的边权。

这种思路可以拓展到 \(\sum k \leq 6 \times 10^5\)。复杂度 \(O(m + \sum k \times \log_2 k)\)

「SDOI2018」战略游戏

题面
题意:

  • \(T\) 组数据。每次给定一张无向连通图 \(G\)\(q\) 次 询问,每次给定一个点集 \(S_i\),你需要求出 \(G\) 中有多少个不在 \(S_i\) 中的点满足删掉这个点和它所有连边后 \(S_i\) 中存在两点 \(u, v\) 不连通。
  • \(1 \leq T \leq 10,\sum |S_i| \leq 2 \times 10^5\)

分析:
因为只能删掉一个点,那么显然只有 割点 可能成为答案。
显然点双连通分量要特殊处理,考虑圆方树
建出原图的圆方树,观察到答案就是 \(S_i\) 中所有点的最小联通子树中不属于 \(S_i\) 的圆点个数。可以在构造 \(S_i\) 虚树的过程中统计答案。
复杂度是构造虚树的单 \(log\)

「ZJOI2019」语言

题面
题意:

  • 给定一棵 \(n\) 个节点的树和树上 \(m\) 条路径,问有多少无序点对 \((u, v)\) 满足 \(u \rightarrow v\) 的路径上所有点都至少被一条路径覆盖过。
  • 数据规模: \(1 \leq n \leq 10^5,1 \leq m \leq 10^5\)

分析:
先来考虑一个暴力:对于每个点 \(i\) 求出它能到达那些点,然后把点数加起来 \(/2\)
这个可以求出所有经过点 \(i\) 的路径的并,显然是一棵联通子树,子树中所有点都是 \(i\) 能到达的点。
具体来说,我们可以拿出所有只有一个端点在 \(i\) 的子树的路径,以所有这样路径的端点作为关键点建立虚树,构建过程中可求出联通子树的大小。复杂度 \(O(n^2 \log_2n)\)
考虑优化:实际上我们求的是树上包含若干点的最小连通块大小。这是一个经典问题,并且可以支持动态加点删点。
做法是按照 \(dfs\) 序将所有点从小到达排序,那么最小联通树中所有边权和的 \(2\) 倍就等于排序后相邻两个点之间的距离和(认为第一个点和最后一个点也是相邻的)。证明画图理解。
因此优化就很显然了,我们在路径的 \(lca\) 处删去路径两个端点,在节点 \(x\)启发式合并所有儿子子树中的路径端点,增量修改答案。总复杂度 \(O(m \log^2 m)\)

作业

Bear and Chemistry

题面

「AHOI / HNOI2018」毒瘤

题面
也可以广义串并联图。

[JRKSJ R6] Dedicatus545

题面

【NOI】桂花树

点分治

概念

点分治 是用来解决 树上路径点对贡献 类问题的重要算法,基本时间复杂度为 \(O(n \log_2 n)\)
它的核心思想是:对于当前树,将路径分为 经过重心不经过重心 的路径。当前将 经过重心 的路径处理后,把重心及连边删掉分裂成若干小树,然后递归到小树中处理剩余路径。
复杂度:以重心为根,那么它所有儿子的子树大小一定小于整棵树的\(\frac{1}{2}\)(如果大于那么显然重心会向这个儿子子树内移动)。假设我们处理一棵树的复杂度只与树的大小有关,假设认为是 \(O(size)\)。因为递归不会超过 \(log_2 n\) 层,每一层的点数和为 \(O(n)\),因此总复杂度为 \(O(n \log_2 n)\)

过程拆解和模板代码

点分治的题目一般比较套路,可以形成一套自己的模板,这样写起来比较快也不容易出错。

1.求重心

这是一个很基础的问题:所连接的最大连通块最小 的点就是重心。
直接上代码:

void get_root(int x, int fa){
	cnt[x] = 1, Maxn[x] = 0;//cnt[x] 表示以x为根的子树的大小, Maxn[x]表示与x相连的最大连通块大小
	for(int i = head[x]; i; i = E[i].last){
		int v = E[i].v;
		if(vis[v] || v == fa) continue;
		get_root(v, x);
		cnt[x] += cnt[v];
		Maxn[x] = max(Maxn[x], cnt[v]);
	}
	Maxn[x] = max(Maxn[x], all - cnt[x]);
	if(Maxn[x] < Maxn[root]) root = x;
}

2.求以当前重心为根各个子树的大小

这一步是为了求出后面递归时 \(all\) 的值。

void get_size(int x, int fa){
	cnt[x] = 1;
	for(int i = head[x]; i; i = E[i].last){
		int v = E[i].v;
		if(vis[v] || v == fa) continue;
		get_size(v, x);
		cnt[x] += cnt[v];
	}
}

3.求解经过重心的路径

针对不同的题目会有不同的处理方法, 但有一个共同的框架:一条经过根的路径是两条在根的不同子树的点到根的路径拼接得到。
步骤:

  1. 依次递归每一个子树,并利用前面子树的点到根的路径信息当前子树中的点到根的路径信息更新答案。
  2. 加入当前子树中的点到根的所有路径信息。

代码模板:

void solve(int rt){//处理根为rt,经过rt的合法最优解
    vis[rt] = 1; 
	for(int i = head[rt]; i; i = E[i].last){
		int v = E[i].v;
		if(vis[v]) continue;
		....//给v节点附上一个初始信息
		calc(v, 0);//递归解决子树v
		change(v, n + 1, 1);//加上子树v中的信息 
	}
	for(int i = head[rt]; i; i = E[i].last){
		int v = E[i].v;
		if(vis[v]) continue;
		change(v, n + 1, 0);//回溯清空
	}
	for(int i = head[rt]; i; i = E[i].last){
		int v = E[i].v;
		if(vis[v]) continue;
		root = 0;
		all = cnt[v];
		get_root(v, n + 1);//找v所在联通块的中心
		get_size(root, n + 1);
		solve(root);//分治解决
	} 
}

注意:我们一般需要 数据结构 来记录或维护 半路径 信息(比如树状数组或桶),这是复杂度增加的来源,且不要忘记清空。

例题

[国家集训队] 聪聪可可

题面
题意:

  • 给定一棵有边权的无向树,求树上有多少路径满足路径上的边权和 \(\mod 3 = 0\)
  • 数据规模: \(1 \leq n \leq 2 \times 10^4\)

分析:
比较古老的题,可将数据规模改成 \(10^6\)
经典的树上路径计数问题,考虑点分治。
套用模板后,我们需要思考的是怎么通过拼接两条半路径对跨过重心的合法路径计数。
发现我们只关注半路经权值\(\mod 3\) 的值。只需要开一个大小为 \(3\) 的桶分别记录当前加入的半路经权值\(\mod 3 = 0/1/2\) 的数量,然后在递归新子树时答案加上对应的数量即可。复杂度一只 \(\log\)

Tree

题面
题意:

  • 给定一棵 \(n\) 个节点的树,每条边有边权,求树上两点距离小于等于 \(k\) 的点对数量。
  • 数据规模:\(1 \leq n \leq 4 \times 10^4,0 \leq w \leq 10^3,0 \leq k \leq 2 \times 10^4\)

分析:
依然套路题。直接考虑点分治,用树状数组维护与根距离不同的半路径数量,然后拼接时求一个前缀和即可计算答案。复杂度 \(O(n \log^2_2n)\)

[USACO13OPEN] Yin and Yang G

题面
题意:

  • 给定一棵 \(n\) 个点的树,树的边权为白色或黑色。求出有多少条路径 \(u \rightarrow v\) 满足 路径上至少存在一个点 \(x\),使得 \(u \rightarrow x\)\(x \rightarrow v\) 两条路径上都有白边数量等于黑边数量
  • \(1 \leq n \leq 10^5\)

分析:
依然是树上路径计数问题,考虑点分治。
如果没有分界点的限制该怎么做?只需要把半路经中白边与黑边数量的差存储到数据结构中,然后拼接计数时到对应查找即可。数据结构使用 \(hash\) 表 或 都可以。
考虑加上了中间点的限制。我们把已经加入的半路径分成两类:已经可以找到至少一个分界点的路径,找不到任何一个分节点路径。
我们将这两类路径分别存到两个 \(hash\) 表中,那么在拼接计数时,如果当前路径可以找到一个分界点,那么就可以从第一类和第二类路径中查找,否则只能从第一类中查找。
问题在于怎么判断当前一条根到点 \(x\) 的半路径是否存在一个分界点。
把白边看成 \(-1\),黑边看成 \(1\),那么分界点到当前点 \(x\) 的路径和为 \(0\),因此从根到分界点 与 从根到当前点 \(x\) 的前缀和相等。我们可以在递归过程中将每个位置的前缀和插入 \(hash\) 表中,在当前点查询 \(hash\) 表中是否有相同的前缀和即可判断是否存在分界点。
\(hash\) 表插入和查询的复杂度都是线性的,因此总复杂度 \(O(n \log_2 n)\)

路径规划

题面
题意:

  • 给定一棵 \(n\) 个点的无根树,树上的边有一个权值,要求找出一条路径使得该路径权的最小值乘边权和最大。
  • 数据规模: \(1 \leq n \leq 3 \times 10^5\)

分析:
拼接过程中,最小值可能在加入的半路径中,也可能在当前的半路径中。第二种情况对计算答案是简单的,只需要求出加入半路径权值和的最大值即可。但第一种情况较为复杂,硬做的或可能需要李超线段树或者维护凸包
其实可以考虑固定最小值,我们规定最小值一定在当前的半路径中,然后正着倒着做两边拼接求解答案的过程即可。需要树状数组维护路径信息,复杂度 \(O(n\log^2_2n)\)

[USACO18JAN] Cow at Large P

题面
题意:

  • 给你一棵 \(n\) 个点的树,度数为 \(1\) 的点代表出口。一个人从起点出发逃生,每个单位时间可以移动到相邻的一个点上。你需要在一些出口放置守卫来阻止他逃生,守卫每个单位时间也可以移动到相邻的一个点上,如果逃生者和守卫在点上或边上相遇就算防守成功。逃生者和守卫都会走最优路线,问对于每个点作为逃生起点时最少需要放置几个守卫才能防守成功。
  • 数据规模 \(1 \leq n \leq 7 \times 10^4\)

输入样例:
7
1 2
1 3
3 4
3 5
4 6
5 7

输出样例
3
1
3
3
3
1
1

分析:
先来考虑暴力:
枚举起点 \(s\) 作为根,有以下性质:
对于一个点 \(x\),设 \(g_x\) 表示 \(x\) 与距离最近的叶子之间的距离。\(cnt_x\) 表示看守 \(x\) 内所有叶子最少需要几个守卫。
\(g_x \leq dep_x\),那么 \(cnt_x = 1\),表示子树内的所有叶子可以仅由一个守卫看守。否则 \(cnt_x = \sum\limits_{v \in son_x} cnt_v\)
基于这个性质,我们能理解答案就是 极大的只需要一个守卫的子树数量。并且这样的极大子树一定满足子树内所有点 \(p\) 都有 \(g_p \leq dep_p\)。证明如下:

9

也就是所有 \(g_x \leq dis_{x,s}\) 在树上会形成若干极大联通子树,我们想通过一种映射方法使一个这样的联通子树对答案有 \(1\) 的贡献。

性质:设 \(m\) 为一棵联通子树的点数,那么有 \(\sum\limits_{x \in tree}deg_x = 2\times m - 1\)

这很好理解,一条边对度数有 \(2\) 的贡献,子树的根还有一条父亲边。
移项,得到 \(\sum\limits_{x \in tree}2 - deg_x = 1\)。那么我们只要给一个点赋权为 \(2 - deg_x\),子树和就是 \(1\) 的贡献。

\(g_x\) 是很容易处理的,只需要跑一遍多源 \(bfs\)。剩下的就是一个 点对贡献问题,满足 \(g_u \leq dis_{u, v}\)\(u\)\(v\) 有一个 \(2 - deg_u\) 的贡献。这可以点分治解决,每层处理路径跨过重心的点对即可,但是需要正着贡献一遍,再倒着贡献一遍。

需要用树状数组求前缀和,复杂度 \(O(n \log_2^2n)\)

[WC2010] 重建计划

题意:

  • 给定一棵 \(n\) 个点的树,边有边权。给定参数 \(L,R\),你需要求出长度 \(k\)\([L, R]\) 之间一条树上路径,使得路径的平均边权最大。答案保留 \(3\) 位小数。
  • 数据规模:\(1 \leq n \leq 10^5\)

分析:
树上路径类问题,考虑点分治。
首先要求平均边权,所以考虑二分答案。问题变成 \(check\) 是否存在一条长度在 \([L, R]\) 范围内,权值和大于等于 \(0\) 的路径。
按照套路的点分治思路,加入的半路径可能需要一个线段树维护区间最大值。这样算上二分的复杂度是 \(O(n \log_2^3 n)\),应该无法通过。

考虑优化:
已经加入的相同长度的半路径显然只需要保留权值最大的即可,对于当前查询子树内的半路径显然也是如此,并且最大长度不超过子树大小。那么可以按照长度从小到大考虑查询路径,这样已加入的半路径长度区间就是一个滑动窗口,用单调队列优化 即可。
其实还可以长链剖分,因为长剖复杂度线性,因此可以直接上树状数组。
最后复杂度 \(O(n \log^2_2n)\)

作业

[USACO12NOV] Balanced Trees G

题面
板子题

P6326 Shopping

题面
不可多得的好题

P9058 [Ynoi2004] rpmtdq

题面
练习数据结构的好题

[COTS 2023] 三元图 Graf

题面

基环树

不是很新奇的概念,主要讲一下基环树类问题的思考方式和模板。

概念

基环树是指 有且仅有 一个环的树。基环森林是指若干棵基环树构成的森林。
对于有向图,基环树可分为 内向基环树外向基环树

  • 内向基环树:所有点 有且只有 一条出边
  • 外向基环树:所有点 有且只有 一条入边

基环树和基环森林都有 点数等于边数 的特点。
基环森林由若干基环树组成,因此下面只讲解如何解决基环树上的问题。

核心思路

  1. 将环找出来看作树根
  2. 不在环上的点只属于一个环上点的子树。对每个环上点的子树分别 \(dp\)\(dfs\) 遍历统计信息
  3. 破环成链,每个环上点代表它子树的信息。在链上 \(dp\)。有时可使用 单调队列 优化。这一步可看作将割裂的部分合并。

模板

void get_loop(int x, int fa) { // 找基环树上的环 
	dfn[x] = ++ rk;
	for(int i = head[x]; i; i = E[i].last) {
		int v = E[i].v; LL w = E[i].w;
		if(v == fa) continue;
		if(dfn[v]) {
			if(dfn[v] < dfn[x]) continue;
			else {
				loop[++ cnt] = v; // loop 存环上点的编号 
				preE[v] = i; // preE 存环上点之间的连边 
				for(; v != x; v = E[faE[v] ^ 1].v) {
					loop[++ cnt] = E[faE[v] ^ 1].v;
					preE[loop[cnt]] = faE[v];
				}
			}
		}
		else {
			faE[v] = i;
			get_loop(v, x);
		}
	}
}

void DP(int x, int fa) { // 以基环树的环上的点为根,不经过环上其它的点,统计它的子树内的某些最优值 
    do sth...
}

for(int i = 1; i <= n; i ++ ) {
	cnt = 0;
	if(!dfn[i]) {
		get_loop(i, 0); // 找i所在基环树的环 
		for(int j = 1; j <= cnt; j ++ ) vis[loop[j]] = 1;
		for(int j = 1; j <= cnt; j ++ ) DP(loop[j], 0); // 可以是dp,也可以是其他操作  
		for(int j = 1; j <= cnt * 2; j ++ ) { // 破环成链,算环上各点之间的边权 
			if(j > cnt) loop[j] = loop[j - cnt];
			if(j == 1) {S[j] = 0; continue;}
			else S[j] = S[j - 1] + W[preE[loop[j]]];
		}
		LL maxn = 0;
		int l = 1, r = 0;
		for(int j = 1; j <= cnt * 2; j ++ ) { // 单调队列求解答案 
			while(l <= r && j - q[l] + 1 > cnt) l ++;
			if(l <= r) {
			    do sth...
			}
			while(l <= r && ...) r --;
			q[++ r] = j;
		}
		res += ...; // 统计答案 
	}
}

不太理解模板?我们来看几道例题感受一下。

例题

[NOIP2018 提高组] 旅行

题面
题意:

  • 给你一张 \(n\) 个点, \(m\) 条边的图。可任选一个点作为起点,每次可去一个 相邻的 没有去过的点 或 上一个到当前点的点。当到一个之前没到过的点时将该点的编号记录下来,最后形成一个长度为 \(n\) 的编号序列。求字典序最小的编号序列。
  • 数据规模:\(1 \leq n \leq 5000\)\(m = n - 1\)\(n\)

分析:
先来考虑 怎么做?
发现如果到达点 \(x\) 后,不把 \(x\) 子树内的点全部遍历一遍是不能从 \(x\) 子树出去的,否则就无法把所有点都遍历到。
那么就很简单了,我们肯定选取 \(1\) 为起点,然后每次往当前点还剩下的编号最小的儿子子树中递归,递归到叶子就回溯。相当于\(1\) 为根后把每个点的儿子按照编号从小到大排序后的 \(dfs\)
基环树怎么做?注意到环上至少有一条边不会被经过。枚举环上一条边断开就成了一颗树。复杂度 \(O(n^2)\),只用到了模板中找环的代码。

[ZJOI2008]骑士

题目

题意:

  • 给你一个 \(n\) 个点的基环森林,点有点权。求基环森林的 最大点独立集
  • \(1 \leq n \leq 10^6,1 \leq w_i \leq 10^6\)

分析:
的最大点独立集是很经典的问题,每棵基环树独立,因此只考虑一棵基环树。
将环找出来作为根,对于每棵以环上一个点为根的子树,它们之间是相对独立的,有用的只是环上的点选或不选。这启示我们将这样的子树信息压缩到环上的点上,然后单独跑一遍环上 \(dp\)
具体来说,对于一个环上点 \(x\),我们求出 \(f_x,g_x\) 分别表示不选/选 \(x\)\(x\) 子树的最大点独立集。然后拆环成链简单跑一遍环上 \(dp\) 即可。复杂度 \(O(n)\)

[IOI2008] Island

题目

题意:

  • 给你一个 \(n\) 个点的基环森林,边有边权。求每棵基环树的直径之和。
  • \(1 \leq n \leq 10^6,1 \leq w_i \leq 10^8\)

分析:
对于,求直径的方法的方法有很多,我们考虑 \(dp\) 将两条半链拼起来的做法。
将路径分为两类:经过环边的路径和完全在子树内路径。
对于第二种,一次树形 \(dp\) 即可。
对于第一种,对每个环上点 \(x\) 求出 \(f_x\) 表示 \(x\) 到子树中某个点的 最大距离,然后拆环成链复制一遍,求出前缀和之后用单调队列维护区间内最小值即可。复杂度 \(O(n)\)

CF1967D Long Way to be Non-decreasing

题目

题意:

给你一个长为 \(n\) 的数列 \(a_1,....,a_n\) 和一个长为 \(m\) 的数列 \(b_1,...,b_m\),保证 \(1 \leq a_i,b_i \leq m\)。定义一次操作如下:

  • 选定一个下标集合 \(S\)
  • 对于 \(\forall i \in S\)\(a_i \gets b_{a_i}\)

问至少多少次操作能使序列 \(a\) 单调不降,输出最小的操作次数。若无法使 \(a\) 单调不降,输出 \(-1\)

  • \(1 \leq n \leq 10^6,1 \leq m \leq 10^6\)\(1 \leq a_i, b_i \leq m\)

分析:
大小为 \(x\) 的数一次被操作后变成了 \(b_x\),我们连 \(i \to b_i\) 的有向边(\(i \in \{1,\dots,m\}\))。不难发现这满足每个点出度为 \(1\),因此会形成一张内向基环森林
问题转化成了最初基环森林上有 \(n\) 个点,我们每次可任意选取一些点移动一步,问最少多少次操作使 \(a\) 单调不降。
最优化问题直接解决是困难的,考虑二分答案转化为判定问题
对于二分值 \(mid\),条件就变成了每个点最多走 \(mid\) 步,问是否能构造出一组解。显然贪心的构造,让 \(a_1\) 取能达到的最小值,\(a_2\) 取大于等于 \(a_1\) 的最小值以此类推。问题变成了怎样快速求出某个点的取值。
维护一个单调的指针判断当前 \(a_i\) 能否取到指针指向的数字。显然利用并查集深度信息等可 \(O(1)\) \(check\),总复杂度 \(O(n \log_2 n)\)

[JOISC 2021] 最悪の記者 4

题面
题意:

  • \(n\) 个人考试,对每个人 \(i\),已知 \(i\) 号的成绩大于等于 \(a_i\) 号。现在给出一组成绩数据 \(\{h_1,\dots,h_n\}\),它可能与已知信息存在矛盾,你需要花费代价修改一些人的成绩。你可以用 \(c_i\) 的代价将 \(h_i\) 修改成 \([1,10^9]\) 中的任意整数,问最小花费。
  • \(2 \leq n \leq 2\times 10^5,1 \leq a_i \leq n,1 \leq h_i,c_i \leq 10^9\)

分析:
\(i \to a_i\) 的有向边,显然能得到一个内向基环森林。我们要做的就是用最小代价修改使得每个点的点权都大于等于它指向的点。
先来考虑树怎么做?
显然有暴力的树形 \(dp\):设 \(f_{x, i}\) 表示点 \(x\) 最终的点权为 \(i\) 时, \(x\) 子树的最小修改代价。那么有转移:

\[f_{x,i} = [h_x \ne i] c_x + \sum\limits_{y\in son_x}\min\limits_{j\geq i} f_{y, j} \]

因为我们的树形 \(dp\) 有两维,且第二维是值域,考虑 线段树合并优化 \(dp\)
\([h_x \ne i]c_x\) 放到合并最后考虑,这是一个区间加。
对于右半部分显然是按照先右子树,后左子树 的顺序进行合并,合并过程中记录两棵线段树的后缀最小值,需要支持查询区间最小值,区间加。维护是容易的。

对于基环树,显然环上的点需要取相同的值。不难发现取值要么是 \(0\),要么是原来的一个环上点权值。将每棵子树合并后枚举取最优即可。小细节:环上点的贡献先不要贡献到子树中,而是要最后计算。

复杂度 \(O(n\log_2 V)\)

作业

[NOIP2023] 三值逻辑

题面
好题

[POI 2012] RAN-Rendezvous

题面

[NOI2012] 迷失游乐园

题面
基环树上随机游走 + 换根 \(dp\)

[JOI 2025 Final] 邮局 / Post Office

题面
神仙题

树上重编号

我的博客

posted @ 2026-09-09 20:21  Czl2007  阅读(5)  评论(0)    收藏  举报