\(\texttt{0x00}\) 概念
給定一棵有根樹,若節點 \(z\) 既是節點 \(x\) 的祖先,又是 \(y\) 的祖先,則稱 \(z\) 是 \(x,y\) 的公共祖先。在 \(x,y\) 的所有公共祖先中,深度最大的一個稱為 \(x,y\) 的最近公共祖先,記為 \(\texttt{LCA(x,y)}\)。
\(\texttt{0x01}\) 求解方法
1. 樹上倍增法
思路:
由向上標記法最佳化而來。
向上標記法是每次向上走一步,效率較低。而樹上倍增法最佳化了“走”的過程,每次向上走 \(2^k\) 輩祖先,然後根據二進位制拆分思想求解。
設 \(f[x][k]\) 是 \(x\) 的 \(2^k\) 輩祖先,根據動態規劃的思想,則可以得到狀態轉移方程:
其中 \(k\in [1,\log n]\)。
節點的深度為動態規劃的“階段”,所以應該對樹執行廣度優先遍歷,按照層次順序,在節點入隊前,計算它對應的 \(f\) 陣列的值。
這樣,就可以在 \(O(n\log n)\) 的時間內預處理出 \(f\) 陣列。
對於每組詢問 \((x,y)\),我們再利用二進位制的思想,將這兩個點中深度大的那個點向上走,直到兩個點深度相同。
此時,如果節點 \(x\) 和 \(y\) 在同一條樹鏈上,就會相遇,此時直接返回 \(x\)。
否則,再將 \(x\) 和 \(y\) 同時向上走相同的距離,即依次嘗試走 \(k = 2^{\log n},\cdots,2^1,2^0\) 步,在每次嘗試中,若 \(f[x][k] \ne f[y][k]\)(即仍未相遇),則令 \(x = f[x][k],y = f[y][k]\)。
此時 \(x,y\) 必定只差一步就相遇了,它們的父節點 \(f[x][0]\) 就是 \(\operatorname{LCA(x,y)}\)。
綜上所述,樹上倍增法求 \(\operatorname{LCA}\) 的預處理為 \(O(n\log n)\),每次詢問為 \(O(\log n)\)。
\(\texttt{Code:}\)
void bfs(int s) {
queue<int> q;
q.push(s);
dep[s] = 1;
while(q.size()) {
int t = q.front();
q.pop();
for(int i = h[t]; ~i; i = ne[i]) {
int j = e[i];
if(dep[j]) continue;
dep[j] = dep[t] + 1;
f[j][0] = t;
for(int k = 1; k <= T; k++) f[j][k] = f[f[j][k - 1]][k - 1];
q.push(j);
}
}
}
int lca(int x, int y) {
if(dep[x] > dep[y]) swap(x, y);
for(int i = T; i >= 0; i--) {
if(dep[f[y][i]] >= dep[x]) y = f[y][i];
}
if(x == y) return x;
for(int i = T; i >= 0; i--) {
if(f[x][i] != f[y][i]) x = f[x][i], y = f[y][i];
}
return f[x][0];
}
2.tarjan 演算法
本質上也是對向上標記法的最佳化。它是個離線演算法,所以侷限性很大,很不常用。
思路:
在深度優先遍歷的任意時刻,樹中的節點分為 \(3\) 類。
- 已經訪問且回溯的節點。這些節點標記為 \(2\);
- 已經訪問過但還沒回溯的節點,此時這些節點就是正在訪問的節點 \(x\) 或 \(x\) 的祖先。這些節點標記為 \(1\);
- 尚未訪問的節點。這些節點標記為 \(0\)。
這樣,對於正在訪問的節點 \(x\),它到根節點的路徑已經標記為 \(1\)。
若 \(y\) 是已經訪問完畢並且正在回溯的點,則 \(\operatorname{LCA(x,y)}\) 就是從 \(y\) 向上走到根,第一個遇到的標記為 \(1\) 的節點。
可以用並查集最佳化這個操作,當一個節點被標記為 \(2\) 時,把它所在的集合合併到它的父節點所在的集合中(合併時它的父節點標記一定為 \(1\),且單獨構成一個集合)。
所以查詢 \(y\) 所在集合的代表元素就等價於求 \(\operatorname{LCA(x,y)}\)。
在 \(x\) 回溯之前,掃描與 \(x\) 相關的所有詢問,若詢問中的另一個點 \(y\) 的標記為 \(2\),答案即為 \(\operatorname{find(y)}\)。
時間複雜度為 \(O(n + m)\)。
\(\texttt{Code:}\)
#include <cmath>
#include <vector>
#include <cstring>
#include <iostream>
using namespace std;
const int N = 500010;
typedef pair<int, int> PII;
int n, m, root;
int h[N], e[N << 1], w[N << 1], ne[N << 1], idx;
int dist[N];
int ans[N];
vector<PII> que[N];
int st[N];
int p[N];
int find(int x) {
if(p[x] != x) p[x] = find(p[x]);
return p[x];
}
void add(int a, int b) {
e[idx] = b, ne[idx] = h[a], h[a] = idx++;
}
void add_query(int a, int b, int id) {
que[a].push_back({b, id});
que[b].push_back({a, id});
//注意兩邊都要 push,因為可能在更新其中之一時另一個點未被標記成 2,導致未計算答案
}
void tarjan(int u) {
st[u] = 1;
for(int i = h[u]; ~i; i = ne[i]) {
int j = e[i];
if(st[j]) continue;
tarjan(j);
p[j] = u;
}
for(int i = 0; i < que[u].size(); i++) {
int j = que[u][i].first, id = que[u][i].second;
if(st[j] == 2) ans[id] = find(j);
}
++st[u];
}
int main() {
memset(h, -1, sizeof h);
scanf("%d%d%d", &n, &m, &root);
int a, b;
for(int i = 1; i < n; i++) {
scanf("%d%d", &a, &b);
add(a, b), add(b, a);
}
for(int i = 1; i <= m; i++) {
scanf("%d%d", &a, &b);
if(a != b) add_query(a, b, i);
else ans[i] = a;
}
for(int i = 1; i <= n; i++) p[i] = i;
tarjan(root);
for(int i = 1; i <= m; i++) printf("%d\n", ans[i]);
return 0;
}
P3379 【模板】最近公共祖先(LCA)
\(\texttt{0x02}\) 一些例題
一. 利用樹的性質求 LCA 維護資訊
P8805 [藍橋杯 2022 國 B] 機房
題目大意:
給定一棵樹,\(m\) 次詢問樹上任意兩點的距離。
思路:
在樹上,兩點之間的路徑唯一,即:\(x\) 到 \(y\) 的路徑為 \(x\to lca(x,y)\to y\)。
再加上距離具有結合律,所以我們可以在求 LCA 時順便處理出根節點到所有節點的距離。
這樣對於每個詢問 \((x,y)\),答案為:
再加上一些小細節即可。
\(\texttt{Code:}\)
#include <cmath>
#include <queue>
#include <cstring>
#include <iostream>
using namespace std;
const int N = 100010;
int n, m, T;
int h[N], e[N << 1], ne[N << 1], idx;
int f[N][25], dep[N];
int v[N];
int dist[N];
void add(int a, int b) {
e[idx] = b, ne[idx] = h[a], h[a] = idx++;
}
void bfs(int s) {
queue<int> q;
q.push(s);
dep[s] = 1, dist[s] = v[s];
while(q.size()) {
int t = q.front();
q.pop();
for(int i = h[t]; ~i; i = ne[i]) {
int j = e[i];
if(dep[j]) continue;
dep[j] = dep[t] + 1;
f[j][0] = t;
dist[j] = v[j] + dist[t];
for(int k = 1; k <= T; k++) f[j][k] = f[f[j][k - 1]][k - 1];
q.push(j);
}
}
}
int lca(int x, int y) {
if(dep[x] > dep[y]) swap(x, y);
for(int i = T; i >= 0; i--)
if(dep[f[y][i]] >= dep[x]) y = f[y][i];
if(x == y) return x;
for(int i = T; i >= 0; i--)
if(f[x][i] != f[y][i]) x = f[x][i], y = f[y][i];
return f[x][0];
}
int main() {
memset(h, -1, sizeof h);
scanf("%d%d", &n, &m);
T = (int)log2(n);
int a, b;
for(int i = 1; i < n; i++) {
scanf("%d%d", &a, &b);
add(a, b), add(b, a);
++v[a], ++v[b];
}
bfs(1);
while(m--) {
scanf("%d%d", &a, &b);
int p = lca(a, b);
printf("%d\n", dist[a] + dist[b] - 2 * dist[p] + v[p]);
}
return 0;
}
P5836 [USACO19DEC] Milk Visits S
題目大意:
給定一棵樹,樹上每一個節點都有一個型別為 \(0\) 或 \(1\) 的物品。
\(m\) 次詢問,回答任意兩點之間的路徑上是否有某種物品。
思路:
考慮到倍增 LCA 能預處理出類似於字首和的資料,維護具有結合率的資訊,所以提前處理出根節點到所有點的路徑上兩種物品的數目各是多少,然後用類似求距離的方法維護。
P4427 [BJOI2018] 求和
題目大意:
給定一棵樹,\(m\) 次詢問,回答任意兩點之間的路徑上所有節點深度的 \(k\) 次方和。
思路:
注意到 \(k \le 50\),所以可以把所有的 \(k\) 值都預處理出來,然後維護即可。
注意:為防止對負數取模,在取模之前要加上模數!
二. 樹上差分
P3128 [USACO15DEC] Max Flow P
題目大意:
給定一棵樹,\(m\) 次操作,每次給定 \((x,y)\),覆蓋樹上 \(x\to y\) 的路徑上的點,最後輸出樹上被覆蓋次數最多的節點的被覆蓋次數。
思路:
考慮暴力,對於每個操作 \((x,y)\),求出 \(\operatorname{LCA(x,y)}\),從 \(x\) 走到 \(\operatorname{LCA(x,y)}\),再從 \(\operatorname{LCA(x,y)}\) 走到 \(y\),給經過的節點都加上 \(1\),最後統計最大值。時間複雜度最壞為 \(O(nm)\)。
其實這種操作很像 DS 中的區間加操作,又因為這是個靜態問題,所以可以樹上差分。
樹上差分類似於序列上的差分,想象一下把 \(\operatorname{LCA(x,y)}\to x\) 和 \(\operatorname{LCA(x,y)}\to y\) 拆成兩條鏈,然後左端點 \(+1\),右端點 \(-1\) 即可。
如圖所示:
好醜
這樣操作之後,每個節點的子樹的大小就是該點的被覆蓋次數。
\(\texttt{Code:}\)
#include <cmath>
#include <queue>
#include <cstring>
#include <iostream>
using namespace std;
const int N = 50010;
int n, m, T;
int h[N], e[N << 1], ne[N << 1], idx;
int dep[N];
int f[N][22];
int siz[N], v[N];
int ans;
void add(int a, int b) {
e[idx] = b, ne[idx] = h[a], h[a] = idx++;
}
void bfs(int s) {
queue<int> q;
q.push(s);
dep[s] = 1;
while(q.size()) {
int t = q.front();
q.pop();
for(int i = h[t]; ~i; i = ne[i]) {
int j = e[i];
if(dep[j]) continue;
dep[j] = dep[t] + 1;
f[j][0] = t;
for(int k = 1; k <= T; k++) f[j][k] = f[f[j][k - 1]][k - 1];
q.push(j);
}
}
}
int LCA(int x, int y) {
if(dep[x] > dep[y]) swap(x, y);
for(int i = T; i >= 0; i--)
if(dep[f[y][i]] >= dep[x]) y = f[y][i];
if(x == y) return x;
for(int i = T; i >= 0; i--)
if(f[x][i] != f[y][i]) x = f[x][i], y = f[y][i];
return f[x][0];
}
int dfs(int u, int fa) {
siz[u] = v[u];
for(int i = h[u]; ~i; i = ne[i]) {
int j = e[i];
if(j == fa) continue;
siz[u] += dfs(j, u);
}
return siz[u];
}
int main() {
memset(h, -1, sizeof h);
scanf("%d%d", &n, &m);
T = (int)log2(n);
int a, b;
for(int i = 1; i < n; i++) {
scanf("%d%d", &a, &b);
add(a, b), add(b, a);
}
bfs(1);
while(m--) {
scanf("%d%d", &a, &b);
int lca = LCA(a, b);
++v[a], ++v[b], --v[lca], --v[f[lca][0]];
}
dfs(1, -1);
for(int i = 1; i <= n; i++) ans = max(ans, siz[i]);
printf("%d\n", ans);
return 0;
}
P3258 [JLOI2014] 松鼠的新家
題目大意:
給定一棵樹,要求按順序走完給定的所有點,每移動一步就要給這次移動經過的點增加 \(1\) 的點權,且路徑的終點不增加點權。求每一個點的最小點權。
和上一道題十分相似,只需注意最後一個點的 \(siz\) 要減一。
P6869 [COCI2019-2020#5] Putovanje
題目大意:
求按節點編號順序遍歷一棵樹的最小費用,邊權分成單程票和多程票兩種。
思路:
把每條邊算作附屬於它下面的點(深度更大的點),然後用樹上差分求出每條邊的經過次數,比較單程票和多程票費用。
只需注意處理每條邊在附屬過後在原來費用陣列中的位置即可。
三. 樹上問題分類討論
P3398 倉鼠找 sugar
很有意思的一道分討題。
題目大意:
給定一棵樹,\(m\) 次詢問 \((a,b,c,d)\),回答 \(a\to b\) 與 \(c\to d\) 是否相交。
先將兩條路徑拆開,得到 \(4\) 條鏈:
不難看出,這兩條路徑相交當且僅當這 \(4\) 條鏈有兩條相交。
(1) ① 與 ③ 相交
如圖:
(2) ① 與 ④ 相交
如圖:
(3) ② 與 ③ 相交
如圖:
(4) ② 與 ④ 相交
如圖:
最後綜合一下就能寫出 \(\operatorname{check}\) 函式。
inline bool check(int a, int b, int c, int d) {
int x = lca(a, b), y = lca(c, d), p1 = lca(a, c), p2 = lca(a, d), p3 = lca(b, c), p4 = lca(b, d);
if(lca(p1, d) == y && lca(p1, b) == x) return true;
if(lca(p2, c) == y && lca(p2, b) == x) return true;
if(lca(p3, d) == y && lca(p3, a) == x) return true;
if(lca(p4, c) == y && lca(p4, a) == x) return true;
return false;
}
P4281 [AHOI2008] 緊急集合 / 聚會
題目大意:
給定一棵樹,\(m\) 次詢問,每次 \(3\) 個點 \((x,y,z)\),回答與這 \(3\) 個點距離和最小的點及距離和。
首先思考什麼點是距離和最小的點。
不難發現,如果隨便選一個點,那麼有些邊可能要重複走幾遍,而如果選擇三個點互相通達的簡單路徑上的一個點,那麼就沒有邊被重複走過。
直接講有點抽象,如圖:
若選擇 \(2\),則 \(2-3\) 這條邊會被走 \(2\) 次,不是最短。
若選擇 \(3\),則所有邊都只會走一次,此時為最短。
而 \(3\) 就在三個點互相通達的簡單路徑上。
多畫幾個圖,總結出:選擇三個點 LCA 中深度最大的那個點是最優的。
此時最小距離為:
四. LCA 綜合運用
P1967 [NOIP2013 提高組] 貨車運輸
題目大意:
給定一張無向圖,\(m\) 次詢問,每次詢問 \(x\) 到 \(y\) 的所有路徑中最小的那條邊的邊權最大是多少。
根據貪心思想,我們肯定優先選擇邊權大的邊走,這啟示我們可以先求一遍原無向圖的最大生成樹,去掉永遠也不會走過的邊。
利用 \(\texttt{kruskal}\) 演算法得到原無向圖的一個最大生成森林。若 \(x\) 和 \(y\) 不在一個連通塊,就直接輸出 \(-1\)。
否則就轉化成了樹上問題,等價於求兩點之間路徑中的邊權最小值,默寫模板即可。
\(\texttt{Code:}\)
#include <queue>
#include <cmath>
#include <vector>
#include <cstring>
#include <iostream>
#include <algorithm>
using namespace std;
const int N = 10010, M = 25, E = 50010;
typedef long long ll;
typedef pair<int, int> PII;
int n, m, q, T;
int h[N], e[N << 1], ne[N << 1], w[N << 1], idx;
int f[N][M], dep[N];
int mind[N][M];
struct node{
int a, b, w;
bool operator < (const node &o) const {
return w > o.w;
}
}edges[M];
int p[N];
int cnt;
vector<int> uni[N];
int v[N];
void add(int a, int b, int c) {
e[idx] = b, ne[idx] = h[a], w[idx] = c, h[a] = idx++;
}
int find(int x) {
if(p[x] != x) p[x] = find(p[x]);
return p[x];
}
void kruskal() {
for(int i = 1; i <= n; i++) p[i] = i;
sort(edges + 1, edges + m + 1);
for(int i = 1; i <= m; i++) {
int a = edges[i].a, b = edges[i].b, w = edges[i].w;
int x = find(a), y = find(b);
if(x != y) {
p[x] = y;
add(a, b, w), add(b, a, w);
}
}
}
void dfs(int u) {
v[u] = cnt;
uni[cnt].push_back(u);
for(int i = h[u]; ~i; i = ne[i]) {
int j = e[i];
if(v[j]) continue;
dfs(j);
}
}
void bfs(int s) {
queue<int> q;
q.push(s);
for(int i = 1; i <= n; i++) {
if(dep[i]) continue;
for(int j = 0; j <= T; j++)
mind[i][j] = 0x3f3f3f3f;
}
dep[s] = 1;
while(q.size()) {
int t = q.front();
q.pop();
for(int i = h[t]; ~i; i = ne[i]) {
int j = e[i];
if(dep[j]) continue;
dep[j] = dep[t] + 1;
f[j][0] = t;
mind[j][0] = w[i];
// printf("------%d\n", mind[j][0]);
for(int k = 1; k <= T; k++) {
f[j][k] = f[f[j][k - 1]][k - 1];
mind[j][k] = min(mind[j][k - 1], mind[f[j][k - 1]][k - 1]);
}
q.push(j);
}
}
}
int lca(int x, int y) {
int res = 0x3f3f3f3f;
if(dep[x] > dep[y]) swap(x, y);
for(int i = T; i >= 0; i--)
if(dep[f[y][i]] >= dep[x]) {
res = min(res, mind[y][i]);
y = f[y][i];
}
if(x == y) return res;
for(int i = T; i >= 0; i--)
if(f[x][i] != f[y][i]) {
res = min(res, min(mind[x][i], mind[y][i]));
x = f[x][i], y = f[y][i];
}
res = min(res, min(mind[x][0], mind[y][0]));
return res;
}
int main() {
memset(h, -1, sizeof h);
scanf("%d%d", &n, &m);
T = (int)log2(n);
int a, b, c;
for(int i = 1; i <= m; i++) {
scanf("%d%d%d", &a, &b, &c);
edges[i] = {a, b, c};
}
kruskal();
for(int i = 1; i <= n; i++)
if(!v[i]) {
cnt++;
dfs(i);
}
for(int i = 1; i <= cnt; i++) bfs(uni[i][0]);
scanf("%d", &q);
while(q--) {
scanf("%d%d", &a, &b);
if(v[a] != v[b]) puts("-1");
else {
printf("%d\n", lca(a, b));
}
}
return 0;
}