Skip to content

AncientDistance - 题解

标签与难度

标签: 树上问题, 贪心, 分治, 深度优先搜索, 动态规划, 复杂度优化 难度: 2100

题目大意喵~

主人你好呀,喵~ 这是一道关于在树上放置“关键节点”的有趣问题!

我们有一棵有 NN 个节点的有根树,根是节点1。我们需要在这棵树上指定最多 KK 个节点作为“关键节点”。

对于树上的任何一个节点 xx,它的 “古代距离” 被定义为:从 xx 沿着唯一的路径往根节点走的路上,遇到的第一个关键节点与 xx 之间的距离。如果这条路径上一个关键节点都木有,那古代距离就是无限大哦。如果 xx 自己就是关键节点,那它的古代距离就是 0。

我们的任务是,对于每一个 KK (从 1 到 NN),找出一种放置不超过 KK 个关键节点的方案,使得所有节点中最大的古代距离尽可能小。最后,把这 NN 个(对于 K=1,2,,NK=1, 2, \dots, N)求出的最小的最大古代距离加起来,作为最终的答案,的说。

举个栗子:如果树是 1-2-3,根是1,我们只在节点2放一个关键节点。

  • 节点1的古代距离是 \infty (往根走没关键节点)。
  • 节点2的古代距离是 0 (它自己就是)。
  • 节点3的古代距离是 1 (从3往根走,第一个遇到的是2,距离是1)。 所以,这次放置方案中,最大的古代距离是 \infty。我们需要找到一个更好的放置方法来让这个最大值变小,喵~

解题思路分析

这道题看起来有点绕,但别担心,跟着我的思路一步步来,问题就会变得清晰起来,喵!

这个题目要求我们对每个 K{1,,N}K \in \{1, \dots, N\} 都求解,这种“对所有xx求解”的模式,通常暗示着我们不能对每个 KK 都独立暴力算一次,而是要找到它们之间的联系,进行整体的计算。

核心问题的转化

我们先简化一下问题。不去想“对于每个K”,而是先解决一个子问题:

子问题:给定一个最大允许的古代距离 DD,最少需要多少个关键节点才能满足要求?

我们把这个最少需要的关键节点数量记为 f(D)f(D)

如果我们可以高效地计算出 f(D)f(D),那么原问题就好办多啦。 为啥呢?因为 f(D)f(D) 这个函数有一个非常好的性质:它是单调不增的。也就是说,如果我们允许的最大距离 DD 越大,所需要的关键节点数量 f(D)f(D) 就只会变少或者不变,绝对不会增多,对吧?这很符合直觉,限制越宽松,需要付出的代价(关键节点数)就越小。

如何计算 f(D)f(D)

现在,我们的焦点变成了如何为一个固定的 DD 计算出 f(D)f(D)。这可以用一个非常经典的树上贪心策略来解决,喵~

我们的目标是用最少的关键节点“覆盖”整棵树。一个节点 uu 被覆盖,意思是它的古代距离 D\le D

我们可以从树的最深处开始考虑。想象一下,在树上找到一个最深的、还没有被覆盖的节点,我们称它为 uu。为了覆盖 uu,我们必须在它自己或者它的某个祖先上放置一个关键节点。这个关键节点距离 uu 不能超过 DD

那么,这个关键节点放在哪里最划算呢?当然是放在尽可能高的位置啦!也就是 uuDD级祖先(从 uu 往上走 DD 步到达的节点)。我们把这个祖先记为 ancestor(u, D)。为什么这样最划算呢?因为把关键节点放得越高,它的“势力范围”(也就是它的子树)就越大,就能顺便覆盖掉更多其他的节点,一举多得,喵!

这启发了我们的贪心算法:

  1. 找到当前所有未被覆盖的节点中,深度最大的那个节点 uu
  2. uuDD 级祖先 ancestor(u, D) 处放置一个关键节点。
  3. 这个新的关键节点会覆盖掉 ancestor(u, D) 的整棵子树。我们将这棵子树里的所有节点都标记为“已覆盖”。
  4. 所需关键节点数量加一。
  5. 重复以上步骤,直到所有节点都被覆盖。

这个过程可以用一次深度优先搜索 (DFS),以后序遍历的方式高效实现。我们定义一个 dp[u] 表示在 uu 的子树中,距离 uu 最远的未被覆盖节点的距离。

  • dfs(u) 的过程:
    1. uu 的所有孩子 vv 递归调用 dfs(v)
    2. 初始化 dp[u] = 0 (代表 uu 自己暂时未被覆盖)。
    3. 遍历 uu 的孩子 vv,更新 dp[u] = max(dp[u], dp[v] + 1)dp[v] + 1 表示 vv 子树中未被覆盖的节点到 uu 的距离。
    4. 更新完后,如果 dp[u] == D,这说明 uu 子树里最远的那个“可怜蛋”距离 uu 刚好是 DD。我们不能再等了,必须在 uu 这里放一个关键节点来拯救它!于是,我们将总关键节点数加一,然后将 dp[u] 设为一个特殊值(比如 -1),表示 uu 的子树已经被完全覆盖,不需要再向上传递“求救信号”了。
    5. 如果 uu 是根节点(节点1),并且在处理完所有孩子后 dp[1] >= 0,说明根节点之上还有未被覆盖的节点(就是根节点自己或者它的子树),所以必须在根节点放一个关键节点。

通过这个 O(N)O(N) 的 DFS,我们就能算出任何一个 DD 对应的 f(D)f(D) 啦!

f(D)f(D) 到最终答案

现在我们能算 f(D)f(D) 了。但是,如果对所有可能的 DD (从 0 到树的最大深度) 都算一遍 f(D)f(D),总复杂度会是 O(Nmax_depth)O(N \cdot \text{max\_depth}),当树是一条链时,这还是太慢了。

注意到我们要求解的是一系列 f(D),并且 f(D) 的取值范围是 [1, N]D 的取值范围是 [0, max_depth]f(D) 关于 D 是单调的!这种 “在单调序列上求解一系列点值” 的问题,是分治大显身手的好地方,喵~

我们可以定义一个分治函数 solve(d_min, d_max, k_min, k_max),它的任务是计算所有 D[dmin,dmax]D \in [d_{min}, d_{max}]f(D)f(D) 值。我们已经知道,这些 f(D)f(D) 的值一定落在 [kmin,kmax][k_{min}, k_{max}] 的范围内。

  • solve(d_min, d_max, k_min, k_max) 的过程:
    1. 如果 dmin>dmaxd_{min} > d_{max},说明范围为空,直接返回。
    2. 取距离范围的中点 dmid=(dmin+dmax)/2d_{mid} = (d_{min} + d_{max}) / 2
    3. 用我们 O(N)O(N) 的贪心算法计算出 kmid=f(dmid)k_{mid} = f(d_{mid})
    4. 利用单调性,我们可以缩小后续的搜索范围:
      • 对于左半边区间 [dmin,dmid1][d_{min}, d_{mid}-1],对应的 f(D)f(D) 值一定不小于 kmidk_{mid}。所以我们递归调用 solve(d_min, d_{mid}-1, k_{mid}, k_{max})
      • 对于右半边区间 [dmid+1,dmax][d_{mid}+1, d_{max}],对应的 f(D)f(D) 值一定不大于 kmidk_{mid}。所以我们递归调用 solve(d_{mid}+1, d_{max}, k_{min}, k_{mid})

这个分治算法的复杂度是多少呢?在每一层分治中,我们对整个距离范围进行了一次 O(N)O(N) 的计算。分治的深度是 O(log(max_depth))O(\log(\text{max\_depth}))。所以总时间复杂度是 O(NlogN)O(N \log N),非常棒!

计算最终总和

通过分治,我们得到了一个数组,不妨叫 keys_needed[D] = f(D)。现在的问题是,如何计算 K=1Nmin_max_dist(K)\sum_{K=1}^{N} \text{min\_max\_dist}(K)

min_max_dist(K) 是我们想求的,对于一个 KK,它是满足 f(D)Kf(D) \le K 的最小的 DD

直接求和有点困难,我们换个角度看。我们要求的总和,可以看作是 min_max_dist(K) 关于 K 的曲线下的面积。我们可以对 D 进行累加:

K=1Nmin_max_dist(K)=D=0max_depthK=1, s.t. min_max_dist(K)>DN1\sum_{K=1}^{N} \text{min\_max\_dist}(K) = \sum_{D=0}^{\text{max\_depth}} \sum_{K=1, \text{ s.t. } \text{min\_max\_dist}(K) > D}^{N} 1

这个变换的意思是,对于每个距离值 DD,我们数一数有多少个 KK 使得最小最大距离比 DD 还大,然后把这些计数全部加起来。

条件 min_max_dist(K)>D\text{min\_max\_dist}(K) > D 等价于什么呢?它意味着,即使我们允许的最大距离是 DD,所需要的关键点数量 f(D)f(D) 仍然比我们拥有的 KK 要多,即 f(D)>Kf(D) > K

所以,对于一个固定的 DD,满足 min_max_dist(K)>D\text{min\_max\_dist}(K) > DKK 的数量就是 f(D)1f(D) - 1 (因为 KK 可以取 1,2,,f(D)11, 2, \dots, f(D)-1)。

于是,最终的答案就是:

D=0max_depth(f(D)1)\sum_{D=0}^{\text{max\_depth}} (f(D) - 1)

(这里我们假设 f(D)1f(D) \ge 1)。

所以,在用分治算法算出所有的 f(D)f(D) 之后,我们只需把它们加起来再减去 max_depth + 1 就好啦!

代码实现

好啦,理论分析结束,是时候亮出我爪下的代码了,喵~

cpp
#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;
}

复杂度分析

  • 时间复杂度: O(NlogN)O(N \log N)

    • dfs_precompute 预处理是 O(N)O(N)
    • 核心是 solve_dc 分治函数。每次调用 solve_dc 都会执行一次 O(N)O(N)calculate_keys。分治的递归深度是 O(log(max_depth))O(\log(\text{max\_depth})),而 max_depth<N\text{max\_depth} < N。所以这部分的总时间复杂度是 O(NlogN)O(N \log N)
    • 最后计算总和是 O(N)O(N) 的双指针。
    • 所以,总的时间复杂度瓶颈在分治上,为 O(NlogN)O(N \log N),完全可以接受,喵~
  • 空间复杂度: O(NlogN)O(N \log N)

    • adj, depth, keys_needed_for_dist, dp 等数组都需要 O(N)O(N) 的空间。
    • parent 倍增数组需要 O(NlogN)O(N \log N) 的空间。
    • 递归栈的深度是 O(logN)O(\log N)
    • 所以总空间复杂度是 O(NlogN)O(N \log N)。如果把倍增数组换成更朴素的单次 O(D)O(D) 查询祖先,空间可以降到 O(N)O(N),但 calculate_keys 的复杂度会略微增加,不过在我们的后序遍历实现中,只需要父节点信息,所以可以优化到 O(N)O(N) 空间。不过 O(NlogN)O(N \log N) 空间对于本题是足够的。

知识点总结

这道题是一道非常好的综合题,融合了多种算法思想,值得好好回味,喵~

  1. 问题转化: 把复杂的“对所有K求解”问题,转化为研究 f(D)min_max_dist(K) 这两个互为“反函数”的关系。
  2. 树上贪心: 解决子问题 f(D) 时,通过“总是选择最深未覆盖点,并在其尽可能高的祖先处放置关键点”的贪心策略,找到了最优解。
  3. DFS实现贪心: 使用后序遍历的DFS,可以自底向上地收集子树信息,完美地实现了上述贪心策略。
  4. 分治 (Divide and Conquer): 利用 f(D) 的单调性,对求解的“值域”(也就是距离D的范围)进行分治,大大优化了从 O(N2)O(N^2)O(NlogN)O(N \log N) 的时间复杂度。这是一种处理“答案范围”的常用技巧,有时也被称作“整体DP”或“CDQ分治”的思想。
  5. 求和技巧: 最后计算总和时,通过转换求和顺序,将对 K 的求和变成了对 D 的求和,或者使用双指针方法,简化了计算。

希望我的讲解对你有帮助哦!如果还有不明白的地方,随时可以再来问我,喵~