代表性面试主题

编程面试:用点分治维护动态树上最近特殊节点距离

编程题困难
Offer.cc 编辑团队发布 更新

题干

给定一棵无向树,节点可以在白色和黑色之间切换。实现 update(u) 翻转节点颜色,以及 query(u) 返回 u 到任意黑色节点的最短距离。要求支持大规模树和在线操作,请给出可证明正确的算法、复杂度与关键反例。

题干与适用场景

这是进阶编程题,考察树分解、距离预处理和动态查询。树有 n 个节点与 n-1 条边,操作在线到达;初始只有一个节点为黑色,之后可任意翻转。查询必须返回当前最近黑点距离,不能为每次查询重新遍历整棵树。

面试官考察什么

  • 能否先给出朴素基线,再识别重复遍历的瓶颈。
  • 能否证明每个节点到根重心链的长度是对数级,并正确维护链上距离。
  • 能否处理重心分解时的子树排除、重复计数、初始无黑点与整数上界。
  • 能否比较点分治与多源 BFS、重链剖分、固定黑点场景的适用条件。

回答前需要澄清的问题

需要确认树是否静态、边权是否全为一、操作是否在线、查询是否只要最小距离还是还要返回节点编号,以及是否允许离线重排。若边权为正数,点分治仍可用但距离改为加权和;若树会改边,静态分解不再直接适用。

30 秒回答框架

我会先说明朴素方案每次从 u BFS,最坏是线性。然后对树做点分治:每个节点保存它到所有祖先重心的距离;每个重心维护黑点到自己的最小距离。查询沿 u 的重心祖先链取“u 到重心距离加该重心最小黑点距离”的最小值,更新只需沿同一条链更新堆或集合。重心链长度是对数级,所以每次操作是对数级,预处理是线性对数级。

分步骤深入解答

1. 建立朴素基线与瓶颈

每次 query 从 u 做 BFS 可以得到正确答案,但一次查询会访问大量节点;反复 update 后无法复用“哪些黑点离 u 近”的结构。全局多源 BFS 只适合黑点批量变化,不适合在线翻转。

2. 选择重心并建立祖先链

在当前连通分量中找重心,使删除它后每个子分量规模不超过原分量的一半。递归处理各子分量,形成一棵重心树。原树节点 u 在重心树上有一条祖先链;预处理每个链节点对 (centroid, distance)。每层规模至少减半,因此链长不超过对数级。

3. 维护更新与查询不变量

对每个重心 c,维护所有当前黑节点 v 的 dist(v,c) 最小值。可以用最小堆加懒删除,或用 multiset 支持插入与删除。update(u) 沿 u 的重心祖先链,把 dist(u,c) 加入或删除。query(u) 对同一条链计算 dist(u,c) + best[c] 的最小值。任意路径 u 到黑点 v 会在某一层经过共同重心,因此该组合覆盖了最优路径;每个重心只负责候选,不会漏解。

4. 处理重复计数与堆删除

若使用两个堆,加入操作写入 live 堆,删除操作写入 dead 堆;读取堆顶前不断弹出两堆相等的值。若边权可能相同,堆中应存距离和节点编号,按二元组懒删除,避免只比较距离导致误删。初始黑点应先执行一次 update,查询无黑点时返回约定的无穷大。

5. 复杂度与替代方案

点分治预处理需要遍历每层分量,总复杂度为 O(n log n),空间为 O(n log n);每次 update 与 query 访问对数条链,堆操作再乘一个对数因子。若黑点只增加不删除,可用单堆且实现更简单;若查询是固定路径聚合,重链剖分和线段树更自然;若所有操作离线,可考虑按时间分治。

6. C++ 实现骨架

下面代码展示单位边权、黑点翻转和最短距离查询。为避免递归栈过深,生产实现可把找重心的遍历改为显式栈;示例保留递归结构以突出不变量。

cpp
#include <bits/stdc++.h>
using namespace std;

struct Entry { int d, u; bool operator>(const Entry& o) const { return tie(d,u) > tie(o.d,o.u); } };
int n;
vector<vector<int>> g;
vector<int> sub, dead, black;
vector<vector<pair<int,int>>> chain;
vector<priority_queue<Entry, vector<Entry>, greater<Entry>>> liveHeap, deadHeap;

void calcSize(int u, int p) {
  sub[u] = 1;
  for (int v : g[u]) if (v != p && !dead[v]) { calcSize(v,u); sub[u] += sub[v]; }
}
int findCentroid(int u, int p, int total) {
  for (int v : g[u]) if (v != p && !dead[v] && sub[v] > total/2)
    return findCentroid(v,u,total);
  return u;
}
void collect(int u, int p, int c, int d) {
  chain[u].push_back({c,d});
  for (int v : g[u]) if (v != p && !dead[v]) collect(v,u,c,d+1);
}
void decompose(int entry) {
  calcSize(entry,-1);
  int c = findCentroid(entry,-1,sub[entry]);
  dead[c] = 1;
  collect(c,-1,c,0);
  for (int v : g[c]) if (!dead[v]) decompose(v);
}
void clean(int c) {
  while (!liveHeap[c].empty() && !deadHeap[c].empty() &&
         liveHeap[c].top().d == deadHeap[c].top().d &&
         liveHeap[c].top().u == deadHeap[c].top().u) {
    liveHeap[c].pop(); deadHeap[c].pop();
  }
}
void update(int u) {
  black[u] ^= 1;
  for (auto [c,d] : chain[u]) {
    if (black[u]) liveHeap[c].push({d,u});
    else deadHeap[c].push({d,u});
  }
}
int query(int u) {
  const int INF = 1e9; int ans = INF;
  for (auto [c,d] : chain[u]) { clean(c); if (!liveHeap[c].empty()) ans = min(ans, d + liveHeap[c].top().d); }
  return ans == INF ? -1 : ans;
}

高质量示范回答

我会用点分治把静态树变成重心树。预处理时,为每个节点记录到每个重心祖先的距离;每个重心维护当前黑点到它的最小距离。翻转节点时沿祖先链插入或懒删除距离,查询时取所有祖先的“到重心距离加重心最小黑点距离”的最小值。路径在某层一定经过共同重心,所以最优黑点不会被漏掉。重心链是对数长度,预处理约为 O(n log n);使用堆懒删除时更新和查询为对数链长乘堆操作开销。若只有新增没有删除,我会去掉删除堆;若树边会动态改变,则改用支持动态树的结构。

常见错误

  • 每次查询 BFS → 正确但无法承受大量在线操作 → 先给基线,再引入重心链。
  • 只把节点加入最近重心 → 路径可能经过更高层重心 → 为每个节点保存完整祖先链。
  • 懒删除只比较距离 → 相同距离的不同节点会误删 → 堆项同时记录节点编号。
  • 把重心分解误当成 LCA → 两者解决的问题不同 → 明确这里维护的是动态集合到单点距离。
  • 忽略初始无黑点 → 返回未初始化的大数 → 约定返回 -1 并在框架中说清楚。

追问及应对

如果边有正权,算法要怎么改?

收集祖先链时累计边权而非步数,堆里保存加权距离;重心分解依据节点数仍成立。若允许零或正权,最小值不变量不变。

如果还要返回最近黑点的编号呢?

堆项保存 (distance, nodeId) 并按字典序比较。多个黑点同距时可按编号稳定选取;查询组合时同时维护当前最优距离和编号。

为什么不能只维护 u 的父重心?

最近黑点可能位于与 u 不同的子分量,路径会在更高层重心汇合。只检查一个父重心会漏掉跨分量的最优答案,必须遍历整条祖先链。

什么时候重链剖分更合适?

当操作是路径上的可结合聚合、需要查询任意两点路径,或黑点集合不是“节点到集合的最小距离”时,重链剖分加线段树通常更直接。点分治适合围绕一个节点对动态集合做距离聚合。

公开来源

同类题目

相关面试工具

用 Screenshot 处理算法题

截图题目后,按顺序看约束、解法、代码、边界条件和复杂度。

查看工具