1. 程式人生 > >【資料結構】樹鏈剖分詳細講解

【資料結構】樹鏈剖分詳細講解

![](https://cdn.jsdelivr.net/gh/Kanna-jiahe/blogimage/img/20201130191127.jpeg)  *“在一棵樹上進行路徑的修改、求極值、求和”乍一看只要線段樹就能輕鬆解決,實際上,僅憑線段樹是不能搞定它的。我們需要用到一種貌似高階的複雜演算法——樹鏈剖分。* **樹鏈剖分**是把一棵樹分割成若干條鏈,以便於維護資訊的一種方法,其中最常用的是**重鏈剖分**(Heavy Path Decomposition,重路徑分解),所以一般提到樹鏈剖分或樹剖都是指重鏈剖分。除此之外還有長鏈剖分和實鏈剖分等,本文暫不介紹。 ### **首先我們需要明確概念:** - 重兒子:父親節點的所有兒子中子樹結點數目最多(size最大)的結點; - 輕兒子:父親節點中除了重兒子以外的兒子; - 重邊:父親結點和重兒子連成的邊; - 輕邊:父親節點和輕兒子連成的邊; - 重鏈:由多條重邊連線而成的路徑; - 輕鏈:由多條輕邊連線而成的路徑; 我們定義樹上一個節點的子節點中子樹最大的一個為它的**重子節點**,其餘的為**輕子節點**。一個節點連向其重子節點的邊稱為**重邊**,連向輕子節點的邊則為**輕邊**。如果把根節點看作輕的,那麼從每個輕節點出發,不斷向下走重邊,都對應了一條鏈,於是我們把樹剖分成了 $l$ 條鏈,其中 $l$ 是輕節點的數量。 ![](https://gitee.com//riotian/blogimage/raw/master/img/20201130192740.jpg) >
最近因為畫圖工具出了點問題,所以轉載了Pecco學長的示意圖(下面求LCA的方法的部分內容也來自Pecco學長) 剖分後的樹(**重鏈**)有如下性質: 1. **對於節點數為 $n$ 的樹,從任意節點向上走到根節點,經過的輕邊數量不會超過 $log\ n$** 這是因為當我們向下經過一條 **輕邊** 時,所在子樹的大小至少會除以二。所以說,對於樹上的任意一條路徑,把它拆分成從 $lca$ 分別向兩邊往下走,分別最多走 $O(\log n)$ 次,樹上的每條路徑都可以被拆分成不超過 $O(\log n)$ 條重鏈。 2. **樹上每個節點都屬於且僅屬於一條重鏈** 。 重鏈開頭的結點不一定是重子節點(因為重邊是對於每一個結點都有定義的)。所有的重鏈將整棵樹 **完全剖分** 。 儘管樹鏈部分看起來很難實現(的確有點繁瑣),但我們可以用兩個 DFS 來實現樹鏈(樹剖)。 相關偽碼(來自 OI wiki) 第一個 DFS 記錄每個結點的父節點(father)、深度(deep)、子樹大小(size)、重子節點(hson)。 $$ \begin{array}{l} \text{TREE-BUILD }(u,dep) \\ \begin{array}{ll} 1 & u.hson\gets 0 \\ 2 & u.hson.size\gets 0 \\ 3 & u.deep\gets dep \\ 4 & u.size\gets 1 \\ 5 & \textbf{for }\text{each }u\text{'s son }v \\ 6 & \qquad u.size\gets u.size + \text{TREE-BUILD }(v,dep+1) \\ 7 & \qquad v.father\gets u \\ 8 & \qquad \textbf{if }v.size>
u.hson.size \\ 9 & \qquad \qquad u.hson\gets v \\ 10 & \textbf{return } u.size \end{array} \end{array} $$ 第二個 DFS 記錄所在鏈的鏈頂(top,應初始化為結點本身)、重邊優先遍歷時的 DFS 序(dfn)、DFS 序對應的節點編號(rank)。 $$ \begin{array}{l} \text{TREE-DECOMPOSITION }(u,top) \\ \begin{array}{ll} 1 & u.top\gets top \\ 2 & tot\gets tot+1\\ 3 & u.dfn\gets tot \\ 4 & rank(tot)\gets u \\ 5 & \textbf{if }u.hson\text{ is not }0 \\ 6 & \qquad \text{TREE-DECOMPOSITION }(u.hson,top) \\ 7 & \qquad \textbf{for }\text{each }u\text{'s son }v \\ 8 & \qquad \qquad \textbf{if }v\text{ is not }u.hson \\ 9 & \qquad \qquad \qquad \text{TREE-DECOMPOSITION }(v,v) \end{array} \end{array} $$ 以下為程式碼實現。 我們先給出一些定義: - $fa(x)$ 表示節點 $x$ 在樹上的父親(也就是父節點)。 - $dep(x)$ 表示節點 $x$ 在樹上的深度。 - $siz(x)$ 表示節點 $x$ 的子樹的節點個數。 - $son(x)$ 表示節點 $x$ 的 **重兒子** 。 - $top(x)$ 表示節點 $x$ 所在 **重鏈** 的頂部節點(深度最小)。 - $dfn(x)$ 表示節點 $x$ 的 **DFS 序** ,也是其線上段樹中的編號。 - $rnk(x)$ 表示 DFS 序所對應的節點編號,有 $rnk(dfn(x))=x$ 。 我們進行兩遍 DFS 預處理出這些值,其中第一次 DFS 求出 $fa(x)$ , $dep(x)$ , $siz(x)$ , $son(x)$ ,第二次 DFS 求出 $top(x)$ , $dfn(x)$ , $rnk(x)$ 。 ```cpp // 當然樹鏈寫法不止一種,這個是我學習Oi wiki上知識點記錄的模板程式碼 void dfs1(int o) { son[o] = -1, siz[o] = 1; for (int j = h[o]; j; j = nxt[j]) if (!dep[p[j]]) { dep[p[j]] = dep[o] + 1; fa[p[j]] = o; dfs1(p[j]); siz[o] += siz[p[j]]; if (son[o] == -1 || siz[p[j]] >
siz[son[o]]) son[o] = p[j]; } } void dfs2(int o, int t) { top[o] = t; dfn[o] = ++cnt; rnk[cnt] = o; if (son[o] == -1) return; dfs2(son[o], t); // 優先對重兒子進行 DFS,可以保證同一條重鏈上的點 DFS 序連續 for (int j = h[o]; j; j = nxt[j]) if (p[j] != son[o] && p[j] != fa[o]) dfs2(p[j], p[j]); } ``` ```cpp // 寫法2:來自Peocco學長,程式碼僅作學習使用 void dfs1(int p, int d = 1){ int Siz = 1,ma = 0; dep[p] = d; for(auto q : edges[p]){ // for迴圈寫法和auto是C++11標準,競賽可用 dfs1(q,d + 1); fa[q] = p; Siz += sz[q]; if(sz[q] > ma) hson[p] = q, ma = sz[q];// hson = 重兒子 } sz[p] = Siz; } // 需要先把根節點的top初始化為自身 void dfs2(int p){ for(auto q : edges[p]){ if(!top[q]){ if(q == hson[p]) top[q] = top[p]; else top[q] = q; dfs2(q); } } } ``` 以上這樣便完成了剖分。 學習到這裡想想開頭的那句話:  *“在一棵樹上進行路徑的修改、求極值、求和”乍一看只要線段樹就能輕鬆解決,實際上,僅憑線段樹是不能搞定它的。我們需要用到一種貌似高階的複雜演算法——樹鏈剖分。* 如果不能一下想不到線段樹解決不了的問題的話不如看看這道題 ↓ ### Hdu 3966 Aragorn's Story 題目連結:http://acm.hdu.edu.cn/showproblem.php?pid=3966 題意:給一棵樹,並給定各個點權的值,然後有3種操作:   I C1 C2 K: 把C1與C2的路徑上的所有點權值加上K   D C1 C2 K:把C1與C2的路徑上的所有點權值減去K   Q C:查詢節點編號為C的權值   分析:典型的樹鏈剖分題目,先進行剖分,然後用線段樹去維護即可 ```cpp // Author : RioTian // Time : 20/11/30 #include using namespace std; #define lson l, m, rt << 1 #define rson m + 1, r, rt << 1 | 1 typedef long long ll; typedef int lld; stack ss; const int maxn = 2e5 + 10; const int inf = ~0u >> 2; // 1073741823 int M[maxn << 2]; int add[maxn << 2]; struct node { int s, t, w, next; } edges[maxn << 1]; int E, n; int Size[maxn], fa[maxn], heavy[maxn], head[maxn], vis[maxn]; int dep[maxn], rev[maxn], num[maxn], cost[maxn], w[maxn]; int Seg_size; int find(int x) { return fa[x] == x ? x : fa[x] = find(fa[x]); } void add_edge(int s, int t, int w) { edges[E].w = w; edges[E].s = s; edges[E].t = t; edges[E].next = head[s]; head[s] = E++; } void dfs(int u, int f) { //起點,父節點 int mx = -1, e = -1; Size[u] = 1; for (int i = head[u]; i != -1; i = edges[i].next) { int v = edges[i].t; if (v == f) continue; edges[i].w = edges[i ^ 1].w = w[v]; dep[v] = dep[u] + 1; rev[v] = i ^ 1; dfs(v, u); Size[u] += Size[v]; if (Size[v] > mx) mx = Size[v], e = i; } heavy[u] = e; if (e != -1) fa[edges[e].t] = u; } inline void pushup(int rt) { M[rt] = M[rt << 1] + M[rt << 1 | 1]; } void pushdown(int rt, int m) { if (add[rt]) { add[rt << 1] += add[rt]; add[rt << 1 | 1] += add[rt]; M[rt << 1] += add[rt] * (m - (m >> 1)); M[rt << 1 | 1] += add[rt] * (m >> 1); add[rt] = 0; } } void built(int l, int r, int rt) { M[rt] = add[rt] = 0; if (l == r) return; int m = (r + l) >> 1; built(lson), built(rson); } void update(int L, int R, int val, int l, int r, int rt) { if (L <= l && r <= R) { M[rt] += val; add[rt] += val; return; } pushdown(rt, r - l + 1); int m = (l + r) >> 1; if (L <= m) update(L, R, val, lson); if (R > m) update(L, R, val, rson); pushup(rt); } lld query(int L, int R, int l, int r, int rt) { if (L <= l && r <= R) return M[rt]; pushdown(rt, r - l + 1); int m = (l + r) >> 1; lld ret = 0; if (L <= m) ret += query(L, R, lson); if (R > m) ret += query(L, R, rson); return ret; } void prepare() { int i; built(1, n, 1); memset(num, -1, sizeof(num)); dep[0] = 0; Seg_size = 0; for (i = 0; i < n; i++) fa[i] = i; dfs(0, 0); for (i = 0; i < n; i++) { if (heavy[i] == -1) { int pos = i; while (pos && edges[heavy[edges[rev[pos]].t]].t == pos) { int t = rev[pos]; num[t] = num[t ^ 1] = ++Seg_size; // printf("pos=%d val=%d t=%d\n", Seg_size, edge[t].w, t); update(Seg_size, Seg_size, edges[t].w, 1, n, 1); pos = edges[t].t; } } } } int lca(int u, int v) { while (1) { int a = find(u), b = find(v); if (a == b) return dep[u] < dep[v] ? u : v; // a,b在同一條重鏈上 else if (dep[a] >= dep[b]) u = edges[rev[a]].t; else v = edges[rev[b]].t; } } void CH(int u, int lca, int val) { while (u != lca) { int r = rev[u]; // printf("r=%d\n",r); if (num[r] == -1) edges[r].w += val, u = edges[r].t; else { int p = fa[u]; if (dep[p] < dep[lca]) p = lca; int l = num[r]; r = num[heavy[p]]; update(l, r, val, 1, n, 1); u = p; } } } void change(int u, int v, int val) { int p = lca(u, v); // printf("p=%d\n",p); CH(u, p, val); CH(v, p, val); if (p) { int r = rev[p]; if (num[r] == -1) { edges[r ^ 1].w += val; //在此處發現了我程式碼的重大bug edges[r].w += val; } else update(num[r], num[r], val, 1, n, 1); } //根節點,特判 else w[p] += val; } lld solve(int u) { if (!u) return w[u]; //根節點,特判 else { int r = rev[u]; if (num[r] == -1) return edges[r].w; else return query(num[r], num[r], 1, n, 1); } } int main() { // freopen("in.txt", "r", stdin); ios::sync_with_stdio(false), cin.tie(0), cout.tie(0); int t, i, a, b, c, m, ca = 1, p; while (cin >> n >> m >> p) { memset(head, -1, sizeof(head)); E = 0; for (int i = 0; i < n; ++i) cin >> w[i]; for (int i = 0; i < m; ++i) { cin >> a >> b; a--, b--; add_edge(a, b, 0), add_edge(b, a, 0); } prepare(); // 預處理 string op; while (p--) { cin >> op; if (op[0] == 'I') { //區間新增 cin >> a >> b >> c; a--, b--; change(a, b, c); } else if (op[0] == 'D') { //區間減少 cin >> a >> b >> c; a--, b--; change(a, b, -c); } else { //查詢 cin >> a; a--; cout << solve(a) << endl; } } } return 0; } ``` 由於資料很大,建議使用快讀,而不是像我一樣用 `cin`(差了近500ms了) 摺疊程式碼是千千dalao的解法:
Code
//千千dalao解法
#include<bits/stdc++.h>
using namespace std;
typedef long long LL;
const int maxn = 50010;
struct Edge {
    int to;
    int next;
} edge[maxn << 1];
int head[maxn], tot;  //鏈式前向星儲存
int top[maxn];        // v所在重鏈的頂端節點
int fa[maxn];         //父親節點
int deep[maxn];       //節點深度
int num[maxn];        //以v為根的子樹節點數
int p[maxn];          // v與其父親節點的連邊線上段樹中的位置
int fp[maxn];         //與p[]陣列相反
int son[maxn];        //重兒子
int pos;
int w[maxn];
int ad[maxn << 2];  //樹狀陣列
int n;              //節點數目
void init() {
    memset(head, -1, sizeof(head));
    memset(son, -1, sizeof(son));
    tot = 0;
    pos = 1;  //因為使用樹狀陣列,所以我們pos初始值從1開始
}
void addedge(int u, int v) {
    edge[tot].to = v;
    edge[tot].next = head[u];
    head[u] = tot++;
}
//第一遍dfs,求出 fa,deep,num,son (u為當前節點,pre為其父節點,d為深度)
void dfs1(int u, int pre, int d) {
    deep[u] = d;
    fa[u] = pre;
    num[u] = 1;
    //遍歷u的鄰接點
    for (int i = head[u]; i != -1; i = edge[i].next) {
        int v = edge[i].to;
        if (v != pre) {
            dfs1(v, u, d + 1);
            num[u] += num[v];
            if (son[u] == -1 || num[v] > num[son[u]])  //尋找重兒子
                son[u] = v;
        }
    }
}
//第二遍dfs,求出 top,p
void dfs2(int u, int sp) {
    top[u] = sp;
    p[u] = pos++;
    fp[p[u]] = u;
    if (son[u] != -1)  //如果當前點存在重兒子,繼續延伸形成重鏈
        dfs2(son[u], sp);
    else
        return;
    for (int i = head[u]; i != -1; i = edge[i].next) {
        int v = edge[i].to;
        if (v != son[u] && v != fa[u])  //遍歷所有輕兒子新建重鏈
            dfs2(v, v);
    }
}
int lowbit(int x) {
    return x & -x;
}
//查詢
int query(int i) {
    int s = 0;
    while (i > 0) {
        s += ad[i];
        i -= lowbit(i);
    }
    return s;
}
//增加
void add(int i, int val) {
    while (i <= n) {
        ad[i] += val;
        i += lowbit(i);
    }
}
void update(int u, int v, int val) {
    int f1 = top[u], f2 = top[v];
    while (f1 != f2) {
        if (deep[f1] < deep[f2]) {
            swap(f1, f2);
            swap(u, v);
        }
        //因為區間減法成立,所以我們把對某個區間[f1,u]
        //的更新拆分為 [0,f1] 和 [0,u] 的操作
        add(p[f1], val);
        add(p[u] + 1, -val);
        u = fa[f1];
        f1 = top[u];
    }
    if (deep[u] > deep[v])
        swap(u, v);
    add(p[u], val);
    add(p[v] + 1, -val);
}
int main() {
    ios::sync_with_stdio(false);
    int m, ps;
    while (cin >> n >> m >> ps) {
        int a, b, c;
        for (int i = 1; i <= n; i++)
            cin >> w[i];
        init();
        for (int i = 0; i < m; i++) {
            cin >> a >> b;
            addedge(a, b);
            addedge(b, a);
        }
        dfs1(1, 0, 0);
        dfs2(1, 1);
        memset(ad, 0, sizeof(ad));
        for (int i = 1; i <= n; i++) {
            add(p[i], w[i]);
            add(p[i] + 1, -w[i]);
        }
        for (int i = 0; i < ps; i++) {
            char op;
            cin >> op;
            if (op == 'Q') {
                cin >> a;
                cout << query(p[a]) << endl;
            } else {
                cin >> a >> b >> c;
                if (op == 'D')
                    c = -c;
                update(a, b, c);
            }
        }
    }
    return 0;
}
### 利用樹鏈求LCA > 這個部分參考了Peocco學長,十分感謝 在這道經典題中,求了LCA,但為什麼樹剖就可以求LCA呢? 樹剖可以單次 $O(log\ n)$! 地求LCA,且常數較小。假如我們要求兩個節點的LCA,如果它們在同一條鏈上,那直接輸出深度較小的那個節點就可以了。 ![](https://gitee.com//riotian/blogimage/raw/master/img/20201130203550.jpg) 否則,LCA要麼在鏈頭深度較小的那條鏈上,要麼就是兩個鏈頭的父節點的LCA,但絕不可能在鏈頭深度較大的那條鏈上[^1]。所以我們可以直接把鏈頭深度較大的節點用其鏈頭的父節點代替,然後繼續求它與另一者的LCA。 ![](https://gitee.com//riotian/blogimage/raw/master/img/20201130204424.jpg) 由於在鏈上我們可以 $O(1)$ 地跳轉,每條鏈間由輕邊連線,而經過輕邊的次數又不超過 ![[公式]](https://www.zhihu.com/equation?tex=%5Clog+n) ,所以我們實現了 $O(log\ n)$ 的LCA查詢。 ```cpp int lca(int a, int b) { while (top[a] != top[b]) { if (dep[top[a]] > dep[top[b]]) a = fa[top[a]]; else b = fa[top[b]]; } return (dep[a] > dep[b] ? b : a); } ``` #### 結合資料結構 在進行了樹鏈剖分後,我們便可以配合[線段樹](https://www.cnblogs.com/RioTian/p/13409694.html)等資料結構維護樹上的資訊,這需要我們改一下第二次 DFS 的程式碼,我們用`dfsn`陣列記錄每個點的**dfs序**,用`madfsn`陣列記錄**每棵子樹的最大dfs序**:(這裡有點像連通圖的知識了) ```cpp // 需要先把根節點的top初始化為自身 int cnt; void dfs2(int p) { madfsn[p] = dfsn[p] = ++cnt; if (hson[p] != 0) { top[hson[p]] = top[p]; dfs2(hson[p]); madfsn[p] = max(madfsn[p], madfsn[hson[p]]); } for (auto q : edges[p]) if (!top[q]) { top[q] = q; dfs2(q); madfsn[p] = max(madfsn[p], madfsn[q]); } } ``` 注意到,**每棵子樹的dfs序都是連續的,且根節點dfs序最小**;而且,如果我們優先遍歷重子節點,那麼**同一條鏈上的節點的dfs序也是連續的,且鏈頭節點dfs序最小**。 ![連通樹(霧)](https://gitee.com//riotian/blogimage/raw/master/img/20201130204718.jpg) 所以就可以用線段樹等資料結構維護區間資訊(以點權的和為例),例如路徑修改(類似於求LCA的過程): ```cpp void update_path(int x, int y, int z) { while (top[x] != top[y]) { if (dep[top[x]] > dep[top[y]]) { update(dfsn[top[x]], dfsn[x], z); x = fa[top[x]]; } else { update(dfsn[top[y]], dfsn[y], z); y = fa[top[y]]; } } if (dep[x] > dep[y]) update(dfsn[y], dfsn[x], z); else update(dfsn[x], dfsn[y], z); } ``` 路徑查詢: ```cpp int query_path(int x, int y) { int ans = 0; while (top[x] != top[y]) { if (dep[top[x]] > dep[top[y]]) { ans += query(dfsn[top[x]], dfsn[x]); x = fa[top[x]]; } else { ans += query(dfsn[top[y]], dfsn[y]); y = fa[top[y]]; } } if (dep[x] > dep[y]) ans += query(dfsn[y], dfsn[x]); else ans += query(dfsn[x], dfsn[y]); return ans; } ``` 子樹修改(更新): ```cpp void update_subtree(int x, int z){ update(dfsn[x], madfsn[x], z); } ``` 子樹查詢: ```cpp int query_subtree(int x){ return query(dfsn[x], madfsn[x]); } ``` 需要注意,建線段樹的時候不是按節點編號建,而是按dfs序建,類似這樣: ```cpp for (int i = 1; i <= n; ++i) B[i] = read(); // ... for (int i = 1; i <= n; ++i) A[dfsn[i]] = B[i]; build(); ``` 當然,不僅可以用線段樹維護,有些題也可以使用[珂朵莉樹](https://www.cnblogs.com/RioTian/p/13434531.html)等資料結構(要求資料不卡珂朵莉樹,如[這道](https://www.luogu.com.cn/problem/P4315))。此外,如果需要維護的是邊權而不是點權,把每條邊的邊權下放到深度較深的那個節點處即可,但是查詢、修改的時候要注意略過最後一個點。 寫在最後: OI wiki上有一些推薦做的列題,但每個都需要比較多的時間+耐心去完成,所以這裡推薦幾個必做的題: [SPOJ QTREE – Query on a tree (樹鏈剖分)](https://www.dreamwings.cn/spoj-qtree/4773.html):千千dalao的題解報告 HDU 3966 Aragorn’s Story (樹鏈剖分):建議先看一遍我的解法再獨立完成。 ### 參考 洛穀日報:https://zhuanlan.zhihu.com/p/41082337 OI wiki:https://oi-wiki.org/graph/hld/ Pecco學長:https://www.zhihu.com/people/one-seventh 千千:https://www.dreamwings.cn/hdu3966/4798.html [^1]: 設top[a]的深度≤top[b]的深度,且c=lca(a,b)在b所在的鏈上;那麼c是a和b的祖先且c的深度≥top[b]的深度,那麼c的深度≥top[a]的深度。c是a的祖先,top[a]也是a的祖先,c的深度大於等於top[a],那c必然在連線top[a]和a的這條鏈上,與前提矛盾