線段樹處理連續陣列;HLD 讓樹上的路徑也變成「連續的一段」。

為什麼需要 HLD?

樹上路徑查詢(路徑上所有節點的值的最大值、和)很常見,但樹的路徑不是陣列裡連續的元素——沒辦法直接套線段樹。

LCA 可以找到路徑,但一段段地取路徑上的節點仍是 O(n)。HLD 把樹預處理後,任意路徑在 DFS 序中最多只跨 O(log n) 個連續段,每段用線段樹 O(log n) 查詢,總 O(log² n)。

重邊與輕邊

對每個非葉節點 u:
  重兒子(heavy child)= 子樹最大的孩子
  重邊(heavy edge)= 連向重兒子的邊
  輕邊(light edge)= 連向其他孩子的邊

重邊串起來形成重鏈(heavy chain)
每個節點恰好屬於一條重鏈

為什麼路徑最多跨 O(log n) 條鏈?

沿輕邊往上走一步:子樹大小至少翻倍(輕兒子的子樹 ≤ 父子樹/2)。從任何節點到根,最多走 O(log n) 條輕邊,因此任意路徑最多跨 O(log n) 條重鏈。

兩次 DFS 預處理

// DFS1:計算子樹大小、深度、父節點、重兒子
void dfs1(int v, int par, int d) {
    parent[v] = par; depth[v] = d; subtreeSize[v] = 1;
    heavyChild[v] = -1;
    int maxSize = 0;
    for (int u : adj[v]) {
        if (u == par) continue;
        dfs1(u, v, d + 1);
        subtreeSize[v] += subtreeSize[u];
        if (subtreeSize[u] > maxSize) {
            maxSize = subtreeSize[u];
            heavyChild[v] = u;
        }
    }
}
 
// DFS2:分配 DFS 序(同一條重鏈連續)
void dfs2(int v, int top) {
    chainTop[v] = top;
    pos[v] = timer++;           // 線段樹的位置
    if (heavyChild[v] != -1)
        dfs2(heavyChild[v], top);   // 重兒子繼承同一條鏈
    for (int u : adj[v]) {
        if (u != parent[v] && u != heavyChild[v])
            dfs2(u, u);             // 輕兒子開啟新鏈,鏈頂是自己
    }
}

路徑查詢

int pathQuery(int u, int v) {
    int result = 0;
    while (chainTop[u] != chainTop[v]) {
        // u 和 v 不在同一條鏈上
        if (depth[chainTop[u]] < depth[chainTop[v]])
            swap(u, v);
        // 讓 u 的鏈頂更深(更靠下),先把 u 那條鏈查完
        result = merge(result, segTree.query(pos[chainTop[u]], pos[u]));
        u = parent[chainTop[u]]; // 跳到上一條鏈
    }
    // 現在 u, v 在同一條鏈,直接查詢
    if (depth[u] > depth[v]) swap(u, v);
    result = merge(result, segTree.query(pos[u], pos[v]));
    return result;
}

每次 while 循環把一條重鏈「消化掉」,最多 O(log n) 次,每次線段樹查詢 O(log n),總 O(log² n)。


HLD 的精髓是 DFS2:讓重鏈連續分配 DFS 序,把樹的拓撲結構壓平成陣列,讓線段樹能接手。