最近公共祖先

Iter-moon發表於2024-06-18

公共祖先: 在一棵有根樹上,若節點 \(F\) 是節點 \(x\) 的祖先,也是節點 \(y\) 的祖先,那麼稱 \(F\)\(x\)\(y\) 的公共祖先。

最近公共祖先(LCA):\(x\)\(y\) 的所有公共祖先中,深度最大的稱為最近公共祖先,記為 \(LCA(x,y)\)

image

LCA 顯然有以下性質。

  1. 在所有公共祖先中,\(LCA(x,y)\)\(x\)\(y\) 的距離都是最短的。例如,在 \(e\)\(g\) 的所有祖先中,\(c\) 距離更短。
  2. \(x\)\(y\) 之間最短的路徑經過 \(LCA(x,y)\)。例如,從 \(e\)\(g\) 的最短路徑經過 \(c\)
  3. \(x\)\(y\) 本身也可以是它們自己的公共祖先。若 \(y\)\(x\) 的祖先,則有 \(LCA(x,y)=y\),如圖中 \(d=lca(d,h)\)

如何求 LCA?根據 LCA 的定義,很容易想到一個簡單直接的方法:分別從 \(x\)\(y\) 出發,一直向根節點走,第一次相遇的節點就是 \(LCA(x,y)\)。具體實現時,可以用標記法:首先從 \(x\) 出發一直向根節點走,沿路標記所有經過的祖先節點;把 \(x\) 的祖先標記完之後,然後再從 \(y\) 出發向根節點走,走到第一個被 \(x\) 標記的節點,就是 \(LCA(x,y)\)。標記法的時間複雜度較高,在有 \(n\) 個節點的樹上求一次 \(LCA(x,y)\) 的時間複雜度為 \(O(n)\)。若有 \(m\) 次查詢,總的時間複雜度為 \(O(mn)\),效率太低。

倍增法求 LCA

可以把標記法換一種方式實現,分為以下兩個步驟。

  1. 先把 \(x\)\(y\) 提到相同的深度。例如,\(x\)\(y\) 深,就把 \(x\) 提到 \(y\) 的高度(既讓 \(x\) 走到 \(y\) 的同一高度),如果發現 \(x\) 直接就跳到 \(y\) 的位置上了,那麼就停止查詢,否則繼續下一步。
  2. \(x\)\(y\) 繼續同步向上走,每走一步就判斷是否相遇,相遇點就是 \(LCA(x,y)\) 停止。

上面的兩個步驟,如果 \(x\)\(y\) 都一步一步向上走,時間複雜度為 \(O(n)\)。如何改進?如果不是一步步走,而是跳著走,就能加快速度。如何跳?可以按 \(2\) 的倍數向上跳,即跳 \(1,2,4,8,\cdots\) 步,這就是倍增法,倍增法用“跳”的方法加快了上述兩個步驟。

步驟 1

\(x\)\(y\) 提到相同的深度。具體任務是:給定兩個節點 \(x\)\(y\),設 \(x\)\(y\) 深,讓 \(x\) “跳”到與 \(y\) 相同的深度。

因為已知條件是隻知道每個節點的父節點,所以如果沒有其他輔助條件,\(x\) 只能一步步向上走,沒法“跳”。要實現“跳”的動作,必須提前計算出一些 \(x\) 的祖先節點,作為 \(x\) 的“跳板”。然而,應該提前計算出哪些祖先節點呢?如何透過這些預計算出的節點準確且高效地跳到一個任意給定的 \(y\) 的深度?這就是倍增法的精妙之處:預計算出每個節點的第 \(1,2,4,8,16,\cdots\) 個祖先,即按 \(2\) 倍增的那些祖先。

有了預計算出的這些祖先做跳板,能從 \(x\) 快速跳到任何一個給定的目標深度。以從 \(x\) 跳到它的第 \(27\) 個祖先為例:

  1. \(x\)\(16\) 步,到達 \(x\) 的第 \(16\) 個祖先 \(fa_1\)
  2. \(fa_1\)\(8\) 步,到達 \(fa_1\) 的第 \(8\) 個祖先 \(fa_2\)
  3. \(fa_2\)\(2\) 步到達祖先 \(fa_3\)
  4. \(fa_3\)\(1\) 步到達祖先 \(fa_4\)

共跳了 \(16+8+2+1=27\) 步,這個方法利用了二進位制的特徵:任何一個數都可以由 \(2\) 的倍數相加得到。\(27\) 的二進位制是 \(11011\),其中的 \(4\)\(1\) 的權值就是 \(16,8,2,1\)

顯然,用倍增法從 \(x\) 跳到某個 \(y\) 的時間複雜度為 \(O(\log n)\)

剩下的問題是如何快速預計算每個節點的“倍增”的祖先。定義 \(fa_{x,i}\)\(x\) 的第 \(2^i\) 個祖先,有非常巧妙的遞推關係:\(fa_{x,i}=fa_{fa_{x,i-1},i-1}\)。分兩步理解:\(fa_{x,i-1}\)\(x\) 起跳,先跳 \(2^{i-1}\) 步,記這個點為 \(z\);再從 \(z\)\(2^{i-1}\) 步,一共跳了 \(2^{i-1}+2^{i-1}=2^i\) 步。

特別地,\(fa_{x,0}\)\(x\) 的第 \(2^0=1\) 個祖先,就是 \(x\) 的父節點。\(fa_{x,0}\) 是遞推式的初始條件,從它開始遞推出了所有的 \(fa_{x,i}\)。遞推的計算量有多大?從任意節點 \(x\) 到根節點,最多隻有 \(\log n\) 個祖先,所以只需要遞推 \(O(\log n)\) 次。所以整個 \(fa\) 的計算時間複雜度為 \(O(n \log n)\)

步驟 2

經過上一個步驟,\(x\)\(y\) 現在位於同一深度,讓它們同步向上跳,就能找到它們的公共祖先。\(x\)\(y\) 的公共祖先有很多,LCA(x, y) 是距離 \(x\)\(y\) 最近的那個,其他祖先都更遠。

從一個節點跳到根節點,最多跳 \(\log n\) 次。現在從 \(x,y\) 出發,從最大的 \(i \approx \log n\) 開始,跳 \(2^i\) 步,分別跳到 \(fa_{x,i},fa_{y,i}\),它們位於非常靠近根節點的位置(\(2^i \approx n\)),有以下兩種情況:

  1. \(fa_{x,i}=fa_{y,i}\),這是一個公共祖先,它的深度小於或等於 LCA(x, y),這說明跳過頭了,退回去換一個小的 \(i-1\) 重新跳一次。
  2. \(fa_{x,i} \ne fa_{y,i}\),說明還沒跳到公共祖先,那麼更新 \(x \leftarrow fa_{x,i}, y \leftarrow fa_{y,i}\),從新的起點 \(x,y\) 繼續開始跳。由於新的 \(x,y\) 的深度比原來位置的深度減少超過一半,再跳時就不用跳 \(2^i\) 步,跳 \(2^{i-1}\) 步就夠了。

以上兩種情況,分別是比 LCA(x, y) 淺和深的兩種位置。用 \(i\) 迴圈判斷以上兩種情況,就是從深淺兩側逐漸逼近 LCA(x, y)。每迴圈一次,\(i\) 減一,當 \(i\) 減為 \(0\) 時,\(x\)\(y\) 正好位於 LCA(x,y) 的下一層,則 \(fa_{x,0}\) 就是 LCA(x,y)。

查詢一次 LCA 的時間複雜度是多少?這裡的 \(i\) 會從 \(\log n\) 遞減到 \(0\),迴圈 \(O(\log n)\) 次。

倍增法的總計算量包括預計算 \(fa\) 和查詢 \(m\) 次 LCA,總時間複雜度為 \(O(n \log n + m \log n)\)

例題:P3379 【模板】最近公共祖先(LCA)

#include <cstdio>
#include <vector>
#include <algorithm>
using namespace std;
const int N = 500005;
const int LOG = 19;
vector<int> tree[N];
int depth[N], fa[N][LOG];
void dfs(int cur, int pre) { 
    depth[cur] = depth[pre] + 1; // 深度比父節點深度多1
    for (int nxt : tree[cur]) { // 遍歷所有鄰居節點
        if (nxt != pre) { // 除了父節點以外都是子節點
            fa[nxt][0] = cur; // 記錄父節點fa[][0]
            dfs(nxt, cur);
        }
    }
}
int lca(int x, int y) {
    if (depth[x] < depth[y]) swap(x, y); // 保證x深度更大
    // 將x和y提到相同高度
    int delta = depth[x] - depth[y];
    for (int i = LOG - 1; i >= 0; i--) 
        if (delta & (1 << i)) x = fa[x][i];
    if (x == y) return x; // 如果提到相同深度後已經重合,則直接返回
    // x和y同步往上跳
    for (int i = LOG - 1; i >= 0; i--) 
        if (fa[x][i] != fa[y][i]) { // 如果祖先相等,說明跳過頭了,換一個小的i繼續嘗試
            x = fa[x][i]; y = fa[y][i]; // 如果祖先不相等,就更新x和y繼續跳
        }
    // 最終x和y位於LCA的下一層,此時x或y的父節點就是LCA
    return fa[x][0]; 
}
int main()
{
    int n, m, s; scanf("%d%d%d", &n, &m, &s);
    for (int i = 1; i < n; i++) {
        int x, y; scanf("%d%d", &x, &y);
        // 建樹
        tree[x].push_back(y); tree[y].push_back(x);
    }
    dfs(s, 0); // 預處理深度等資訊
    for (int i = 1; i < LOG; i++)
        for (int j = 1; j <= n; j++)
            fa[j][i] = fa[fa[j][i - 1]][i - 1]; // 從fa[][0]開始遞推
    while (m--) {
        int a, b; scanf("%d%d", &a, &b); printf("%d\n", lca(a, b));
    }
    return 0;
}

例題:P4180 [BJWC2010] 嚴格次小生成樹

設一張圖的最小生成樹邊權之和為 \(S\),則該圖的嚴格次小生成樹定義為該圖所有邊權之和大於 \(S\) 的生成樹中邊權之和最小者(可能不存在,也可能存在多棵)。
現給出一張 \(n\) 個點,\(m\) 條邊的無向圖,邊權為 \(w_i\),求出該圖的嚴格次小生成樹邊權之和。資料保證原圖存在嚴格次小生成樹。
資料範圍:\(n \le 10^5, m \le 3 \times 10^5, 0 \le w_i \le 10^9\)

分析:一種簡單的思路是嘗試找到原圖的所有生成樹,然後透過比較得出答案。但由於生成樹數量過多,這樣的演算法顯然效率很低。

由於嚴格次小生成樹的邊權和僅大於最小生成樹邊權和,因此可以猜測,嚴格次小生成樹很可能就是在最小生成樹上替換一條或幾條邊得到。事實上,可以證明,一定存在一棵嚴格次小生成樹,使得它與某棵最小生成樹僅有一條邊的差距。

考慮一條不在原來的最小生成樹上的邊,如果把它加入最小生成樹後會形成一個環,顯然這個環上其他邊的邊權都小於等於剛加的這條邊的邊權(不然一開始的最小生成樹就不成立了)。更進一步,這裡可以把等於的情況去掉,因為如果存在等於的情況,說明是另一棵邊權和相等但樹的形態不同的最小生成樹。所以如果嚴格次小生成樹和最小生成樹之間有兩條以上的邊不同,那麼我們可以把這些不同的邊中的其中一條改為在最小生成樹上的邊,剩下的不變,則此時得到的生成樹邊權和變小了,但還是比最小生成樹的邊權和要大。由此得知,最多隻選 \(1\) 條邊做替換。

有了這個性質,就可以考慮在建完最小生成樹之後尋找那條不屬於最小生成樹,但屬於嚴格次小生成樹的邊。列舉每一條非樹邊,在加入這條邊之後,生成樹上出現了一個環,再斷掉環中其他邊(環中其他的邊實際上就是這條非樹邊的兩點在最小生成樹中的路徑)裡面邊權最大的邊(若該邊邊權與環內其他邊權最大者相等,則斷掉邊權中嚴格次大的,注意有可能不存在這樣的嚴格次大邊),那麼就得到了包含這條邊的生成樹中權值最小的。將所有這樣的生成樹權值和取 \(\min\) 後,就可以得到最終的答案。

可以採用樹上倍增的方法,定義 \(1\) 為根,並儲存每個點向上 \(2^i\) 條邊的最大值與嚴格次大值。在尋找時,透過倍增取出這些最大值與嚴格次大值並依次進行更新,就可以得到需要斷的邊的權值。注意在儲存和尋找嚴格次大值時的分類討論條件。

整個演算法的時間複雜度為 \(O(n \log n + m \log n)\)

#include <cstdio>
#include <algorithm>
#include <vector>
using std::sort;
using std::swap;
using std::min;
using std::max;
using std::vector;
typedef long long LL;
const int N = 1e5 + 5;
const int M = 3e5 + 5;
const int LOG = 17;
const LL INF = 1e15;
struct Edge {
    int x, y, z;
};
Edge edges[M];
bool mst[M]; // 記錄每條邊是否是最小生成樹上的邊
vector<Edge> tree[N];
// root用於並查集
// depth儲存節點深度
// fa/w1/w2[u][i]代表節點u向上2的i次方層祖先/邊權最大值/邊權次大值
int root[N], depth[N], fa[N][LOG], w1[N][LOG], w2[N][LOG];
int query(int x) {
    return root[x] == x ? x : root[x] = query(root[x]);
}
// update函式實現對兩組最大、次大值合併得到新的最大、次大值
void update(int& mx1, int& mx2, int a1, int a2, int b1, int b2) {
    mx1 = max(a1, b1);
    mx2 = a1 == b1 ? max(a2, b2) : max(min(a1, b1), max(a2, b2));
}
void dfs(int u, int pre) {
    depth[u] = depth[pre] + 1;
    fa[u][0] = pre;
    for (Edge e : tree[u]) {
        int v = e.y, w = e.z;
        if (v == pre) continue;
        w1[v][0] = w;
        dfs(v, u);
    }
}
LL lca(int x, int y, int w, LL sum) {
    // 在倍增法求lca的過程中實現最大邊權和次大邊權的計算
    if (depth[x] < depth[y]) swap(x, y);
    int delta = depth[x] - depth[y];
    int res1 = 0, res2 = 0; // 最大、嚴格次大
    for (int i = LOG - 1; i >= 0; i--) 
        if (delta & (1 << i)) {
            update(res1, res2, res1, res2, w1[x][i], w2[x][i]);
            x = fa[x][i];
        }
    if (x == y) {
        // 有可能加的非樹邊與環內其他最長邊相等
        // 也有可能環內不存在次長邊
        if (res1 == w) return res2 == 0 ? INF : sum + w - res2;
        else return res1 == 0 ? INF : sum + w - res1;
    }
    int tmp1 = 0, tmp2 = 0;
    for (int i = LOG - 1; i >= 0; i--) 
        if (fa[x][i] != fa[y][i]) {
            update(tmp1, tmp2, w1[x][i], w2[x][i], w1[y][i], w2[y][i]);
            update(res1, res2, res1, res2, tmp1, tmp2);
            x = fa[x][i]; y = fa[y][i];
        }
    update(tmp1, tmp2, w1[x][0], w2[x][0], w1[y][0], w2[y][0]);
    update(res1, res2, res1, res2, tmp1, tmp2);
    // 有可能加的非樹邊與環內其他最長邊相等
    // 也有可能環內不存在次長邊
    if (res1 == w) return res2 == 0 ? INF : sum + w - res2;
    else return res1 == 0 ? INF : sum + w - res1;
}
int main()
{
    int n, m; scanf("%d%d", &n, &m);
    for (int i = 1; i <= n; i++) root[i] = i;
    for (int i = 1; i <= m; i++) {
        scanf("%d%d%d", &edges[i].x, &edges[i].y, &edges[i].z);
    }
    // 先求一棵最小生成樹
    sort(edges + 1, edges + m + 1, [](Edge& e1, Edge& e2) { 
        return e1.z < e2.z;
    });
    LL sum = 0;
    for (int i = 1; i <= m; i++) {
        int x = edges[i].x, y = edges[i].y, z = edges[i].z;
        int qx = query(x), qy = query(y);
        if (qx != qy) {
            root[qx] = qy; sum += z; mst[i] = true;
            tree[x].push_back({x, y, z});
            tree[y].push_back({y, x, z});
        }
    }
    dfs(1, 0);
    for (int i = 1; i < LOG; i++) 
        for (int j = 1; j <= n; j++) {
            // 預處理倍增表
            fa[j][i] = fa[fa[j][i - 1]][i - 1];
            int a1 = w1[j][i - 1], a2 = w2[j][i - 1];
            int b1 = w1[fa[j][i - 1]][i - 1], b2 = w2[fa[j][i - 1]][i - 1];
            update(w1[j][i], w2[j][i], a1, a2, b1, b2);
        }
    LL ans = INF;
    for (int i = 1; i <= m; i++) {
        int x = edges[i].x, y = edges[i].y, z = edges[i].z;
        if (x == y) continue;
        if (!mst[i]) ans = min(ans, lca(x, y, z, sum));
    }
    printf("%lld\n", ans);
    return 0;
}

相關文章