Understand
30-second explanation
Build the interview-ready mental model before comparing implementations.
Heavy-light decomposition (HLD) makes path queries on a tree fast. Each node picks its child with the largest subtree as its 'heavy' child; the heavy edges link nodes into chains. Lay the chains out contiguously in an array (via DFS position) and back that array with a segment tree. Any root-to-node path crosses at most O(log n) chains, so a path query splits into O(log n) contiguous segment-tree ranges — giving O(log^2 n) per path query (O(log n) chains × O(log n) per range).
Interview contextWhat is testedWhere candidates slipHow to stand outExplore interview contextHide interview context
What's being tested
Whether you understand why choosing the largest-subtree child bounds light edges at O(log n), and how that turns a path into O(log n) contiguous ranges a segment tree can answer.
Where candidates slip
Picking the heavy child by depth or degree instead of subtree size (losing the log bound), laying chains non-contiguously, or mishandling the final same-chain segment (forgetting to include the right endpoint or double-counting the LCA on edge-valued trees).
How to stand out
State the O(log n) light-edge bound from the halving argument, describe the two-DFS build crisply, and contrast HLD (paths, O(log^2 n)) with Euler tour (subtrees, O(log n)) and binary lifting (LCA only).
Choose
Compare the available approaches
Use constraints and trade-offs to select the route you can defend in an interview.
Interactive decision treeInteractiveChoose the right approachExplore the full treeCollapse the tree
How to read it:Solid edges are heavy (toward the largest-subtree child); a path crosses few light edges. head[] marks each chain's top.
Learn & trace
Build each solution step by step
Move from the core idea to a visual trace, implementation, proof, and edge cases.
Approach 1 of 2
Baseline — Walk the Path via the LCA
1Hint 1Explore hintHide hint
Find the LCA of u and v, then walk u up to the LCA and v up to the LCA, aggregating values along the way.
2Hint 2Explore hintHide hint
update(node, val) is one assignment — O(1).
3Hint 3Explore hintHide hint
A path can be the whole height of the tree, so a query is O(n). With many queries this is too slow — motivating a layout where a path is a few contiguous ranges.
A · Core idea
The direct method needs only parent pointers, depths, and per-node values.
Find the meeting point. Compute lca(u, v). The path from u to v is u → … → lca → … → v.
Walk and aggregate. Step u upward to the LCA, combining each node's value; do the same for v; include the LCA once. The combine is whatever the query asks (sum, max, …).
Why it is correct. Every node on the u-to-v path is on one of the two upward walks to their LCA, and the LCA is the single shared top.
Why it is too slow. A path can span the entire height of the tree — O(n) for a chain. Answering 10⁵ such queries is O(n·q). The remedy is to relabel nodes so that the path decomposes into a few contiguous ranges that a segment tree aggregates in logarithmic time — which is exactly what HLD provides.
B · Visual dry run
Interactive dry run
Select a frame to connect the decision with its visual state.
tree 0-1,0-2,1-3,1-4; values=[1,2,3,4,5]; pathQuery(3,2) sumu side
3 (val 4) → 1 (val 2) → 0
partial = 4 + 2 = 610C · Code with teaching comments
Reference implementation previewimport java.util.*; class NaivePath { private final List<List<Integer>> adj; private final int[] parent, depth;Explore the full codeHide the full code
D · Why it works and complexityO(n) per query, O(1) update timeO(n) spaceOpen the invariant and proofExplore the proofHide the proof
The path-via-LCA walk is the conceptual baseline HLD accelerates: HLD keeps the same 'climb toward the LCA' motion but jumps a whole chain at a time (one segment-tree range) instead of one node at a time.
ComplexityO(n) per query, O(1) update time, O(n) space — Walking both halves of the path visits up to O(n) nodes (e.g. a chain). Updates are O(1). With q queries the cost is O(q·n).
E · Edge cases and pitfalls3Double-counting the LCA — add it exactly once after the loops.Explore edge casesHide edge cases
Pitfalls
- Double-counting the LCA — add it exactly once after the loops.
- Accepting O(n) per query — fine for one query, too slow for many.
- Recursive setup DFS on a chain — stack overflow; use an explicit stack.
Approach 2 of 2 · Optimal
Heavy-Light Decomposition + Segment Tree (Optimal)
1Hint 1Explore hintHide hint
First DFS: compute each node's subtree size and mark its heavy child (the child with the largest subtree). Heavy edges chain nodes together.
2Hint 2Explore hintHide hint
Second DFS: walk heavy children first so a chain's nodes get consecutive positions pos[node]; record head[node] = the top of the chain node belongs to. Build a segment tree over the base array indexed by pos.
3Hint 3Explore hintHide hint
Path query: while u and v are on different chains, take the segment-tree range for the deeper head's chain, then jump to parent[head] of that chain; once on the same chain, combine the final in-chain range. Each jump crosses one light edge, and there are at most O(log n) of those.
A · Core idea
Heavy-light decomposition relabels the tree so that arbitrary paths become a few contiguous ranges, then lets a segment tree do the aggregation.
Step 1 — choose heavy edges (why subtree size). For each node, its heavy child is the child with the largest subtree; the edge to it is a heavy edge, every other child edge is light. The reason for picking by subtree size is the crucial guarantee: when you descend a light edge from a node, the child's subtree is at most half the parent's (otherwise that child would have been the heavy one). Halving can happen at most log₂ n times along any downward path, so any root-to-node path crosses at most O(log n) light edges — equivalently, it passes through at most O(log n) heavy chains.
Step 2 — lay chains contiguously. A chain is a maximal run of heavy edges. In a second DFS we always recurse into the heavy child first, assigning positions pos[node] in visit order. This makes every chain occupy a contiguous block of positions, and we record head[node] = the topmost node of the chain containing node. We then build a segment tree over the base array where index pos[node] holds node's value.
Step 3 — answer a path query. To aggregate the path u … v, repeatedly look at the chain heads. While head[u] ≠ head[v], the two nodes are on different chains: pick whichever has the deeper head (say u), aggregate the contiguous range [pos[head[u]], pos[u]] from the segment tree (the whole portion of u's chain from its head down to u), then hop to the chain above by setting u = parent[head[u]]. Each hop crosses exactly one light edge, so this loop runs O(log n) times. When head[u] == head[v] the nodes are finally on the same chain; aggregate the single range between them ([pos[deeper], pos[shallower]]) and combine. The deeper-head node is implicitly the LCA's chain.
Why O(log² n). O(log n) chains, each costing an O(log n) segment-tree range query → O(log² n). Updates touch one position, so they are a plain O(log n) segment-tree update.
Node vs edge values. With values on nodes, include the LCA's position in the final same-chain range. With values on edges, store each edge's value at its deeper endpoint and exclude the LCA from that last range (pos[lca] + 1 instead of pos[lca]) so the edge above the LCA is not wrongly added — the standard HLD off-by-one.
How it relates to other tools. The Euler tour makes subtrees contiguous; HLD makes paths (in pieces) contiguous. Binary lifting answers LCA alone. HLD is the heavyweight when you need aggregates along paths under updates.
B · Visual dry run
Interactive dry run
Select a frame to connect the decision with its visual state.
tree 0-1,0-2,1-3,1-4; values=[1,2,3,4,5]; pathQuery(3,4) sumInteractive diagram
Path 3 to 4 as two chain ranges
headsNode 4 is a light child of 1 (its own chain); the rest of the path lives in the chain {0,1,3}. Two segment-tree ranges total.
11C · Code with teaching comments
D · Why it works and complexityO(n) build, O(log² n) path query, O(log n) update timeO(n) spaceOpen the invariant and proofExplore the proofHide the proof
The entire power comes from one combinatorial fact: choosing the largest-subtree child as heavy forces light edges to be rare (≤ log n on any path). Rarity of light edges is what bounds the number of contiguous ranges a path decomposes into — and contiguous ranges are exactly what segment trees love. HLD is 'make the expensive thing (changing chains) rare, then reuse array structures'.
ComplexityO(n) build, O(log² n) path query, O(log n) update time, O(n) space — Both build DFS passes are O(n); building the segment tree is O(n). A path query crosses O(log n) chains (the light-edge bound), and each chain contributes one segment-tree range query costing O(log n) → O(log² n) per path query. A point update is one segment-tree update → O(log n).
E · Edge cases and pitfalls7pathQuery(node, node) — Same chain immediately; the final range is [pos[node], pos[node]] → the node's own value.Explore edge casesHide edge cases
Edge cases handled
pathQuery(node, node)Same chain immediately; the final range is [pos[node], pos[node]] → the node's own value.straight chain treeEverything is one heavy chain; any path is a single contiguous range — HLD degenerates to a plain segment-tree range query.edge-valued variantStore edge values at the deeper endpoint and use pos[lca] + 1 for the final range so the edge above the LCA is excluded.
Pitfalls
- Choosing the heavy child by depth or degree instead of subtree size — loses the O(log n) light-edge bound.
- Not visiting the heavy child first in DFS 2 — chains stop being contiguous and ranges break.
- Double-counting or omitting the LCA in the final same-chain range (especially for edge values).
- Recursive setup on a deep chain — stack overflow; build the two DFS passes iteratively.
Practice
Try the solution yourself
Edit the optimal implementation, test another input, and observe the result.
Editable code previewimport java.util.*; class HLD { private final int n; private final List<List<Integer>> adj;Explore the playgroundClose the playground
Avoid mistakes
Recognize the interview traps
Understand why common attempts fail before reviewing the corrected implementation.
1Wrong heavy-child rule
The O(log n) bound on light edges holds ONLY because the heavy child has the largest subtree, forcing every light edge to at least halve the remaining subtree. Picking by depth or first-child breaks the guarantee and paths can touch O(n) chains.
Explore the code comparisonHide the code comparison
Wrong heavy-child rule
The O(log n) bound on light edges holds ONLY because the heavy child has the largest subtree, forcing every light edge to at least halve the remaining subtree. Picking by depth or first-child breaks the guarantee and paths can touch O(n) chains.
Don't write this
self.heavy[node] = self.adj[node][0] # arbitrary first childWrite this instead
best, best_size = -1, 0
for c in self.adj[node]:
if c != self.parent[node] and self.size[c] > best_size:
best_size, best = self.size[c], c
self.heavy[node] = best2Heavy child not visited first
Contiguous chains require the heavy child to receive the next consecutive position. If light children are positioned in between, a chain's nodes are scattered and a single segment-tree range no longer covers the chain.
Explore the code comparisonHide the code comparison
Heavy child not visited first
Contiguous chains require the heavy child to receive the next consecutive position. If light children are positioned in between, a chain's nodes are scattered and a single segment-tree range no longer covers the chain.
Don't write this
for (int c : adj.get(node)) {
if (c != parent[node]) st.push(new int[]{c, c}); // heavy gets a new head too
}Write this instead
for (int c : adj.get(node))
if (c != parent[node] && c != heavy[node]) st.push(new int[]{c, c});
if (heavy[node] != -1) st.push(new int[]{heavy[node], head[node]}); // same chain, popped first3LCA handling in the final segment
For node values the LCA must be counted once — include pos[lca]. For edge values it must be excluded — use pos[lca] + 1 — or you add the edge above the LCA that is not on the path.
Explore the code comparisonHide the code comparison
LCA handling in the final segment
For node values the LCA must be counted once — include pos[lca]. For edge values it must be excluded — use pos[lca] + 1 — or you add the edge above the LCA that is not on the path.
Don't write this
# edge-valued tree, but including the LCA position:
res += self._seg_query(1, 0, self.n - 1, self.pos[u], self.pos[v])Write this instead
# edge-valued tree: skip the LCA's own position
res += self._seg_query(1, 0, self.n - 1, self.pos[u] + 1, self.pos[v])Revise
Carry the pattern into the interview
Pattern to remember
Heavy child = biggest subtree; chains laid contiguously; path = O(log n) chain ranges
Code pattern to memorize
heavy[node] = child with max subtree size
# DFS 1: subtree sizes + heavy child
# DFS 2: assign pos[node] along chains; head[node] = chain top
query(u, v):
res = identity
while head[u] != head[v]:
if depth[head[u]] < depth[head[v]]: swap(u, v)
res = combine(res, seg.query(pos[head[u]], pos[u]))
u = parent[head[u]]
# now same chain: combine the final in-chain range
if depth[u] > depth[v]: swap(u, v)
res = combine(res, seg.query(pos[u], pos[v]))Rules to remember
- 1Heavy child = the child whose subtree is largest; ties broken arbitrarily. Every other child starts a new (light) chain.
- 2A light edge at least halves the subtree size going down, so any root-to-leaf path has at most log2 n light edges → O(log n) chains.
- 3Lay each chain contiguously in the base array so a chain segment is one segment-tree range.
- 4head[node] is the topmost node of node's chain; jump
u = parent[head[u]]to hop to the next chain up. - 5Climb the deeper head each step (compare depth[head[u]] vs depth[head[v]]) — this is implicitly walking toward the LCA.
Use this pattern when
- Path queries/updates between two nodes (sum, max, add) on a static-shape tree
- Mixing path aggregates with subtree aggregates (HLD orders nodes so both are ranges)
- Repeated 'value on the path from u to v' questions where naive walking is too slow
- When you already know segment trees and need to lift them to tree paths
Choose another pattern when
- Only subtree queries, no paths → a plain Euler tour + Fenwick tree is simpler
- Only LCA / distance, no aggregates → binary lifting is shorter
- Tree changes shape between queries → link-cut trees handle dynamic trees instead
- A single path query → just walk it in O(n); HLD's setup is not worth it
Interview takeaway
HLD is the bridge from 'range structure on an array' to 'range structure on tree paths'. By choosing heavy edges so light edges are rare (≤ log n on any path), it guarantees every path is a small union of contiguous chain ranges — letting a segment tree answer path queries in O(log^2 n).
Continue
Transfer the pattern to another problem
Use a direct variation or pattern sibling to prove the idea is reusable.
Transferable patternHeavy-light decomposition: mark the largest-subtree child of each node as heavy, lay the resulting chains contiguously, and back the array with a segment tree. Because light edges are at most O(log n) on any path, a path splits into O(log n) contiguous chain ranges, giving O(log² n) path queries and O(log n) updates. The standard tool for path aggregates with updates on a static-shape tree.
Practice queueDirect variations and pattern siblings
Direct follow-ups
- Path Update (add x to every node on a path)Coming soonSwap the segment tree for one with lazy propagation; decompose the path the same way and range-add each chain segment.
- Subtree + Path Queries TogetherComing soonHLD's pos ordering also makes each subtree contiguous (pos[node] .. pos[node] + size[node] − 1), so one segment tree serves both.
- Euler Tour of a TreeIf you only need subtree queries, the simpler Euler tour + Fenwick tree is one log factor faster.
- LCA with Binary LiftingWhen you only need the LCA or distance (no aggregates), lifting is shorter than full HLD.