1. 程式人生 > 其它 >Note -「Dsu On Tree」學習筆記

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(x)\) 表示 \(x\) 的子樹大小。

不難發現,若 \(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\)

,所以有 \(1 \leq \frac {size(u)} {2^e}\)。故 \(2^e \leq size(u)\),即 \(e \leq \log_2(size(u))\),得證。

關於統計輕兒子的時間複雜度

對於當前節點 \(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\) 都達不到,故其不為演算法瓶頸。


應用場景