Note -「Dsu On Tree」學習筆記
前置芝士
樹連剖分及其思想,以及優化時間複雜度的原理。
講個笑話這個東西其實和 Dsu(並查集)沒什麼關係。
等等好像時間複雜度證明偽了??
演算法本身
Dsu On Tree
,一下簡稱 DOT
,常用於解決子樹間的資訊合併問題。
其實本質上可以理解為高維樹上 DP
的空間優化,也可以理解為暴力優化。
在這裡我們再次明確一些定義:
- 重兒子 & 輕兒子:一個節點的兒子中子樹最大的兒子稱為該節點的重兒子,其餘的兒子即為輕兒子。特殊的,如果子樹最大的有多個,我們任取一個作為重兒子。
- 重邊 & 輕邊:連線一個節點與它的重兒子的邊稱為重邊,連線一個節點與它的輕兒子的邊稱為輕邊。
- 重鏈 & 輕鏈:全由重邊構成的鏈稱為重鏈,全由輕邊構成的鏈稱為輕鏈。重鏈和輕鏈互不相交。
對於需要統計一個子樹的資訊的問題,暴力的時間複雜度通常是 \(O(n^2)\) 。
為了優化時間複雜度,DOT
採用了一個非常巧妙的轉移方式。
我們利用 \(O(1)\) 的時間複雜度維護並上傳每個節點的重兒子及其子樹的資訊。
在遭遇一次查詢時,我們再暴力統計當前節點的所有輕兒子及其子樹資訊,並和重兒子資訊結合得到答案。可以證明到對於每個節點統計輕兒子及其子樹的時間複雜度和是 \(O(n \log_2 n)\) 的。具體流程詳見程式碼。
時間複雜度具體口胡證明方法如下。
樹上性質 1
結論:如果有一條輕邊 \((u, v)\),且 \(u\) 是 \(v\) 的父親。則一定有 \(size(v) \leq \frac {size(u)} {2}\)
不難發現,若 \(size(v) > \frac {size(u)} {2}\) ,則它一定是 \(u\) 的重兒子,這與輕邊 \((u, v)\) 矛盾,得證。
樹上性質 2
結論:從某一子樹的根節點 \(u\) 到該子樹上的任意節點 \(v\) 的路徑經過的輕邊數一定小於等於 \(\log_2(size(u))\)。
由性質 \(1\) 可知,經過一條輕邊,至少會將節點個數減半。設總共會經過 \(e\) 條輕邊,則有 \(size(v) \leq \frac {size(u)} {2^e}\)。且 \(size(v) \geq 1\)
關於統計輕兒子的時間複雜度
對於當前節點 \(u\),到達其任意子樹上的節點經過的輕邊數小於等於 \(\log_2(size(u))\)。故可以粗略理解為在這條路徑上存在 \(\log_2(size(u))\) 個輕兒子。所以在這個子樹上總共有小於等於 \(size(u)\log_2 size(u)\) 個親兒子。
而所有的重兒子子樹我們都是一直向上傳遞,直到遇到一條輕邊,故重兒子部分仍然是 \(O(1)\)。
那麼對於 \(u\),得到它完整的資訊所需時間複雜度為 \(size(u)\log_2 size(u)\), 故對於根節點,整個樹的資訊統計僅需耗時 \(n \log_2 n\)。
具體實現
#include <cstdio>
#include <vector>
using namespace std;
typedef long long LL;
int Max(int x, int y) { return x > y ? x : y; }
int Min(int x, int y) { return x < y ? x : y; }
int Abs(int x) { return x < 0 ? -x : x; }
int read() {
int k = 1, x = 0;
char s = getchar();
while (s < '0' || s > '9') {
if (s == '-')
k = -1;
s = getchar();
}
while (s >= '0' && s <= '9') {
x = (x << 3) + (x << 1) + s - '0';
s = getchar();
}
return x * k;
}
void write(LL x) {
if (x < 0) {
putchar('-');
x = -x;
}
if (x > 9)
write(x / 10);
putchar(x % 10 + '0');
}
void print(LL x, char s) {
write(x);
putchar(s);
} // 漂亮的輸入輸出優化及一些模板。
const int MAXN = 1e5 + 5;
struct data {
int id, x;
// id 表示這是第幾個查詢,x 表示是誰的子樹資訊。
data() {}
data(int Id, int X) {
id = Id;
x = X;
}
} vector<data> q[MAXN];
// DOT 雖然解決了時間複雜度,但如果想要儲存所有子樹的資訊還是有平方的空間複雜度。
// 所以我們經常採用邊跑 DOT,邊在有查詢的節點 x 上統計答案的思路。
vector<int> mp[MAXN];
void Add_Edge(int u, int v) {
mp[u].push_back(v);
mp[v].push_back(u);
} // 加邊 & 建樹。
int Son[MAXN], Size[MAXN];
// Son 代表節點 u 的重兒子節點編號,Size 代表節點 u 的子樹大小。
LL ans[MAXN];
// 用於統計答案。
void dfs(int u, int fa) {
Size[u] = 1;
Son[u] = -1;
int ma = -1; // 用於尋找最大子樹。
// 一些初始化。
for (int i = 0, v; i < mp[u].size(); i++) {
v = mp[u][i];
if (v == fa)
continue;
dfs(v, u);
Size[u] += Size[v]; // 計運算元樹大小。
if (Size[v] > ma) {
ma = Size[v];
Son[u] = v; // 找重兒子。
}
}
}
int son = -1;
void calc(int u, int fa, int val) { // 暴力統計除重兒子及其子樹外的殘缺子樹資訊。
...; // 一些操作因題而異。
for (int i = 0, v; i < mp[u].size(); i++) {
v = mp[u][i];
if (v == fa || v == son)
continue;
calc(v, u, val);
}
}
void dfs2(int u, int fa, bool keep) {
// u, fa 來自樹上遍歷的常規變數。
// 若 keep 為 1,表示當前節點是 fa
// 的重兒子,當前節點統計的輕兒子加重兒子的資訊不需要清空,保留下來直接上傳。 若 keep 為 2,表示當前節點是
// fa 的輕兒子,則當前節點統計的輕兒子加重兒子的資訊需要清空,不清空的話,在 fa 上會再統計一次,這一段是
// DOT 的核心操作,可以藉助畫圖深入理解。
for (int i = 0, v; i < mp[u].size(); i++) {
v = mp[u][i];
if (v == fa || v == Son[u])
continue;
dfs2(v, u, false);
}
if (Son[u] != -1) {
dfs2(Son[u], u, true);
son = Son[u];
}
calc(u, fa, 1); // 統計貢獻。
for (int i = 0; i < q[u].size(); i++) ans[q[u][i].id] = ...;
son = -1;
// 注意,清空貢獻是整個子樹,不止是去除了重兒子及其子樹的殘缺子樹。
// 當然這裡的不會影響到總體的時間複雜度。見補充
if (!keep)
calc(u, fa, -1); // 清空貢獻。
}
int main() {
// 顯然主函式因題而異。
int n = read();
for (int i = 1, u, v; i < n; i++) {
u = read(), v = read();
Add_Edge(u, v);
}
int m = read();
for (int i = 1; i <= m; i++) {
int x = read();
q.push_back(data(i, x));
}
dfs(1, -1);
dfs2(1, -1, 1);
for (int i = 1; i <= m; i++) print(ans[i], ' ');
return 0;
}
補充
對於每一層,我們需要清空的節點個數大概是 \(\frac n {2^e}\) 個(即當前層輕兒子的子樹大小和),所以總需要清空的節點數近似於 \(\sum_{e = 1}^{\lfloor log_2 n \rfloor} \frac n {2^e}\),簡單化簡發現它等於 \(n \times (\frac 1 2 + \dots + \frac 1 {2^{\lfloor log_2 n \rfloor}})\),即 \(n \times (1 - \frac 1 {2^{\lfloor log_2 n \rfloor}})\),顯然它甚至連 \(n\) 都達不到,故其不為演算法瓶頸。