跳转至

最近公共祖先 LCA

概述

定义

对于 \(x\)\(y\) 两个结点,我们需要找到一个节点 \(z\),使得 \(z\) 既是 \(x\) 的祖先也是 \(y\) 的祖先,并且 \(z\)\(x\)\(y\) 的距离最近。此时,结点 \(z\) 就是 \(x\)\(y\) 的最近公共祖先(Least Common Ancestor, LCA)。值得注意的是,我们可以看作结点本身也是自己的祖先。

尝试求解

首先由浅至深找到两个结点的祖先列表,然后再由深至浅找到最近的公共祖先结点。值得注意的是,这种方法虽然是暴力,但是在二叉树当中是可以达到 \(\mathcal O(\log_2 n)\) 的速度的,但是这里并不一定是二叉树,也就是说当这颗树退化到一条链的时候时,这种算法可能达到 \(\mathcal O(n)\) 甚至 \(\mathcal O(n^2)\)

尝试改进

这种方法可能会造成两个结点的祖先列表长度不一样,给我们匹配时带来麻烦。因此,我们可以跑一遍 dfs 求出任意节点离根结点的距离,然后在求解时先将深度较大的结点向上提,然后两个结点同时向上查找最近的公共祖先。这样子就省去了匹配的时间,但是时间复杂度最高还是有可能会达到 \(\mathcal O(n)\),不符合期望。

算法

倍增思想

优化

我们可以跑一边树上 dp,设 \(dp_{(i,j)}\) 表示为结点 \(i\) 往上 \(2^j\) 个祖先的编号,那么我们就可以使用倍增算法在 \(\mathcal O(\log_2 n)\) 的时间复杂度之内将两个结点的层级提升到同一层,接下来就是优化枚举公共节点了。

因为有单调性,也就是往上上升的高度越大,那么成为公共祖先的概率就越高,因此我们同样使用倍增思想,那么也可以在 \(\mathcal O(\log_2 n)\) 的时间之内求出两个结点的最近公共祖先,注意这里仍然要使用之前的 \(dp\) 数组。

代码

#include <iostream>
#include <vector>

using namespace std;

const int kMaxN = 5e5 + 5;

int dp[kMaxN][25], d[kMaxN], lg[kMaxN], n, m, s;
vector<int> e[kMaxN];

void dfs(int x, int f) {
  dp[x][0] = f, d[x] = d[f] + 1;         // 更新 dp 数组和距离数组
  for (int i = 1; i <= lg[d[x]]; i++) {  // 枚举往前 2^i 的祖先
    dp[x][i] = dp[dp[x][i - 1]][i - 1];  // 计算
  }
  for (int i : e[x]) {         // 枚举邻点
    f != i && (dfs(i, x), 0);  // 递归处理
  }
}

int LCA(int x, int y) {
  for (d[x] < d[y] && (x ^= y, y ^= x, x ^= y); d[x] > d[y]; x = dp[x][lg[d[x] - d[y]] - 1]) { }
  // 首先保持 x > y,然后进行倍增,直到 d[x] = d[y]
  if (x == y) {  // 如果已经等于了
    return x;    // 直接返回 x
  }
  for (int k = lg[d[x]] - 1; k >= 0; k--) {                // 往上做差分
    dp[x][k] != dp[y][k] && (x = dp[x][k], y = dp[y][k]);  // 取最优值
  }
  return dp[x][0];  // 记得返回上面一个数
}

int main() {
  cin >> n >> m >> s;
  for (int i = 1, u, v; i < n; i++) {
    cin >> u >> v;
    e[u].push_back(v), e[v].push_back(u);
  }
  for (int i = 1; i <= n; lg[i] = lg[i - 1] + (1 << lg[i - 1] == i), i++) { }
  dfs(s, 0);
  for (int i = 1, x, y; i <= m; i++) {
    cin >> x >> y;
    cout << LCA(x, y) << '\n';
  }
  return 0;
}

Tarjan

思路

这是一种离线算法。我们可以将一个结点的所有子树合并到这个结点内,然后当要查询这个子树内的任意两个结点的最近公共祖先的时候,那么这个结点就是答案了。而支持快速合并操作的就是并查集了。我们在函数内,首先我们需要递归处理子树,然后合并至当前结点,接着遍历所有需要求解的二元组,然后存进 \(a\) 数组里面就行了。

这种算法是最快的,但是只能用作离线成了这个算法的最大弱点。并且你还需要将所有的询问读入进来在处理,在分批询问、有更改操作的情况下不能很好的支持。

注意

朴素 Tarjan 的时间复杂度并不是 \(\mathcal O(n+m)\) 的,而是 \(\mathcal O(m\times\alpha(n+m,n)+n)\) 的。如果需要追求线性的话,可以看 这篇英文论文

代码

#include <iostream>
#include <vector>

using namespace std;

const int kMaxN = 1e6 + 5;

struct Node {
  vector<int> e;             // 邻点
  vector<pair<int, int>> q;  // 查询
  int f;
} v[kMaxN];

int a[kMaxN], n, m, s;

int F(int x) {
  return !v[x].f ? x : v[x].f = F(v[x].f);
}

void tarjan(int x, int f) {
  for (int i : v[x].e) {  // 枚举邻点
    if (i != f) {         // 如果不为父亲
      tarjan(i, x);       // 递归处理
    }
  }
  for (auto i : v[x].q) {  // 枚举需要的答案
    if (i.first == x) {    // 如果正好等于 x
      a[i.second] = x;     // 直接记录
    } else {
      a[i.second] = F(i.first);  // 否则更改为 i.first 的根
    }
  }
  v[x].f = f;  // 合并
}

int main() {
  ios::sync_with_stdio(0), cin.tie(0), cout.tie(0);
  cin >> n >> m >> s;
  for (int i = 1, x, y; i < n; i++) {
    cin >> x >> y;
    v[x].e.push_back(y), v[y].e.push_back(x);
  }
  for (int i = 1, x, y; i <= m; i++) {
    cin >> x >> y;
    v[x].q.emplace_back(y, i), v[y].q.emplace_back(x, i);
  }
  tarjan(s, 0);
  for (int i = 1; i <= m; i++) {
    cout << a[i] << '\n';
  }
  return 0;
}

习题

注意

最近公共祖先基本上不会考模板题,而是题目做着做着就会用一下的那种,所以需要背熟板子,不过基本上只用背倍增的那种就行了。比如接下来的题目中,就有很多是将一条路径拆分成两条路径的然后求解的那种。

判断路径

题目大意

有一颗 \(n\) 个节点的树,给出 \(q\) 个询问,每一个询问有 \(a,b,c,d\) 四个结点编号,问你 \(a\)\(b\)\(c\)\(d\) 的路径是否碰到过。

思路

这题我们使用倍增求解 LCA。然后就非常简单了,变成了判断是否碰到过的问题。首先,我们可以找出 \(a\)\(b\) 的最近公共祖先,就假设为 \(l_1\),然后当 \(c\)\(l_1\) 的最近公共祖先证号为 \(l_1\) 的时候,或者 \(d\)\(l_1\) 的最近公共祖先正好为 \(l_1\) 的时候,那么就说明碰到过。然后反过来再判断一次就行了。

代码

#include <iostream>
#include <vector>

using namespace std;

const int kMaxN = 5e5 + 5;

int dp[kMaxN][25], d[kMaxN], lg[kMaxN], n, q;
vector<int> e[kMaxN];

void dfs(int x, int f) {
  dp[x][0] = f, d[x] = d[f] + 1;
  for (int i = 1; i <= lg[d[x]]; i++) {
    dp[x][i] = dp[dp[x][i - 1]][i - 1];
  }
  for (int i : e[x]) {
    f != i && (dfs(i, x), 0);
  }
}

int LCA(int x, int y) {
  for (d[x] < d[y] && (x ^= y, y ^= x, x ^= y); d[x] > d[y]; x = dp[x][lg[d[x] - d[y]] - 1]) { }
  if (x == y) {
    return x;
  }
  for (int k = lg[d[x]] - 1; k >= 0; k--) {
    dp[x][k] != dp[y][k] && (x = dp[x][k], y = dp[y][k]);
  }
  return dp[x][0];
}

int main() {
  cin >> n >> q;
  for (int i = 1, u, v; i < n; i++) {
    cin >> u >> v;
    e[u].push_back(v), e[v].push_back(u);
  }
  for (int i = 1; i <= n; lg[i] = lg[i - 1] + (2 * lg[i - 1] == i), i++) { }
  dfs(1, 0);
  for (int x1, y1, x2, y2; q; q--) {
    cin >> x1 >> y1 >> x2 >> y2;
    int z1 = LCA(x1, y1), z2 = LCA(x2, y2);  // 获取祖先
    cout << ((LCA(x2, z1) == z1 || LCA(y2, z1) == z1) && (LCA(x1, z2) == z2 || LCA(y1, z2) == z2) ? 'Y' : 'N') << '\n';  // 判断
  }
  return 0;
}

求和

题目大意

有一颗 \(n\) 个结点的树,设 \(d(i)\) 表示为第 \(i\) 个结点离根节点的距离,那么每一次询问,都给你两个点和一个整数 \(k\),然后假设两个点之间的路径的编号数组为 \(a\),那么你需要求解 \(\sum\limits_{i=1}^{|a|}d(a_i)^k\)

思路

这题是一道树上差分的题目。我们设 \(p_{(i,j)}\) 表示为根节点 \(1\)\(i\) 号结点上,所有函数的值的 \(j\) 次方的和。那么我们就可以跑树上 dp,求出 \(dp\) 数组和 \(p\) 数组,然后将每一次询问一刀剁成两半,分别作差分操作,最后累加起来就行了。注意这题需要对 \(998244353\) 取模,然而差分的时候,我们可能会差分到负数,但是这题理论上不可能差分到复数的,所以我们每一次相减就先加上 \(998277353\) 之后再取模。

代码

#include <iostream>
#include <vector>

using namespace std;
using ll = long long;

const ll kMaxN = 3e5 + 5, kMod = 998244353;

ll d[kMaxN], fa[kMaxN], p[kMaxN][51], dp[kMaxN][25], lg[kMaxN], n, m, u, v, k;
vector<ll> e[kMaxN];

void dfs(ll x, ll f) {
  dp[x][0] = f, d[x] = d[f] + 1, fa[x] = f;  // 计算父亲、距离
  ll pow = 1;                                // d[x] ^ 0
  for (ll i = 1; i <= 50; i++) {             // d[x] ^ i
    pow = (pow * d[x]) % kMod;               // 计算幂,记得取模
    p[x][i] = (p[f][i] + pow) % kMod;        // 存下来,记得取模
  }
  for (ll i = 1; i <= lg[d[x]]; i++) {       // 更新 dp 数组
    dp[x][i] = dp[dp[x][i - 1]][i - 1];
  }
  for (ll i : e[x]) {
    f != i && (dfs(i, x), 0);
  }
}

ll LCA(ll x, ll y) {  // 最近公共祖先模板
  for (d[x] < d[y] && (x ^= y, y ^= x, x ^= y); d[x] > d[y]; x = dp[x][lg[d[x] - d[y]] - 1]) { }
  if (x == y) {
    return x;
  }
  for (ll k = lg[d[x]] - 1; k >= 0; k--) {
    dp[x][k] != dp[y][k] && (x = dp[x][k], y = dp[y][k]);
  }
  return dp[x][0];
}

int main() {
  cin >> n, d[0] = -1;  // 根节点距离应为 0
  for (ll i = 1, u, v; i < n; i++) {
    cin >> u >> v;
    e[u].push_back(v);
    e[v].push_back(u);
  }
  for (ll i = 1; i <= n; lg[i] = lg[i - 1] + (1 << lg[i - 1] == i), i++) { }
  dfs(1, 0);
  for (cin >> m; m; m--) {
    cin >> u >> v >> k;
    u > v && (u ^= v, v ^= u, u ^= v);  // 保持 u <= v
    ll l = LCA(u, v);                   // 获取最近公共祖先
    if (l == u || l == v) {             // 如果是一条边
      cout << (p[v][k] - p[fa[u]][k] + kMod) % kMod << '\n';                     // 直接计算
    } else {
      cout << ((((p[v][k] - p[fa[l]][k] + kMod) % kMod +                         // 左边的和
                 (p[u][k] - p[fa[l]][k] + kMod) % kMod) % kMod -                 // 右边的和
                 (p[l][k] - p[fa[l]][k] + kMod) % kMod) + kMod) % kMod << '\n';  // 减去 d[l] ^ k
    }
  }
  return 0;
}

继续差分

题目大意

\(k\) 个询问,每一次 \(u\)\(v\) 路径上面的每一个结点的值都要加 \(1\),问你最后最大的值是多少。

思路

还是可以作差分,将一条路径分成两半,作差分操作,最后跑一边 dfs 还原数组,取最大值就行了。

代码

#include <iostream>
#include <algorithm>
#include <vector>

using namespace std;

const int kMaxN = 5e4 + 5;

int dp[kMaxN][25], d[kMaxN], lg[kMaxN], p[kMaxN], fa[kMaxN], a[kMaxN], n, k;
vector<int> e[kMaxN];

void dfs(int x, int f) {
  fa[x] = f;
  dp[x][0] = f, d[x] = d[f] + 1;
  for (int i = 1; i <= lg[d[x]]; i++) {
    dp[x][i] = dp[dp[x][i - 1]][i - 1];
  }
  for (int i : e[x]) {
    f != i && (dfs(i, x), 0);
  }
}

int LCA(int x, int y) {
  for (d[x] < d[y] && (x ^= y, y ^= x, x ^= y); d[x] > d[y]; x = dp[x][lg[d[x] - d[y]] - 1]) { }
  if (x == y) {
    return x;
  }
  for (int k = lg[d[x]] - 1; k >= 0; k--) {
    dp[x][k] != dp[y][k] && (x = dp[x][k], y = dp[y][k]);
  }
  return dp[x][0];
}

void solve(int x, int f) {  // 还原数组
  a[x] = p[x];              // 初始化为当前元素
  for (int i : e[x]) {      // 枚举邻点
    if (i != f) {
      solve(i, x);          // 递归处理
      a[x] += a[i];         // 累加和
    }
  }
}

int main() {
  cin >> n >> k;
  for (int i = 1, u, v; i < n; i++) {
    cin >> u >> v;
    e[u].push_back(v);
    e[v].push_back(u);
  }
  for (int i = 1; i <= n; i++) {
    lg[i] = lg[i - 1] + (1 << lg[i - 1] == i);
  }
  dfs(1, 0);
  for (int i = 1, u, v; i <= k; i++) {
    cin >> u >> v;
    int l = LCA(u, v);                   // 获取最近公共祖先
    p[u]++, p[v]++, p[l]--, p[fa[l]]--;  // 差分
  }
  solve(1, 0);                                     // 求解
  cout << *max_element(a + 1, a + n + 1) << '\n';  // 求最大值
  return 0;
}

评论