DSA DSA In Depth
0%
lesson hldtree-queriessegment-tree

Heavy-Light Decomposition (HLD): Decomposing Trees into O(log N) Heavy Paths

7 min read Module 3.3: Range Query & Sublinear Decomposition Structures Updated Oct 23, 2026
📄 Cheat Sheet
Algorithms & Data Structures: Heavy-Light Decomposition (HLD) on Trees
Pavel Mavrin Algorithms & Data Structures: Heavy-Light Decomposition (HLD) on Trees Watch on YouTube ↗

Explains the heavy/light edge classification, why every root-to-leaf path crosses at most log2(N) light edges, and how DFS ordering flattens both heavy chains and subtrees.

0:00 / --:--

1. Heavy vs Light Edges & The $\lfloor \log_2 N \rfloor$ Path-Partition Bound

For each node $u$ with children $c_1, c_2, \dots, c_k$, pick a child $c^*$ maximizing $\text{sz}[c^*]$. Call $(u, c^*)$ the heavy edge out of $u$, and all other edges $(u, c_i)$ ($c_i \neq c^*$) light edges.

HLD Halving Invariant & Unified DFS Layout:
  1. Light Edge Halving: If $(u, v)$ is a light edge, there exists a heavy sibling $c^*$ with $\text{sz}[c^*] \ge \text{sz}[v]$. Since $\text{sz}[u] \ge 1 + \text{sz}[c^*] + \text{sz}[v] > 2 \cdot \text{sz}[v]$, we have $\text{sz}[v] < \frac{1}{2}\text{sz}[u]$. Thus any path from $u$ to the root crosses at most $\lfloor \log_2 N \rfloor$ light edges and traverses at most $\lfloor \log_2 N \rfloor + 1$ heavy chains.
  2. Heavy-Child-First Preorder (pos[u]): If the second DFS visits heavy[u] before all light children of $u$, then:
    • Every heavy chain starting at head[u] down to $u$ occupies the contiguous subarray $[\text{pos}[\text{head}[u]], \text{pos}[u]]$.
    • Every subtree $T_u$ simultaneously occupies the contiguous subarray $[\text{pos}[u], \text{pos}[u] + \text{sz}[u] - 1]$!
Heavy-Light Decomposition: Heavy Chains (Solid) vs Light Edges (Dashed) 0 1 3 6 4 7 2 5 Heavy-Child-First Preorder Array pos[0..7]: [0] [1] [3] [6] Chain head=0 (pos 0..3) [4] [7] head=4 (4..5) [2] [5] head=2 (6..7) Dual Property of Heavy-Child-First Ordering: • Path(7, 5) climbs: [pos[4]..pos[7]] -> [pos[2]..pos[5]] -> [pos[0]..pos[1]] • Subtree(1) = {1, 3, 6, 4, 7} is ALSO contiguous: pos[1..5]!

2. Climbing Chains in queryPath(u, v) & Handling Edge Weights vs Vertex Weights

To query or update the path between $u$ and $v$, compare the chain heads $\text{head}[u]$ and $\text{head}[v]$:

  • While $\text{head}[u] \neq \text{head}[v]$, pick whichever chain head is deeper (say $\text{depth}[\text{head}[u]] > \text{depth}[\text{head}[v]]$), query the contiguous segment $[\text{pos}[\text{head}[u]], \text{pos}[u]]$ in the Segment Tree, and jump $u \leftarrow \text{parent}[\text{head}[u]]$ across the light edge into the upper chain.
  • Once $\text{head}[u] = \text{head}[v]$, $u$ and $v$ lie on the same heavy chain! Query the final segment $[\min(\text{pos}[u], \text{pos}[v]), \max(\text{pos}[u], \text{pos}[v])]$ (for vertex queries) or $[\min(\text{pos}[u], \text{pos}[v]) + 1, \max(\text{pos}[u], \text{pos}[v])]$ (when weights live on edges and each edge weight is stored at its deeper child endpoint, excluding the LCA vertex!).
flowchart TD Start["queryPath(u, v)"] --> Loop{"head[u] != head[v]?"} Loop -- Yes --> Swap["If depth[head[u]] < depth[head[v]]: swap(u, v)"] Swap --> Seg["ans = combine(ans, seg.query(pos[head[u]], pos[u])) u = parent[head[u]]"] Seg --> Loop Loop -- No --> Final["Ensure depth[u] <= depth[v] Vertex query: seg.query(pos[u], pos[v]) Edge query: seg.query(pos[u] + 1, pos[v])"]
Hardware & Cache Reality — The swap(adj[u][0], adj[u][heavyIdx]) Trick & Tight Bound on Segment Tree Calls: By moving the heavy child to adj[u][0] during the first size-computation DFS, the second decomposition DFS simply iterates over adj[u] in order. Even better: although a path crosses $O(\log N)$ heavy chains and each chain call takes $O(\log N)$ in a Segment Tree ($O(\log^2 N)$ worst case), all chain intervals except the last one start at a chain head pos[head[u]], and the sum of lengths of the sub-trees along any heavy path telescopes! In an iterative $2N$ segment tree, HLD path queries execute in microsecond-level cache-hot loops.
Heavy-Light Decomposition, Cartesian Tree Stack & Mo’s Snake-Order Sweep
O((N+Q)√N) Mo · O(log² N) HLD · O(N) Cartesian

Switch between three foundational range-query reductions: (1) Mo’s offline √N block-snake query ordering, (2) Heavy-Light path decomposition into ≤ 2 log₂ N contiguous Segment Tree intervals, and (3) O(N) Monotonic Stack Cartesian Tree construction.

Step 1 / 8
Custom Input:
Timeline

Invariant & State

Step 1 (Even Block 0): Queries with L ∈ Block 0 are sorted by R ascending. curR sweeps right monotonically (6 → 7) in O(N) total steps for the block.
🌳 Live 2D Hierarchical Tree Topology — Heavy-Light Decomposition, Cartesian Tree Stack & Mo’s Snake-Order Sweep Visited/Active Pivot/Root Resolved
Even Blo Q1:[1… [0] curR adv Q2:[0… [1] Q3:[3… [2] Q4:[4… [3]
[0]Q1:[1, 6] (blk 0, R↑)Even Block 0: Sweep R Ascending (6 → 7)
[1]Q2:[0, 7] (blk 0, R↑)curR advances +1
[2]Q3:[3, 5] (blk 1, R↓)
[3]Q4:[4, 2] (blk 1, R↓)
Optimal Block Size B
B = max(1, ⌊N / √Q⌋)
Left Pointer Moves
≤ Q · B = O(N√Q)
Right Pointer Moves
≤ (N/B) · N = O(N√Q)
[Step 1] In Even Block 0, curR moves right from 6 to 7 (+1 step) while curL jitters within Block 0 by at most B = √N steps.

3. Production Implementation: Heavy-Light Decomposition with Iterative Segment Tree

/**
 * Heavy-Light Decomposition (HLD) supporting:
 *  - Point update on vertex u: O(log N)
 *  - Path query between u and v: O(log^2 N)
 *  - Subtree query on u: O(log N)
 *  - LCA(u, v): O(log N)
 */
class HeavyLightDecomposition {
  constructor(n, adj, values, combine, identity, root = 0) {
    this.n = n;
    this.combine = combine;
    this.identity = identity;

    this.parent = new Int32Array(n);
    this.depth = new Int32Array(n);
    this.sz = new Int32Array(n);
    this.heavy = new Int32Array(n).fill(-1);
    this.head = new Int32Array(n);
    this.pos = new Int32Array(n);

    this._dfsSize(adj, root);
    this._dfsDecompose(adj, root);

    // Build flat 2N iterative segment tree in HLD pos[] order
    this.tree = new Array(2 * n);
    for (let u = 0; u < n; u++) {
      this.tree[n + this.pos[u]] = values[u];
    }
    for (let i = n - 1; i >= 1; i--) {
      this.tree[i] = combine(this.tree[i << 1], this.tree[(i << 1) | 1]);
    }
  }

  _dfsSize(adj, root) {
    const order = [root];
    this.parent[root] = -1;
    this.depth[root] = 0;
    for (let i = 0; i < order.length; i++) {
      const u = order[i];
      for (const v of adj[u]) {
        if (v !== this.parent[u]) {
          this.parent[v] = u;
          this.depth[v] = this.depth[u] + 1;
          order.push(v);
        }
      }
    }
    for (let i = order.length - 1; i >= 0; i--) {
      const u = order[i];
      this.sz[u] = 1;
      let maxSub = 0;
      for (const v of adj[u]) {
        if (v !== this.parent[u]) {
          this.sz[u] += this.sz[v];
          if (this.sz[v] > maxSub) {
            maxSub = this.sz[v];
            this.heavy[u] = v;
          }
        }
      }
    }
  }

  _dfsDecompose(adj, root) {
    let curPos = 0;
    const stack = [[root, root]]; // [u, chainHead]
    while (stack.length > 0) {
      const [u, h] = stack.pop();
      this.head[u] = h;
      this.pos[u] = curPos++;
      // Push light children first so heavy[u] is popped and visited FIRST!
      for (let i = adj[u].length - 1; i >= 0; i--) {
        const v = adj[u][i];
        if (v !== this.parent[u] && v !== this.heavy[u]) {
          stack.push([v, v]);
        }
      }
      if (this.heavy[u] !== -1) {
        stack.push([this.heavy[u], h]);
      }
    }
  }

  _segQuery(l, r) {
    let resL = this.identity, resR = this.identity;
    for (let a = l + this.n, b = r + this.n + 1; a < b; a >>= 1, b >>= 1) {
      if (a & 1) resL = this.combine(resL, this.tree[a++]);
      if (b & 1) resR = this.combine(this.tree[--b], resR);
    }
    return this.combine(resL, resR);
  }

  setVertex(u, val) {
    let p = this.pos[u] + this.n;
    this.tree[p] = val;
    for (p >>= 1; p >= 1; p >>= 1) {
      this.tree[p] = this.combine(this.tree[p << 1], this.tree[(p << 1) | 1]);
    }
  }

  queryPath(u, v) {
    let res = this.identity;
    while (this.head[u] !== this.head[v]) {
      if (this.depth[this.head[u]] < this.depth[this.head[v]]) {
        const tmp = u; u = v; v = tmp;
      }
      res = this.combine(res, this._segQuery(this.pos[this.head[u]], this.pos[u]));
      u = this.parent[this.head[u]];
    }
    if (this.depth[u] > this.depth[v]) {
      const tmp = u; u = v; v = tmp;
    }
    return this.combine(res, this._segQuery(this.pos[u], this.pos[v]));
  }

  querySubtree(u) {
    return this._segQuery(this.pos[u], this.pos[u] + this.sz[u] - 1);
  }
}

4. Complexity Matrix

Tree Decomposition Technique Build Time & Space Path Update / Query Subtree Update / Query Dynamic Tree Topology (link/cut)?
Euler Tour Preorder (tin[u]..tout[u]) + Fenwick $\Theta(N)$ Root-to-node or Invertible only $\Theta(\log N)$ No
Heavy-Light Decomposition (HLD) + SegTree $\Theta(N)$ $O(\log^2 N)$ (any monoid) $O(\log N)$ No (fixed tree topology)
Link-Cut Trees (Sleator-Tarjan Splay Chains) $\Theta(N)$ $O(\log N)$ amortized Harder (requires Top Tree / Euler) Yes (link(u,v), cut(u,v) in $O(\log N)$)
Common Pitfall — Swapping depth[u] < depth[v] Instead of depth[head[u]] < depth[head[v]]: Inside the while (head[u] !== head[v]) loop of HLD, you MUST compare the depths of the chain heads (depth[head[u]] < depth[head[v]]), NOT the depths of $u$ and $v$ themselves! A node $u$ can be deeper than $v$ while head[u] is actually the root of the entire tree (depth[head[u]] = 0); jumping from head[u] would jump above the root (parent[root] = -1) and crash or miss the LCA entirely.
Check yourself Question 1 of 4
Concept Check Maximum Light Edges on Any Root-to-Leaf Path recall

In Heavy-Light Decomposition (HLD) of a tree with $N$ vertices, at each vertex $u$ the edge to the child with the largest subtree size is marked **heavy** (ties broken arbitrarily), and all other child edges are marked **light**.

⚡ Interactive Challenge · Staff Level

Algorithmic Design & Complexity Check

Analyze the invariant, asymptotic bound, and hardware behavior before selecting.

How must the HLD path query be adapted for edge weights and directed non-commutative paths from $u \to v$?

Key Takeaways

Every time a path steps down a light edge (u, v), the subtree size strictly more than halves (sz[v] <= sz[u] / 2), bounding light edges on any root-to-leaf path to <= floor(log2 N).
Visiting the heavy child first during the second DFS ensures that both every heavy chain [pos[head[u]]..pos[u]] AND every subtree [pos[u]..pos[u]+sz[u]-1] occupy contiguous intervals in a single array.
During path queries, always advance whichever node has the deeper chain head (depth[head[u]] > depth[head[v]]), never comparing depth[u] vs depth[v] until head[u] == head[v].
For edge-weighted trees, assign each edge's weight to its deeper endpoint and query [pos[u] + 1, pos[v]] on the final shared chain so the LCA vertex is excluded.
Was this lesson helpful? Spot an issue or have a suggestion? Let us know on GitHub.
Report an issue