圖論專題-學習筆記:虛樹
1. 前言
虛樹,主要是用於一類樹上問題,這類問題通常是需要取一些關鍵點,然後要在這些關鍵點和其 LCA 上做一些奇怪的玩意。
關鍵前置知識:LCA。
2. 詳解
2.1 虛樹定義
首先我們需要知道虛樹是什麼:
現在給出一棵 \(n\) 個點的樹,從中選取出 \(k\) 個關鍵點,這些關鍵點以及其兩兩的 LCA 構成了對於這些關鍵點而言的虛樹,特別注意如果取出來的樹不連通的話我們還需要一個根節點來連通。
比如說對於下面這棵樹:
如果取 \(7,8,10,11,14\) 這五個點作為關鍵點,那麼對於這五個點的虛樹是這樣的:
其中加粗的節點表示我們需要的 LCA,剩下的就是 5 個關鍵點。
我們發現構建虛樹之後,一些無用 / 干擾節點都被我們刪去了,這樣對開頭所述的問題有不錯的解決效果。
2.2 虛樹構造
接下來詳細講一下虛樹的構造。
一般的虛樹構造是使用單調棧來構造的,也是相對比較大眾的構造方法(包括日報),這裡簡要闡述一下:
- 首先將所有關鍵點按照 DFS 序升序排序,如果關鍵點中沒有做 LCA 時指定的根節點我們需要人為加入這個點。
- 然後一個一個插入,採用一個對深度單調遞增的單調棧維護,每次彈棧的時候將棧內第二個點與棧頂連邊,然後彈出。
- 設當前插入節點為 \(x\),棧頂節點為 \(y\),其 LCA 為 \(z\)
- 這裡需要注意,如果當前棧頂不是 \(z\) 的話我們需要將 \(z\) 丟進棧內,同時如果棧頂深度小於 \(z\) 我們也要從棧頂向 \(z\) 連邊。
- 最後加入這個點即可。
- 上述所有關鍵點加入完畢之後,一起彈棧,重複執行 2 步驟。
上述方法更加具體的步驟與正確性證明見參考資料 - 1 中的日報。
這邊我學的是另外一種更加簡潔的方法:
- 首先將所有關鍵點按照 DFS 序升序排序,設該關鍵點序列為 \(\{a_n\}\)。
- 對於所有 \(i \in [2,n]\),如果 \(a_i\) 和 \(a_{i-1}\) 的 LCA 不是他們中的任意一個,意味著這個點也是要加入序列裡面的,丟到 \(\{a_n\}\)
- 對該序列去重之後重新按照 DFS 序升序排序。
- 最後對於所有 \(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 的時間複雜度:
- 預處理 LCA 複雜度 \(O(n \log n)\)。
- 升序排序複雜度 \(O(k \log k)\)。
- 在插入一個點的時候求 LCA 複雜度 \(O(\log n)\),單調棧複雜度會單獨計算。
- 插入所有點求 LCA 複雜度是 \(O(k \log n)\),單調棧複雜度單獨是 \(O(2(k+p))\)(所有點進出棧各一次),其中 \(p\) 表示這些點的 LCA 的數量。為分析方便,取最壞情況(該情況取不到,但是可以拿來分析複雜度)\(p=k\),則單調棧複雜度是 \(O(4k)\)。
- 因此該方法總複雜度是 \(O(n \log n + k (4 + \log k))\),不記預處理複雜度情況下忽略常數後可化簡為 \(O(k \log k)\)。
方法 2 的時間複雜度:
- 預處理 LCA 複雜度 \(O(n \log n)\)。
- 升序排序複雜度 \(O(k \log k)\)。
- 對於加入 LCA 的操作而言,這塊的複雜度也是 \(O(k \log k)\)。
- 去重 + 重新排序複雜度是 \(O(2k \log (2k) + (k + p) \log (k + p))\),\(p\) 跟方法一定義相同,最壞情況依然是 \(O(4k \log (2k))\)。
- 然後建樹過程複雜度是 \(O(2k \log 2k)\)。
- 因此該方法總複雜度是 \(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 分類:
- 如果 Size 為 0,考慮統計其直接兒子當中有幾個點 Size 為 1(設為 \(x\)),如果 \(x=1\),那麼直接將這個點 Size 置為 1 即可,如果大於 1,那麼就斷掉這個點,貢獻為 1。
- 如果 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. 總結
虛樹實際上就是將一棵樹剔除不關鍵點的演算法,本質上剝離出來後就是在一棵樹上做一些演算法。
其實我認為平常的樹上問題就是一種所有點都是關鍵點的虛樹吧。