题解:洛谷 CF911F Tree Destruction
【题目来源】
洛谷:CF911F Tree Destruction - 洛谷
【题目描述】
给定一棵有 \(n\) 个顶点的无权树。然后对这棵树依次进行 \(n-1\) 次操作。每次操作包括以下步骤:
- 选择树上的两个叶子节点;
- 将这两个叶子间简单路径的长度加到当前答案中;
- 从树中移除选中的其中一个叶子节点。
初始答案为 \(0\)。显然,经过 \(n-1\) 次这样的操作后,树中只剩下一个顶点。
请计算你能获得的最大答案,并构造一种操作顺序,使你能获得这个最大答案!
【输入】
第一行包含一个整数 \(n\)(\(2\le n\le 2\cdot10^{5}\))——树的顶点数。
接下来的 \(n-1\) 行描述树的边,每行用形式 \(a_i,b_i\) 给出一条边(\(1\le a_i, b_i\le n\),\(a_i\ne b_i\))。保证给出的图是一棵树。
【输出】
第一行输出一个整数——你能获得的最大答案。
接下来 \(n-1\) 行,按操作顺序输出每次操作,格式为 \(a_i,b_i,c_i\),其中 \(a_i,b_i\) 表示本次操作所选择的两个叶子结点(\(1\le a_i, b_i\le n\)),\(c_i\)(\(1\le c_i\le n\),\(c_i=a_i\) 或 \(c_i=b_i\))表示本次操作中被从树中移除的叶子。
具体见样例。
【输入样例】
3
1 2
1 3
【输出样例】
3
2 3 3
2 1 1
【核心思想】
-
问题分析:给定一棵 \(n\) 个节点的无权树,进行 \(n-1\) 次操作。每次选择树上的两个叶子,将两点间路径长度累加到答案中,然后删除其中一个叶子,最终只剩一个节点。目标是最大化答案总和,并构造操作序列。本质上是树上的贪心删除策略——为了最大化每次路径长度,应尽量让被删除的叶子与树的直径端点配对,确保每次路径尽可能长。
-
算法选择:
- 两次 DFS 求直径:先找出树的一条直径,记录两个端点 \(l\) 和 \(r\),并标记直径路径上的所有节点。
- 贪心删除非直径叶子:对于所有不在直径上的节点,将其视为“分支叶子”,它们最终会与直径的某个端点配对(选择到 \(l\) 或 \(r\) 距离更大的那个),累加该距离,并记录操作。
- 最后处理直径路径:直径上的节点依次与保留的端点配对(从一端向另一端删除),每次累加当前点到保留端点的距离。
- 正确性证明:由于每次删除的叶子若不在直径上,它与直径端点的距离是它能形成的最大路径长度;直径上的节点最后删除,确保所有非直径节点都能与最优的直径端点配对,从而获得最大总和。
-
关键步骤:
- 读入与建图:读入 \(n\) 和 \(n-1\) 条边,构建无向邻接表。
- 两次 DFS 求直径:
- 第一次从任意点(如 1)出发,找到最远点 \(l\)。
- 第二次从 \(l\) 出发,找到最远点 \(r\),并记录每个节点的父节点
pre,用于回溯直径路径。
- 标记直径路径:从 \(r\) 沿
pre回溯到 \(l\),将路径上的节点标记为col=1。 - 处理非直径节点:对直径上的每个节点,递归处理其非直径子树。对于每个非直径节点 \(x\),找到其在直径上的最近祖先 \(rt\),计算 \(x\) 到 \(l\) 的距离 \(d_x\) 和 \(x\) 到 \(r\) 的距离(\(d_x + d_r - 2*d_{rt}\)),选择较大值累加到答案,并记录配对操作。
- 处理直径路径:从 \(r\) 向 \(l\) 遍历,每次将当前点与 \(l\) 配对,累加距离,并记录操作(删除当前点),直到只剩下 \(l\)。
- 输出:第一行输出最大答案,随后 \(n-1\) 行按操作顺序输出每次选择的两个叶子及删除的叶子(通常删除第二个)。
-
时间/空间复杂度:
- 时间复杂度:\(O(n)\),两次 DFS 遍历整棵树,后续处理也是线性。
- 空间复杂度:\(O(n)\),存储邻接表和辅助数组。
-
贪心 + 直径的核心思想:
- 最优策略的结构:为了最大化每次路径长度,应尽量让被删除的叶子与树的直径端点配对。所有非直径节点最终都会与直径的一个端点配对,而直径上的节点会依次与保留的端点配对。
- 距离计算的简化:利用直径端点的距离信息,可以快速得到任意节点到直径端点的距离,避免重复计算。
- 操作的可行性:每次删除的必须是当前叶子。在算法中,非直径节点删除时仍是叶子,直径上的节点在非直径节点全部删除后成为叶子,因此操作顺序合法。
- 适用场景:适用于需要最大化“删除叶子并累加路径长度”的树形贪心问题,是树的直径与贪心策略结合的经典例题。
【算法标签】
提高+ #树形DP
【代码详解】
#include <bits/stdc++.h>
using namespace std;
#define int long long // 将 int 定义为 long long,防止答案溢出
typedef pair<int, int> PII; // 存储操作对:两个叶子节点
const int N = 200005, M = 2 * N; // N: 最大节点数, M: 最大边数(无向图双向边)
int n; // 树的顶点数
int h[N], e[M], w[M], ne[M], idx; // 链式前向星存图
int pre[N]; // pre[u]: DFS时u的父节点(用于回溯直径路径)
int d[N]; // d[u]: 从某个起点到u的距离
int col[N]; // col[u]: 标记u是否在主直径路径上
int ans; // 最大答案(总路径长度之和)
int l, r, p, maxd; // l: 直径左端点, r: 直径右端点, p: 临时最远点, maxd: 当前最大距离
vector<PII> s; // 存储所有操作的叶子节点对
// 链式前向星:添加无向边
void add(int a, int b, int c)
{
e[idx] = b, w[idx] = c, ne[idx] = h[a], h[a] = idx++;
}
// DFS:求树的直径端点,同时记录父节点信息
void dfs(int u, int fa)
{
pre[u] = fa; // 记录u的父节点
// 遍历u的所有邻接点
for (int i = h[u]; i != -1; i = ne[i])
{
int v = e[i];
if (v == fa) continue; // 跳过父节点
d[v] = d[u] + w[i]; // 更新v到起点的距离
// 更新最远点(距离相同时优先编号小的)
if (d[v] > maxd || (d[v] == maxd && v < p))
{
maxd = d[v];
p = v;
}
dfs(v, u); // 递归DFS
}
}
// work:处理以x为根的子树,rt是当前子树在直径上的根节点
// 对于非直径路径上的叶子节点,计算最优配对策略
void work(int x, int rt)
{
// 先递归处理子节点
for (int i = h[x]; i != -1; i = ne[i])
{
int y = e[i];
if (y == pre[x]) continue; // 跳过父节点
if (d[y] > d[x]) // y是x的子节点(距离递增方向)
work(y, col[y] ? y : rt); // 如果y在直径上,rt更新为y,否则继承rt
}
// 如果x不在直径路径上,则需要将x作为叶子删除
// 选择配对对象使得路径长度最大
if (!col[x])
{
// 比较两种配对策略:
// 1. x与直径左端点l配对:路径长度 = d[x](x到l的距离)
// 2. x与直径右端点r配对:路径长度 = d[x] + d[r] - 2*d[rt](x到r的距离,rt是x在直径上的最近祖先)
if (d[x] > d[x] + d[r] - d[rt] * 2)
{
ans += d[x];
s.push_back({l, x});
}
else
{
ans += d[x] + d[r] - d[rt] * 2;
s.push_back({r, x});
}
}
}
signed main() // 使用 signed 替代 int,因为 #define int long long
{
cin >> n; // 读入顶点数
// 初始化链式前向星
memset(h, -1, sizeof(h));
// 读入n-1条边(树结构)
for (int i = 1; i < n; i++)
{
int a, b;
cin >> a >> b;
add(a, b, 1), add(b, a, 1); // 无向边,长度为1
}
// ========== 第一步:求树的直径 ==========
// 第一次DFS:从任意点1出发,找到最远点p
dfs(1, 0);
l = p; // l是直径的一个端点
d[l] = maxd = 0; // 重置距离数组,准备第二次DFS
// 第二次DFS:从l出发,找到最远点r
dfs(l, 0);
r = p; // r是直径的另一个端点
// 标记直径路径上的所有点
for (int i = r; i; i = pre[i])
col[i] = 1;
// ========== 第二步:处理非直径路径上的叶子节点 ==========
// 对于直径上的每个分支,递归处理其叶子节点
work(l, l);
// ========== 第三步:处理直径路径上的点 ==========
// 从r向l遍历,依次删除直径上的点(保留l到最后)
for (; l != r; r = pre[r])
{
ans += d[r]; // 当前配对(l, r)的路径长度
s.push_back({l, r}); // 记录操作
}
// ========== 输出结果 ==========
cout << ans << endl;
// 输出所有操作,每次删除第二个叶子节点
for (auto i : s)
cout << i.first << " " << i.second << " " << i.second << endl;
return 0;
}
【运行结果】
3
1 2
1 3
3
2 3 3
2 1 1
浙公网安备 33010602011771号