AncientDistance - 题解
标签与难度
标签: 树上问题, 贪心, 分治, 深度优先搜索, 动态规划, 复杂度优化 难度: 2100
题目大意喵~
主人你好呀,喵~ 这是一道关于在树上放置“关键节点”的有趣问题!
我们有一棵有 个节点的有根树,根是节点1。我们需要在这棵树上指定最多 个节点作为“关键节点”。
对于树上的任何一个节点 ,它的 “古代距离” 被定义为:从 沿着唯一的路径往根节点走的路上,遇到的第一个关键节点与 之间的距离。如果这条路径上一个关键节点都木有,那古代距离就是无限大哦。如果 自己就是关键节点,那它的古代距离就是 0。
我们的任务是,对于每一个 (从 1 到 ),找出一种放置不超过 个关键节点的方案,使得所有节点中最大的古代距离尽可能小。最后,把这 个(对于 )求出的最小的最大古代距离加起来,作为最终的答案,的说。
举个栗子:如果树是 1-2-3,根是1,我们只在节点2放一个关键节点。
- 节点1的古代距离是 (往根走没关键节点)。
- 节点2的古代距离是 0 (它自己就是)。
- 节点3的古代距离是 1 (从3往根走,第一个遇到的是2,距离是1)。 所以,这次放置方案中,最大的古代距离是 。我们需要找到一个更好的放置方法来让这个最大值变小,喵~
解题思路分析
这道题看起来有点绕,但别担心,跟着我的思路一步步来,问题就会变得清晰起来,喵!
这个题目要求我们对每个 都求解,这种“对所有xx求解”的模式,通常暗示着我们不能对每个 都独立暴力算一次,而是要找到它们之间的联系,进行整体的计算。
核心问题的转化
我们先简化一下问题。不去想“对于每个K”,而是先解决一个子问题:
子问题:给定一个最大允许的古代距离 ,最少需要多少个关键节点才能满足要求?
我们把这个最少需要的关键节点数量记为 。
如果我们可以高效地计算出 ,那么原问题就好办多啦。 为啥呢?因为 这个函数有一个非常好的性质:它是单调不增的。也就是说,如果我们允许的最大距离 越大,所需要的关键节点数量 就只会变少或者不变,绝对不会增多,对吧?这很符合直觉,限制越宽松,需要付出的代价(关键节点数)就越小。
如何计算 ?
现在,我们的焦点变成了如何为一个固定的 计算出 。这可以用一个非常经典的树上贪心策略来解决,喵~
我们的目标是用最少的关键节点“覆盖”整棵树。一个节点 被覆盖,意思是它的古代距离 。
我们可以从树的最深处开始考虑。想象一下,在树上找到一个最深的、还没有被覆盖的节点,我们称它为 。为了覆盖 ,我们必须在它自己或者它的某个祖先上放置一个关键节点。这个关键节点距离 不能超过 。
那么,这个关键节点放在哪里最划算呢?当然是放在尽可能高的位置啦!也就是 的 级祖先(从 往上走 步到达的节点)。我们把这个祖先记为 ancestor(u, D)。为什么这样最划算呢?因为把关键节点放得越高,它的“势力范围”(也就是它的子树)就越大,就能顺便覆盖掉更多其他的节点,一举多得,喵!
这启发了我们的贪心算法:
- 找到当前所有未被覆盖的节点中,深度最大的那个节点 。
- 在 的 级祖先
ancestor(u, D)处放置一个关键节点。 - 这个新的关键节点会覆盖掉
ancestor(u, D)的整棵子树。我们将这棵子树里的所有节点都标记为“已覆盖”。 - 所需关键节点数量加一。
- 重复以上步骤,直到所有节点都被覆盖。
这个过程可以用一次深度优先搜索 (DFS),以后序遍历的方式高效实现。我们定义一个 dp[u] 表示在 的子树中,距离 最远的未被覆盖节点的距离。
dfs(u)的过程:- 对 的所有孩子 递归调用
dfs(v)。 - 初始化
dp[u] = 0(代表 自己暂时未被覆盖)。 - 遍历 的孩子 ,更新
dp[u] = max(dp[u], dp[v] + 1)。dp[v] + 1表示 子树中未被覆盖的节点到 的距离。 - 更新完后,如果
dp[u] == D,这说明 子树里最远的那个“可怜蛋”距离 刚好是 。我们不能再等了,必须在 这里放一个关键节点来拯救它!于是,我们将总关键节点数加一,然后将dp[u]设为一个特殊值(比如 -1),表示 的子树已经被完全覆盖,不需要再向上传递“求救信号”了。 - 如果 是根节点(节点1),并且在处理完所有孩子后
dp[1] >= 0,说明根节点之上还有未被覆盖的节点(就是根节点自己或者它的子树),所以必须在根节点放一个关键节点。
- 对 的所有孩子 递归调用
通过这个 的 DFS,我们就能算出任何一个 对应的 啦!
从 到最终答案
现在我们能算 了。但是,如果对所有可能的 (从 0 到树的最大深度) 都算一遍 ,总复杂度会是 ,当树是一条链时,这还是太慢了。
注意到我们要求解的是一系列 f(D),并且 f(D) 的取值范围是 [1, N],D 的取值范围是 [0, max_depth]。f(D) 关于 D 是单调的!这种 “在单调序列上求解一系列点值” 的问题,是分治大显身手的好地方,喵~
我们可以定义一个分治函数 solve(d_min, d_max, k_min, k_max),它的任务是计算所有 的 值。我们已经知道,这些 的值一定落在 的范围内。
solve(d_min, d_max, k_min, k_max)的过程:- 如果 ,说明范围为空,直接返回。
- 取距离范围的中点 。
- 用我们 的贪心算法计算出 。
- 利用单调性,我们可以缩小后续的搜索范围:
- 对于左半边区间 ,对应的 值一定不小于 。所以我们递归调用
solve(d_min, d_{mid}-1, k_{mid}, k_{max})。 - 对于右半边区间 ,对应的 值一定不大于 。所以我们递归调用
solve(d_{mid}+1, d_{max}, k_{min}, k_{mid})。
- 对于左半边区间 ,对应的 值一定不小于 。所以我们递归调用
这个分治算法的复杂度是多少呢?在每一层分治中,我们对整个距离范围进行了一次 的计算。分治的深度是 。所以总时间复杂度是 ,非常棒!
计算最终总和
通过分治,我们得到了一个数组,不妨叫 keys_needed[D] = f(D)。现在的问题是,如何计算 ?
min_max_dist(K) 是我们想求的,对于一个 ,它是满足 的最小的 。
直接求和有点困难,我们换个角度看。我们要求的总和,可以看作是 min_max_dist(K) 关于 K 的曲线下的面积。我们可以对 D 进行累加:
这个变换的意思是,对于每个距离值 ,我们数一数有多少个 使得最小最大距离比 还大,然后把这些计数全部加起来。
条件 等价于什么呢?它意味着,即使我们允许的最大距离是 ,所需要的关键点数量 仍然比我们拥有的 要多,即 。
所以,对于一个固定的 ,满足 的 的数量就是 (因为 可以取 )。
于是,最终的答案就是:
(这里我们假设 )。
所以,在用分治算法算出所有的 之后,我们只需把它们加起来再减去 max_depth + 1 就好啦!
代码实现
好啦,理论分析结束,是时候亮出我爪下的代码了,喵~
#include <iostream>
#include <vector>
#include <algorithm>
#include <numeric>
using namespace std;
const int MAXN = 200005;
vector<int> adj[MAXN];
int parent[MAXN][20]; // 用于倍增跳跃
int depth[MAXN];
int n;
long long keys_needed_for_dist[MAXN]; // f(D) 数组
int post_order_nodes[MAXN]; // 后序遍历序列
int post_order_idx;
// 预处理DFS,计算深度、父节点和后序遍历序列
void dfs_precompute(int u, int p, int d) {
depth[u] = d;
parent[u][0] = p;
for (int v : adj[u]) {
if (v != p) {
dfs_precompute(v, u, d + 1);
}
}
post_order_nodes[post_order_idx++] = u;
}
// 预处理倍增数组
void build_lca() {
for (int j = 1; j < 20; ++j) {
for (int i = 1; i <= n; ++i) {
if (parent[i][j - 1] != 0) {
parent[i][j] = parent[parent[i][j - 1]][j - 1];
}
}
}
}
// 贪心计算 f(D)
// dp[u] 表示 u 子树中,距离 u 最远的未被覆盖节点的距离
int dp[MAXN];
int calculate_keys(int max_dist) {
if (max_dist < 0) return n + 1; // 不可能的情况
int keys_count = 0;
// 使用后序遍历序列来模拟从叶到根的计算过程
for (int i = 0; i < n; ++i) {
int u = post_order_nodes[i];
dp[u] = 0;
for (int v : adj[u]) {
if (v != parent[u][0]) { // 只考虑孩子节点
// dp[v] == -1 表示 v 子树已覆盖
if (dp[v] != -1) {
dp[u] = max(dp[u], dp[v] + 1);
}
}
}
// 如果 u 子树内最远的未覆盖节点距离 u 恰好是 max_dist
// 并且 u 不是根节点,我们必须在 u 处放置一个关键节点
if (u != 1 && dp[u] == max_dist) {
keys_count++;
dp[u] = -1; // 标记 u 子树为已覆盖
}
}
// 最后检查根节点
// 如果根节点或其子树仍有未覆盖的节点,必须在根节点放置一个
if (dp[1] != -1) {
keys_count++;
}
return keys_count;
}
// 核心的分治函数
void solve_dc(int d_min, int d_max, int k_min, int k_max) {
if (d_min > d_max || k_min > k_max) {
return;
}
int d_mid = d_min + (d_max - d_min) / 2;
// 计算中间点的 f(D) 值
keys_needed_for_dist[d_mid] = calculate_keys(d_mid);
int k_mid = keys_needed_for_dist[d_mid];
// 递归处理左右两边
solve_dc(d_min, d_mid - 1, k_mid, k_max);
solve_dc(d_mid + 1, d_max, k_min, k_mid);
}
void solve() {
for (int i = 1; i <= n; ++i) {
adj[i].clear();
keys_needed_for_dist[i] = 0;
for(int j = 0; j < 20; ++j) parent[i][j] = 0;
}
for (int i = 2; i <= n; ++i) {
int p;
cin >> p;
adj[p].push_back(i);
adj[i].push_back(p);
}
post_order_idx = 0;
dfs_precompute(1, 0, 0);
int max_depth = 0;
for (int i = 1; i <= n; ++i) {
max_depth = max(max_depth, depth[i]);
}
// 分治求解 f(D) for D in [0, max_depth]
// f(D) 的值在 [1, n] 之间
solve_dc(0, max_depth, 1, n);
// 有些 D 值在分治中点没被取到,需要用单调性补全
// keys_needed_for_dist[D] <= keys_needed_for_dist[D-1]
for(int i = max_depth - 1; i >= 0; --i) {
if(keys_needed_for_dist[i] == 0) { // 0 是我们的初始值,表示没算过
keys_needed_for_dist[i] = keys_needed_for_dist[i+1];
}
}
long long total_sum = 0;
// 根据公式 sum(f(D)-1) 计算总和
// f(D) = 1 是最少的情况 (只在根节点放一个)
// 对于 D >= max_depth,f(D) 总是 1
// min_max_dist(K) 的值不会超过 max_depth
// 我们求 sum_{K=1 to n} min_max_dist(K)
// min_max_dist(K) = D_val <==> f(D_val) <= K and f(D_val-1) > K
// 我们可以用双指针来求和
long long current_dist = max_depth;
for (int k = 1; k <= n; ++k) {
while (current_dist > 0 && keys_needed_for_dist[current_dist-1] <= k) {
current_dist--;
}
total_sum += current_dist;
}
cout << total_sum << endl;
}
int main() {
// 提高cin/cout效率
ios_base::sync_with_stdio(false);
cin.tie(NULL);
while (cin >> n) {
solve();
}
return 0;
}复杂度分析
时间复杂度:
dfs_precompute预处理是 。- 核心是
solve_dc分治函数。每次调用solve_dc都会执行一次 的calculate_keys。分治的递归深度是 ,而 。所以这部分的总时间复杂度是 。 - 最后计算总和是 的双指针。
- 所以,总的时间复杂度瓶颈在分治上,为 ,完全可以接受,喵~
空间复杂度:
adj,depth,keys_needed_for_dist,dp等数组都需要 的空间。parent倍增数组需要 的空间。- 递归栈的深度是 。
- 所以总空间复杂度是 。如果把倍增数组换成更朴素的单次 查询祖先,空间可以降到 ,但
calculate_keys的复杂度会略微增加,不过在我们的后序遍历实现中,只需要父节点信息,所以可以优化到 空间。不过 空间对于本题是足够的。
知识点总结
这道题是一道非常好的综合题,融合了多种算法思想,值得好好回味,喵~
- 问题转化: 把复杂的“对所有K求解”问题,转化为研究
f(D)和min_max_dist(K)这两个互为“反函数”的关系。 - 树上贪心: 解决子问题
f(D)时,通过“总是选择最深未覆盖点,并在其尽可能高的祖先处放置关键点”的贪心策略,找到了最优解。 - DFS实现贪心: 使用后序遍历的DFS,可以自底向上地收集子树信息,完美地实现了上述贪心策略。
- 分治 (Divide and Conquer): 利用
f(D)的单调性,对求解的“值域”(也就是距离D的范围)进行分治,大大优化了从 到 的时间复杂度。这是一种处理“答案范围”的常用技巧,有时也被称作“整体DP”或“CDQ分治”的思想。 - 求和技巧: 最后计算总和时,通过转换求和顺序,将对
K的求和变成了对D的求和,或者使用双指针方法,简化了计算。
希望我的讲解对你有帮助哦!如果还有不明白的地方,随时可以再来问我,喵~