1. 程式人生 > 其它 >圖論專題-學習筆記:虛樹

圖論專題-學習筆記:虛樹

目錄

1. 前言

虛樹,主要是用於一類樹上問題,這類問題通常是需要取一些關鍵點,然後要在這些關鍵點和其 LCA 上做一些奇怪的玩意。

關鍵前置知識:LCA。

2. 詳解

2.1 虛樹定義

首先我們需要知道虛樹是什麼:

現在給出一棵 \(n\) 個點的樹,從中選取出 \(k\) 個關鍵點,這些關鍵點以及其兩兩的 LCA 構成了對於這些關鍵點而言的虛樹,特別注意如果取出來的樹不連通的話我們還需要一個根節點來連通。

比如說對於下面這棵樹:


如果取 \(7,8,10,11,14\) 這五個點作為關鍵點,那麼對於這五個點的虛樹是這樣的:


其中加粗的節點表示我們需要的 LCA,剩下的就是 5 個關鍵點。

我們發現構建虛樹之後,一些無用 / 干擾節點都被我們刪去了,這樣對開頭所述的問題有不錯的解決效果。

2.2 虛樹構造

接下來詳細講一下虛樹的構造。

一般的虛樹構造是使用單調棧來構造的,也是相對比較大眾的構造方法(包括日報),這裡簡要闡述一下:

  1. 首先將所有關鍵點按照 DFS 序升序排序,如果關鍵點中沒有做 LCA 時指定的根節點我們需要人為加入這個點。
  2. 然後一個一個插入,採用一個對深度單調遞增的單調棧維護,每次彈棧的時候將棧內第二個點與棧頂連邊,然後彈出。
  3. 設當前插入節點為 \(x\),棧頂節點為 \(y\),其 LCA 為 \(z\)
    ,那麼當彈棧彈到深度小於 \(z\) 的時候就可以停止了。
  4. 這裡需要注意,如果當前棧頂不是 \(z\) 的話我們需要將 \(z\) 丟進棧內,同時如果棧頂深度小於 \(z\) 我們也要從棧頂向 \(z\) 連邊。
  5. 最後加入這個點即可。
  6. 上述所有關鍵點加入完畢之後,一起彈棧,重複執行 2 步驟。

上述方法更加具體的步驟與正確性證明見參考資料 - 1 中的日報。

這邊我學的是另外一種更加簡潔的方法:

  1. 首先將所有關鍵點按照 DFS 序升序排序,設該關鍵點序列為 \(\{a_n\}\)
  2. 對於所有 \(i \in [2,n]\),如果 \(a_i\)\(a_{i-1}\) 的 LCA 不是他們中的任意一個,意味著這個點也是要加入序列裡面的,丟到 \(\{a_n\}\)
    的末尾,這樣我們得到了一個新的序列 \(\{a_n\}\)
  3. 對該序列去重之後重新按照 DFS 序升序排序。
  4. 最後對於所有 \(i \in [2,n]\)\(a_i\) 的父親就是 \(a_i\)\(a_{i-1}\) 的 LCA,根節點就是 \(a_1\)

這個方法看起來很簡單但是很迷,我並沒有找到證明過程,於是自己口胡了一個,有誤請指出:

  • 首先根據 2 操作,我們已經將所有虛樹上有的節點丟到 \(\{a_n\}\) 裡面了。
  • 由 LCA 的性質,如果設 \(z\)\(a_i,a_{i-1}\) 的 LCA,那麼 \(dfn_z \leq \min \{dfn_{a_i},dfn_{a_{i-1}}\}\),3 操作之後這個式子簡化成了 \(dfn_z \leq dfn_{a_{i-1}} < dfn_{a_i}\)(後面這個小於號是因為我們去重了),且 \(z\) 是一定在 \(\{a_n\}\) 裡面的。
  • 於是我們就可以證明,對於任意 \(a_i \mid i \geq 2\),其虛樹上的父親也在序列裡面,且在 \(a_i\) 的前面而且一定不會與 \(a_i\) 相等。

現在分析兩個方法的時間複雜度:

  • 由於每個人的求 LCA 的方式並不一樣,這裡我採用的是 \(O(n \log n) - O(\log n)\) 的倍增求 LCA 的方法,下面的涉及到 LCA 的複雜度都以該複雜度為準。
  • 注意我喜歡先帶常數分析複雜度(雖然是錯的),分析完之後再按照複雜度的定義去掉常數。

\(k\) 是關鍵點個數。

方法 1 的時間複雜度:

  1. 預處理 LCA 複雜度 \(O(n \log n)\)
  2. 升序排序複雜度 \(O(k \log k)\)
  3. 在插入一個點的時候求 LCA 複雜度 \(O(\log n)\),單調棧複雜度會單獨計算。
  4. 插入所有點求 LCA 複雜度是 \(O(k \log n)\),單調棧複雜度單獨是 \(O(2(k+p))\)(所有點進出棧各一次),其中 \(p\) 表示這些點的 LCA 的數量。為分析方便,取最壞情況(該情況取不到,但是可以拿來分析複雜度)\(p=k\),則單調棧複雜度是 \(O(4k)\)
  5. 因此該方法總複雜度是 \(O(n \log n + k (4 + \log k))\),不記預處理複雜度情況下忽略常數後可化簡為 \(O(k \log k)\)

方法 2 的時間複雜度:

  1. 預處理 LCA 複雜度 \(O(n \log n)\)
  2. 升序排序複雜度 \(O(k \log k)\)
  3. 對於加入 LCA 的操作而言,這塊的複雜度也是 \(O(k \log k)\)
  4. 去重 + 重新排序複雜度是 \(O(2k \log (2k) + (k + p) \log (k + p))\)\(p\) 跟方法一定義相同,最壞情況依然是 \(O(4k \log (2k))\)
  5. 然後建樹過程複雜度是 \(O(2k \log 2k)\)
  6. 因此該方法總複雜度是 \(O(n \log n + k (\log k + 6 \log (2k))\),不記預處理複雜度情況下忽略常數後可化簡為 \(O(k \log k)\)

發現兩種方法的複雜度化簡後是一樣的,未化簡前複雜度是方法 2 高一點,但是在 OI 中方法 2 比方法 1 寫的更快,而且不會被卡(真的會有人卡你這玩意嗎?),不容易寫錯(我是這麼認為的),價效比較高。

需要注意的是,方法一建虛樹的時候為了方便,通常會將 1 號點放入關鍵點序列中,但是方法二並不會,因此有些題採用方法 2 的時候需要特別注意根節點是否為 1

2.3 例題

例題:CF613D Kingdom and its Cities

首先發現題目需要判無解情況,無解情況只可能是存在兩個關鍵點之間有連邊。

然後考慮有解情況,發現如果要斷開兩個關鍵點一定是 LCA 處最優,所以我們需要的點只有關鍵點和 LCA。

於是我們可以考慮先建虛樹,然後這道題就變成了一道簡單的樹形 DP(或者不是)。

考慮對於所有的關鍵點,先將其 Size 設為 1,然後對一個點,這個點的貢獻按 Size 分類:

  1. 如果 Size 為 0,考慮統計其直接兒子當中有幾個點 Size 為 1(設為 \(x\)),如果 \(x=1\),那麼直接將這個點 Size 置為 1 即可,如果大於 1,那麼就斷掉這個點,貢獻為 1。
  2. 如果 Size 為 1,考慮統計其直接兒子中有幾個 Size 為 1,有幾個答案就加幾,因為我們隨便斷掉一個點即可。
    這個地方存在直接兒子有 Size 為 1 是因為有些兒子的 Size 是其子樹內傳上來的,由於我們已經判了無解情況,所以這個做法是對的。

其實這道題去掉建虛樹好像就沒什麼了()

以後我程式碼還是不放 GitHub 上了,因為這玩意實在是太卡了,但是還是會同步更新的。

GitHub:CodeBase-of-Plozia

Code:

/*
========= Plozia =========
    Author:Plozia
    Problem:CF613D Kingdom and its Cities
    Date:2021/11/29
========= Plozia =========
*/

#include <bits/stdc++.h>

typedef long long LL;
const int MAXN = 1e5 + 5;
int n, q, sta[MAXN], Top, ask[MAXN << 1], fa[MAXN][21], dep[MAXN], Size[MAXN], dfn[MAXN], ans;
struct Graph
{
    int cnt_Edge = 1, Head[MAXN];
    struct node { int to, Next; } Edge[MAXN << 1];
    void add_Edge(int x, int y) { ++cnt_Edge; Edge[cnt_Edge] = (node){y, Head[x]}; Head[x] = cnt_Edge; }
}og, ng;

int Read()
{
    int sum = 0, fh = 1; char ch = getchar();
    for (; ch < '0' || ch > '9'; ch = getchar()) fh -= (ch == '-') << 1;
    for (; ch >= '0' && ch <= '9'; ch = getchar()) sum = sum * 10 + (ch ^ 48);
    return sum * fh;
}
int Max(int fir, int sec) { return (fir > sec) ? fir : sec; }
int Min(int fir, int sec) { return (fir < sec) ? fir : sec; }
bool cmp(const int &fir, const int &sec) { return dfn[fir] < dfn[sec]; }

void dfs(int now, int father)
{
    fa[now][0] = father; dep[now] = dep[father] + 1; ++dfn[0]; dfn[now] = dfn[0];
    for (int i = og.Head[now]; i; i = og.Edge[i].Next)
    {
        int u = og.Edge[i].to;
        if (u == father) continue ;
        dfs(u, now);
    }
}

void init()
{
    for (int j = 1; j <= 20; ++j)
        for (int i = 1; i <= n; ++i)
            fa[i][j] = fa[fa[i][j - 1]][j - 1];
}

int LCA(int x, int y)
{
    if (dep[x] < dep[y]) std::swap(x, y);
    for (int i = 20; i >= 0; --i)
        if (dep[fa[x][i]] >= dep[y]) x = fa[x][i];
    if (x == y) return x;
    for (int i = 20; i >= 0; --i)
        if (fa[x][i] != fa[y][i]) x = fa[x][i], y = fa[y][i];
    return fa[x][0];
}

void Calc(int now)
{
    if (Size[now])
    {
        for (int i = ng.Head[now]; i; i = ng.Edge[i].Next)
        {
            int u = ng.Edge[i].to; Calc(u);
            if (Size[u]) { Size[u] = 0; ++ans;}
        }
    }
    else
    {
        int sum = 0;
        for (int i = ng.Head[now]; i; i = ng.Edge[i].Next)
        {
            int u = ng.Edge[i].to; Calc(u);
            if (Size[u]) ++sum; Size[u] = 0;
        }
        if (sum == 1) Size[now] = 1;
        else if (sum > 1) ++ans;
    }
    ng.Head[now] = 0;
}

int main()
{
    n = Read();
    for (int i = 1; i < n; ++i)
    {
        int x = Read(), y = Read();
        og.add_Edge(x, y); og.add_Edge(y, x);
    }
    dfs(1, 1); init(); q = Read();
    for (int i = 1; i <= q; ++i)
    {
        int k = Read(); ans = 0;
        for (int j = 1; j <= k; ++j) Size[ask[j] = Read()] = 1;
        bool flag = 0;
        for (int j = 1; j <= k; ++j)
            if (ask[j] != 1 && Size[fa[ask[j]][0]]) flag = 1;
        if (flag)
        {
            for (int j = 1; j <= k; ++j) Size[ask[j]] = 0;
            puts("-1"); continue ;
        }
        //開始建虛樹
        std::sort(ask + 1, ask + k + 1, cmp); ask[0] = k;
        for (int j = 2; j <= k; ++j)
        {
            int l = LCA(ask[j - 1], ask[j]);
            if (l != ask[j - 1] && l != ask[j]) ask[++ask[0]] = l;
        }
        k = ask[0]; std::sort(ask + 1, ask + k + 1);
        k = std::unique(ask + 1, ask + k + 1) - (ask + 1);
        std::sort(ask + 1, ask + k + 1, cmp);
        for (int j = 2; j <= k; ++j)
        {
            int l = LCA(ask[j - 1], ask[j]);
            ng.add_Edge(l, ask[j]);
        }
        //建完了
        Calc(ask[1]); ng.cnt_Edge = 1; printf("%d\n", ans);
        for (int j = 1; j <= k; ++j) Size[ask[j]] = 0;
    }
    return 0;
}

3. 總結

虛樹實際上就是將一棵樹剔除不關鍵點的演算法,本質上剝離出來後就是在一棵樹上做一些演算法。

其實我認為平常的樹上問題就是一種所有點都是關鍵點的虛樹吧。

4. 參考資料

  1. 【洛穀日報#185】淺談虛樹 - SSerxhs 的部落格
  2. 虛樹入門 - Pils - 部落格園
  3. CF613D Kingdom and its Cities 洛谷全部題解