樹上公共祖先(LCA)

Brilliant11001發表於2024-07-23

\(\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\) 輩祖先,根據動態規劃的思想,則可以得到狀態轉移方程:

\[f[x][k] = f[f[x][k - 1]][k - 1] \]

其中 \(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\) 類。

  1. 已經訪問且回溯的節點。這些節點標記為 \(2\)
  2. 已經訪問過但還沒回溯的節點,此時這些節點就是正在訪問的節點 \(x\)\(x\) 的祖先。這些節點標記為 \(1\)
  3. 尚未訪問的節點。這些節點標記為 \(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)\),答案為:

\[dist[x] + dist[y] - 2 * dist[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\) 即可。

如圖所示:

\[\Huge\Downarrow \]

好醜

這樣操作之後,每個節點的子樹的大小就是該點的被覆蓋次數。

\(\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\) 條鏈:

\[a\to \operatorname{LCA(a,b)}--------- ① \]

\[\operatorname{LCA(a,b)}\to b---------② \]

\[c\to \operatorname{LCA(c,d)}---------③ \]

\[\operatorname{LCA(c,d)}\to d---------④ \]

不難看出,這兩條路徑相交當且僅當這 \(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 中深度最大的那個點是最優的。

此時最小距離為:

\[dist[x] + dist[y] + dist[z] + dist[\text{最深 LCA}] - 2 * dist[\text{最淺 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;
}

相關文章