Heavy-Light Decomposition Under the Hood: A Step-by-Step Walkthrough

Algorithms & Data Structures

Heavy-Light Decomposition Under the Hood: A Step-by-Step Walkthrough

Imagine you have an array of numbers, and you are asked to repeatedly find the maximum value within various ranges, or update individual elements. You would immediately reach for a Segment Tree, solving both queries and updates in $O(\log N)$ time. But what if the data is not in a straight line? What if your data is structured as a massive, unrooted tree (a connected acyclic graph) representing a corporate hierarchy or a routing network, and you need to query the maximum value along the shortest path between any two arbitrary nodes?

A standard Segment Tree is utterly useless here because a path through a tree does not correspond to a contiguous segment of an array. To solve this efficiently, we must somehow "flatten" the tree into an array such that any path between two nodes can be broken down into a very small number of contiguous segments. This is exactly what Heavy-Light Decomposition (HLD) achieves. In this deep dive, we will build an accurate mental model of HLD, prove why it guarantees logarithmic bounds, perform a step-by-step trace to flatten a tree, and uncover the developer pitfalls that commonly ruin implementations.


1. The Intuition: Express Trains and Local Stops

Before we introduce mathematical formalism, let's build a mental model. Imagine the tree as a massive subway system. Nodes are stations, and edges are the tracks connecting them. If you want to travel from a station in the deep suburbs to a station on the other side of the city, taking a local train that stops at every single station (a standard graph traversal) takes $O(N)$ time.

To speed this up, the transit authority introduces Express Lines. An Express Line is a contiguous, non-branching track that goes straight towards the city center. When you travel, you ride the Express Line as far as you can. When the Express Line no longer takes you where you want to go, you step off, walk across the platform (a Local Transfer), and board another Express Line.

Heavy-Light Decomposition is the algorithm that designs this subway map. It intelligently assigns edges to be either "Heavy" (part of an Express Line) or "Light" (a Local Transfer). The magic of HLD is how it assigns these edges to guarantee that no matter which two stations you travel between, you will never have to make more than $O(\log N)$ local transfers.

Developer Pitfall — Misunderstanding the Goal:

Many developers think HLD is a data structure. It is not. HLD is a tree traversal and numbering strategy. Its only job is to map a 2D graph structure into a 1D array. Once the mapping is done, HLD steps back, and you use a standard 1D Segment Tree (or Fenwick Tree) over that flattened array to actually answer the queries.


2. Defining Heavy and Light Edges

2.1 Subtree Sizes

To determine which edges become heavy and which become light, we must first root the tree arbitrarily (node 0 or 1 is fine). We then run a Depth First Search (DFS) to calculate the subtree size of every node. The subtree size of node $u$, denoted as $size(u)$, is the total number of nodes in the subtree rooted at $u$, including $u$ itself.

2.2 The Selection Rule

For every non-leaf node $u$, we look at all of its immediate children. We find the child $v$ that has the strictly largest subtree size. (If there is a tie, we can pick any of the tied children).

  • The edge from $u$ to this largest child $v$ is classified as a Heavy Edge.
  • The edges from $u$ to all other children are classified as Light Edges.

Because every node selects exactly one Heavy Edge leading down to a child, these Heavy Edges naturally link together to form non-branching paths going down the tree. We call these Heavy Chains. A node that is connected to its parent via a Light Edge is considered the "head" of a new Heavy Chain.

2.3 The Core Theorem: Why $O(\log N)$ Light Edges?

If we walk from any node $u$ up to the root, how many Light Edges will we traverse? The mathematics is beautifully simple.

Suppose we traverse a Light Edge from node $v$ up to its parent $u$. Because the edge $(u, v)$ is Light, we know that $u$ must have some other child $w$ that was chosen as the Heavy child. By definition, $size(w) \ge size(v)$.

Therefore, the total size of the parent's subtree must be at least the size of $v$'s subtree plus the size of $w$'s subtree, plus the parent itself:

$$size(u) > size(v) + size(w) \ge 2 \times size(v)$$

This inequality is the secret to HLD. Every time we walk up a Light Edge, the size of the subtree we are standing in at least doubles. Since the maximum possible subtree size is $N$ (the total number of nodes in the tree), we can only double the size $\log_2(N)$ times before we hit the root. Therefore, any path from a node to the root contains at most $O(\log N)$ Light Edges!

Because Heavy Chains are contiguous segments, a path to the root consists of jumping up a Heavy Chain, taking a Light Edge transfer, jumping up the next Heavy Chain, and so on. This means the path is broken into at most $O(\log N)$ contiguous Heavy Chain segments.


3. The DFS Strategy: Flattening the Tree

We know that Heavy Chains are contiguous paths. To make Segment Trees work, nodes on the same Heavy Chain must be assigned contiguous indices in our 1D array. We achieve this using a very specific Depth First Search (DFS) order.

Standard DFS visits children in whatever order they appear in the adjacency list. For HLD, we modify our DFS to always visit the Heavy Child first, before visiting any Light Children. By diving down the Heavy Chain completely before backtracking, we ensure that all nodes in a Heavy Chain receive sequential DFS discovery timestamps.

3.1 The Two-Pass DFS Implementation

Implementation generally requires two DFS passes:

  • DFS 1 (Information Gathering): Computes depths, parents, and subtree sizes. Crucially, it identifies the "heavy child" for each node and swaps it to the 0th index of the adjacency list so it gets visited first in the next pass.
  • DFS 2 (Flattening): Traverses the tree, tracking the "head" (top-most node) of the current Heavy Chain. It assigns sequential array positions to nodes as it visits them.
vector<int> parent, depth, heavy, head, pos;
int current_pos = 0;

// DFS 1: Calculate subtree sizes and heavy edges
int dfs1(int v, int p) {
    int size = 1, max_child_size = 0;
    for (int c : adj[v]) {
        if (c != p) {
            parent[c] = v, depth[c] = depth[v] + 1;
            int c_size = dfs1(c, v);
            size += c_size;
            if (c_size > max_child_size) {
                max_child_size = c_size;
                heavy[v] = c; // Record the heavy child
            }
        }
    }
    return size;
}

// DFS 2: Assign segment tree positions and track chain heads
void dfs2(int v, int p, int chain_head) {
    head[v] = chain_head;     // The top of this node's express line
    pos[v] = current_pos++;   // Flattened array index
    
    if (heavy[v] != -1) {
        // ALWAYS visit heavy child first to keep the chain contiguous
        dfs2(heavy[v], v, chain_head);
    }
    
    for (int c : adj[v]) {
        if (c != p && c != heavy[v]) {
            // Light children start their own new chains
            dfs2(c, v, c);
        }
    }
}
Developer Pitfall — Base Array Mapping:

After `dfs2` finishes, you cannot just build the Segment Tree on your original value array `V`. Node 3 might have `pos[3] = 7`. You must create a new array `mapped_V` where `mapped_V[7] = V[3]`, and then build your Segment Tree over `mapped_V`. Forgetting this mapping will result in querying completely randomized data.


4. Executing the Query

Once the tree is flattened, how do we query the path between $u$ and $v$? We use a technique similar to finding the Lowest Common Ancestor (LCA). We examine the `head` of the chains that $u$ and $v$ are currently on.

  • If `head[u]` and `head[v]` are different, they are on different Express Lines. We take the node whose chain head is deeper in the tree (has a greater depth) and "jump" it up.
  • We query the Segment Tree for the contiguous range from `pos[head[u]]` to `pos[u]`. (Because it's a Heavy Chain, they are contiguous in the array!)
  • We then update $u$ to be `parent[head[u]]`, jumping across the Light Edge to the bottom of the next chain.
  • We repeat this until $u$ and $v$ finally land on the exact same Heavy Chain (`head[u] == head[v]`).
  • Once they are on the same chain, the path between them is just a single contiguous segment! We do one final Segment Tree query between `pos[u]` and `pos[v]` (making sure to query from the smaller position to the larger).
int query(int u, int v) {
    int res = 0; // Or -INFINITY for max queries
    // While they are on different chains...
    while (head[u] != head[v]) {
        // Force u to be the one deeper in the tree
        if (depth[head[u]] < depth[head[v]]) swap(u, v);
        
        // Query the segment tree for u's current chain
        res = combine(res, seg_query(pos[head[u]], pos[u]));
        
        // Jump u up to the parent of its chain head
        u = parent[head[u]];
    }
    
    // Now they are on the same chain.
    if (depth[u] > depth[v]) swap(u, v);
    
    // Final query for the segment between them
    res = combine(res, seg_query(pos[u], pos[v]));
    
    return res;
}
Developer Pitfall — Edge vs Node Queries:

The code above assumes values are stored on the Nodes. If your values are stored on the Edges (e.g., edge weights), the logic changes slightly. When $u$ and $v$ land on the same chain, the Lowest Common Ancestor (the highest node) does not contain an edge weight belonging to the path. You must query `seg_query(pos[u] + 1, pos[v])`. Adding `+ 1` skips the LCA node, avoiding including an edge leading out of the path.


5. Frequently Asked Questions

Q1: What is the time complexity of an HLD update or query?

A path between any two nodes is split into at most $O(\log N)$ heavy chain segments. For each segment, we perform a Segment Tree query or update, which takes $O(\log N)$ time. Therefore, the total time complexity per query or update is $O(\log^2 N)$. Building the structure takes $O(N)$ for the two DFS passes, plus $O(N \log N)$ or $O(N)$ to build the underlying Segment Tree.

Q2: Can HLD handle tree structural changes (adding/removing edges)?

No. HLD relies on a static tree structure because the DFS assignment array (`pos`) is immutable once built. If edges are dynamically added or removed, subtree sizes change, invalidating the heavy/light edge assignments and destroying the contiguous array mappings. If you need dynamic tree topology (e.g., link-cut operations), you must use a much more complex data structure called a Link-Cut Tree.

Q3: How do we handle subtree queries in HLD?

HLD natively supports subtree queries flawlessly! Because our DFS visits all nodes in a subtree before backtracking, all nodes within the subtree of node $u$ are guaranteed to occupy a single contiguous block in the flattened array, starting exactly at `pos[u]`. The end of the block is `pos[u] + size(u) - 1`. To query a subtree, you just perform a single $O(\log N)$ segment tree query over that range.

Q4: Are there alternatives to HLD for path queries?

Yes. If your operations are commutative and invertable (like path sums, where you can add and subtract), you can use Euler Tour Trees or Binary Lifting combined with prefix sums. However, if your operation is non-invertable (like path maximums), Binary Lifting is too slow for updates ($O(N)$), leaving HLD and Link-Cut Trees as the only viable options.


Written by Professor Pixel · CodingPancake · Algorithms & Data Structures Series

1. The Intuition: Express Trains and Local Stops

Before we introduce mathematical formalism, let's build a mental model. Imagine the tree as a massive subway system. Nodes are stations, and edges are the tracks connecting them. If you want to travel from a station in the deep suburbs to a station on the other side of the city, taking a local train that stops at every single station (a standard graph traversal) takes $O(N)$ time.

To speed this up, the transit authority introduces Express Lines. An Express Line is a contiguous, non-branching track that goes straight towards the city center. When you travel, you ride the Express Line as far as you can. When the Express Line no longer takes you where you want to go, you step off, walk across the platform (a Local Transfer), and board another Express Line.

Heavy-Light Decomposition is the algorithm that designs this subway map. It intelligently assigns edges to be either "Heavy" (part of an Express Line) or "Light" (a Local Transfer). The magic of HLD is how it assigns these edges to guarantee that no matter which two stations you travel between, you will never have to make more than $O(\log N)$ local transfers.

Developer Pitfall — Misunderstanding the Goal:

Many developers think HLD is a data structure. It is not. HLD is a tree traversal and numbering strategy. Its only job is to map a 2D graph structure into a 1D array. Once the mapping is done, HLD steps back, and you use a standard 1D Segment Tree (or Fenwick Tree) over that flattened array to actually answer the queries.


2. Defining Heavy and Light Edges

2.1 Subtree Sizes

To determine which edges become heavy and which become light, we must first root the tree arbitrarily (node 0 or 1 is fine). We then run a Depth First Search (DFS) to calculate the subtree size of every node. The subtree size of node $u$, denoted as $size(u)$, is the total number of nodes in the subtree rooted at $u$, including $u$ itself.

2.2 The Selection Rule

For every non-leaf node $u$, we look at all of its immediate children. We find the child $v$ that has the strictly largest subtree size. (If there is a tie, we can pick any of the tied children).

  • The edge from $u$ to this largest child $v$ is classified as a Heavy Edge.
  • The edges from $u$ to all other children are classified as Light Edges.

Because every node selects exactly one Heavy Edge leading down to a child, these Heavy Edges naturally link together to form non-branching paths going down the tree. We call these Heavy Chains. A node that is connected to its parent via a Light Edge is considered the "head" of a new Heavy Chain.

2.3 The Core Theorem: Why $O(\log N)$ Light Edges?

If we walk from any node $u$ up to the root, how many Light Edges will we traverse? The mathematics is beautifully simple.

Suppose we traverse a Light Edge from node $v$ up to its parent $u$. Because the edge $(u, v)$ is Light, we know that $u$ must have some other child $w$ that was chosen as the Heavy child. By definition, $size(w) \ge size(v)$.

Therefore, the total size of the parent's subtree must be at least the size of $v$'s subtree plus the size of $w$'s subtree, plus the parent itself:

$$size(u) > size(v) + size(w) \ge 2 \times size(v)$$

This inequality is the secret to HLD. Every time we walk up a Light Edge, the size of the subtree we are standing in at least doubles. Since the maximum possible subtree size is $N$ (the total number of nodes in the tree), we can only double the size $\log_2(N)$ times before we hit the root. Therefore, any path from a node to the root contains at most $O(\log N)$ Light Edges!

Because Heavy Chains are contiguous segments, a path to the root consists of jumping up a Heavy Chain, taking a Light Edge transfer, jumping up the next Heavy Chain, and so on. This means the path is broken into at most $O(\log N)$ contiguous Heavy Chain segments.


3. The DFS Strategy: Flattening the Tree

We know that Heavy Chains are contiguous paths. To make Segment Trees work, nodes on the same Heavy Chain must be assigned contiguous indices in our 1D array. We achieve this using a very specific Depth First Search (DFS) order.

Standard DFS visits children in whatever order they appear in the adjacency list. For HLD, we modify our DFS to always visit the Heavy Child first, before visiting any Light Children. By diving down the Heavy Chain completely before backtracking, we ensure that all nodes in a Heavy Chain receive sequential DFS discovery timestamps.

3.1 The Two-Pass DFS Implementation

Implementation generally requires two DFS passes:

  • DFS 1 (Information Gathering): Computes depths, parents, and subtree sizes. Crucially, it identifies the "heavy child" for each node and swaps it to the 0th index of the adjacency list so it gets visited first in the next pass.
  • DFS 2 (Flattening): Traverses the tree, tracking the "head" (top-most node) of the current Heavy Chain. It assigns sequential array positions to nodes as it visits them.
vector<int> parent, depth, heavy, head, pos;
int current_pos = 0;

// DFS 1: Calculate subtree sizes and heavy edges
int dfs1(int v, int p) {
    int size = 1, max_child_size = 0;
    for (int c : adj[v]) {
        if (c != p) {
            parent[c] = v, depth[c] = depth[v] + 1;
            int c_size = dfs1(c, v);
            size += c_size;
            if (c_size > max_child_size) {
                max_child_size = c_size;
                heavy[v] = c; // Record the heavy child
            }
        }
    }
    return size;
}

// DFS 2: Assign segment tree positions and track chain heads
void dfs2(int v, int p, int chain_head) {
    head[v] = chain_head;     // The top of this node's express line
    pos[v] = current_pos++;   // Flattened array index
    
    if (heavy[v] != -1) {
        // ALWAYS visit heavy child first to keep the chain contiguous
        dfs2(heavy[v], v, chain_head);
    }
    
    for (int c : adj[v]) {
        if (c != p && c != heavy[v]) {
            // Light children start their own new chains
            dfs2(c, v, c);
        }
    }
}
Developer Pitfall — Base Array Mapping:

After `dfs2` finishes, you cannot just build the Segment Tree on your original value array `V`. Node 3 might have `pos[3] = 7`. You must create a new array `mapped_V` where `mapped_V[7] = V[3]`, and then build your Segment Tree over `mapped_V`. Forgetting this mapping will result in querying completely randomized data.


4. Executing the Query

Once the tree is flattened, how do we query the path between $u$ and $v$? We use a technique similar to finding the Lowest Common Ancestor (LCA). We examine the `head` of the chains that $u$ and $v$ are currently on.

  • If `head[u]` and `head[v]` are different, they are on different Express Lines. We take the node whose chain head is deeper in the tree (has a greater depth) and "jump" it up.
  • We query the Segment Tree for the contiguous range from `pos[head[u]]` to `pos[u]`. (Because it's a Heavy Chain, they are contiguous in the array!)
  • We then update $u$ to be `parent[head[u]]`, jumping across the Light Edge to the bottom of the next chain.
  • We repeat this until $u$ and $v$ finally land on the exact same Heavy Chain (`head[u] == head[v]`).
  • Once they are on the same chain, the path between them is just a single contiguous segment! We do one final Segment Tree query between `pos[u]` and `pos[v]` (making sure to query from the smaller position to the larger).
int query(int u, int v) {
    int res = 0; // Or -INFINITY for max queries
    // While they are on different chains...
    while (head[u] != head[v]) {
        // Force u to be the one deeper in the tree
        if (depth[head[u]] < depth[head[v]]) swap(u, v);
        
        // Query the segment tree for u's current chain
        res = combine(res, seg_query(pos[head[u]], pos[u]));
        
        // Jump u up to the parent of its chain head
        u = parent[head[u]];
    }
    
    // Now they are on the same chain.
    if (depth[u] > depth[v]) swap(u, v);
    
    // Final query for the segment between them
    res = combine(res, seg_query(pos[u], pos[v]));
    
    return res;
}
Developer Pitfall — Edge vs Node Queries:

The code above assumes values are stored on the Nodes. If your values are stored on the Edges (e.g., edge weights), the logic changes slightly. When $u$ and $v$ land on the same chain, the Lowest Common Ancestor (the highest node) does not contain an edge weight belonging to the path. You must query `seg_query(pos[u] + 1, pos[v])`. Adding `+ 1` skips the LCA node, avoiding including an edge leading out of the path.


5. Frequently Asked Questions

Q1: What is the time complexity of an HLD update or query?

A path between any two nodes is split into at most $O(\log N)$ heavy chain segments. For each segment, we perform a Segment Tree query or update, which takes $O(\log N)$ time. Therefore, the total time complexity per query or update is $O(\log^2 N)$. Building the structure takes $O(N)$ for the two DFS passes, plus $O(N \log N)$ or $O(N)$ to build the underlying Segment Tree.

Q2: Can HLD handle tree structural changes (adding/removing edges)?

No. HLD relies on a static tree structure because the DFS assignment array (`pos`) is immutable once built. If edges are dynamically added or removed, subtree sizes change, invalidating the heavy/light edge assignments and destroying the contiguous array mappings. If you need dynamic tree topology (e.g., link-cut operations), you must use a much more complex data structure called a Link-Cut Tree.

Q3: How do we handle subtree queries in HLD?

HLD natively supports subtree queries flawlessly! Because our DFS visits all nodes in a subtree before backtracking, all nodes within the subtree of node $u$ are guaranteed to occupy a single contiguous block in the flattened array, starting exactly at `pos[u]`. The end of the block is `pos[u] + size(u) - 1`. To query a subtree, you just perform a single $O(\log N)$ segment tree query over that range.

Q4: Are there alternatives to HLD for path queries?

Yes. If your operations are commutative and invertable (like path sums, where you can add and subtract), you can use Euler Tour Trees or Binary Lifting combined with prefix sums. However, if your operation is non-invertable (like path maximums), Binary Lifting is too slow for updates ($O(N)$), leaving HLD and Link-Cut Trees as the only viable options.


Written by Professor Pixel · CodingPancake · Algorithms & Data Structures Series

1. The Intuition: Express Trains and Local Stops

Before we introduce mathematical formalism, let's build a mental model. Imagine the tree as a massive subway system. Nodes are stations, and edges are the tracks connecting them. If you want to travel from a station in the deep suburbs to a station on the other side of the city, taking a local train that stops at every single station (a standard graph traversal) takes $O(N)$ time.

To speed this up, the transit authority introduces Express Lines. An Express Line is a contiguous, non-branching track that goes straight towards the city center. When you travel, you ride the Express Line as far as you can. When the Express Line no longer takes you where you want to go, you step off, walk across the platform (a Local Transfer), and board another Express Line.

Heavy-Light Decomposition is the algorithm that designs this subway map. It intelligently assigns edges to be either "Heavy" (part of an Express Line) or "Light" (a Local Transfer). The magic of HLD is how it assigns these edges to guarantee that no matter which two stations you travel between, you will never have to make more than $O(\log N)$ local transfers.

Developer Pitfall — Misunderstanding the Goal:

Many developers think HLD is a data structure. It is not. HLD is a tree traversal and numbering strategy. Its only job is to map a 2D graph structure into a 1D array. Once the mapping is done, HLD steps back, and you use a standard 1D Segment Tree (or Fenwick Tree) over that flattened array to actually answer the queries.


2. Defining Heavy and Light Edges

2.1 Subtree Sizes

To determine which edges become heavy and which become light, we must first root the tree arbitrarily (node 0 or 1 is fine). We then run a Depth First Search (DFS) to calculate the subtree size of every node. The subtree size of node $u$, denoted as $size(u)$, is the total number of nodes in the subtree rooted at $u$, including $u$ itself.

2.2 The Selection Rule

For every non-leaf node $u$, we look at all of its immediate children. We find the child $v$ that has the strictly largest subtree size. (If there is a tie, we can pick any of the tied children).

  • The edge from $u$ to this largest child $v$ is classified as a Heavy Edge.
  • The edges from $u$ to all other children are classified as Light Edges.

Because every node selects exactly one Heavy Edge leading down to a child, these Heavy Edges naturally link together to form non-branching paths going down the tree. We call these Heavy Chains. A node that is connected to its parent via a Light Edge is considered the "head" of a new Heavy Chain.

2.3 The Core Theorem: Why $O(\log N)$ Light Edges?

If we walk from any node $u$ up to the root, how many Light Edges will we traverse? The mathematics is beautifully simple.

Suppose we traverse a Light Edge from node $v$ up to its parent $u$. Because the edge $(u, v)$ is Light, we know that $u$ must have some other child $w$ that was chosen as the Heavy child. By definition, $size(w) \ge size(v)$.

Therefore, the total size of the parent's subtree must be at least the size of $v$'s subtree plus the size of $w$'s subtree, plus the parent itself:

$$size(u) > size(v) + size(w) \ge 2 \times size(v)$$

This inequality is the secret to HLD. Every time we walk up a Light Edge, the size of the subtree we are standing in at least doubles. Since the maximum possible subtree size is $N$ (the total number of nodes in the tree), we can only double the size $\log_2(N)$ times before we hit the root. Therefore, any path from a node to the root contains at most $O(\log N)$ Light Edges!

Because Heavy Chains are contiguous segments, a path to the root consists of jumping up a Heavy Chain, taking a Light Edge transfer, jumping up the next Heavy Chain, and so on. This means the path is broken into at most $O(\log N)$ contiguous Heavy Chain segments.


3. The DFS Strategy: Flattening the Tree

We know that Heavy Chains are contiguous paths. To make Segment Trees work, nodes on the same Heavy Chain must be assigned contiguous indices in our 1D array. We achieve this using a very specific Depth First Search (DFS) order.

Standard DFS visits children in whatever order they appear in the adjacency list. For HLD, we modify our DFS to always visit the Heavy Child first, before visiting any Light Children. By diving down the Heavy Chain completely before backtracking, we ensure that all nodes in a Heavy Chain receive sequential DFS discovery timestamps.

3.1 The Two-Pass DFS Implementation

Implementation generally requires two DFS passes:

  • DFS 1 (Information Gathering): Computes depths, parents, and subtree sizes. Crucially, it identifies the "heavy child" for each node and swaps it to the 0th index of the adjacency list so it gets visited first in the next pass.
  • DFS 2 (Flattening): Traverses the tree, tracking the "head" (top-most node) of the current Heavy Chain. It assigns sequential array positions to nodes as it visits them.
vector<int> parent, depth, heavy, head, pos;
int current_pos = 0;

// DFS 1: Calculate subtree sizes and heavy edges
int dfs1(int v, int p) {
    int size = 1, max_child_size = 0;
    for (int c : adj[v]) {
        if (c != p) {
            parent[c] = v, depth[c] = depth[v] + 1;
            int c_size = dfs1(c, v);
            size += c_size;
            if (c_size > max_child_size) {
                max_child_size = c_size;
                heavy[v] = c; // Record the heavy child
            }
        }
    }
    return size;
}

// DFS 2: Assign segment tree positions and track chain heads
void dfs2(int v, int p, int chain_head) {
    head[v] = chain_head;     // The top of this node's express line
    pos[v] = current_pos++;   // Flattened array index
    
    if (heavy[v] != -1) {
        // ALWAYS visit heavy child first to keep the chain contiguous
        dfs2(heavy[v], v, chain_head);
    }
    
    for (int c : adj[v]) {
        if (c != p && c != heavy[v]) {
            // Light children start their own new chains
            dfs2(c, v, c);
        }
    }
}
Developer Pitfall — Base Array Mapping:

After `dfs2` finishes, you cannot just build the Segment Tree on your original value array `V`. Node 3 might have `pos[3] = 7`. You must create a new array `mapped_V` where `mapped_V[7] = V[3]`, and then build your Segment Tree over `mapped_V`. Forgetting this mapping will result in querying completely randomized data.


4. Executing the Query

Once the tree is flattened, how do we query the path between $u$ and $v$? We use a technique similar to finding the Lowest Common Ancestor (LCA). We examine the `head` of the chains that $u$ and $v$ are currently on.

  • If `head[u]` and `head[v]` are different, they are on different Express Lines. We take the node whose chain head is deeper in the tree (has a greater depth) and "jump" it up.
  • We query the Segment Tree for the contiguous range from `pos[head[u]]` to `pos[u]`. (Because it's a Heavy Chain, they are contiguous in the array!)
  • We then update $u$ to be `parent[head[u]]`, jumping across the Light Edge to the bottom of the next chain.
  • We repeat this until $u$ and $v$ finally land on the exact same Heavy Chain (`head[u] == head[v]`).
  • Once they are on the same chain, the path between them is just a single contiguous segment! We do one final Segment Tree query between `pos[u]` and `pos[v]` (making sure to query from the smaller position to the larger).
int query(int u, int v) {
    int res = 0; // Or -INFINITY for max queries
    // While they are on different chains...
    while (head[u] != head[v]) {
        // Force u to be the one deeper in the tree
        if (depth[head[u]] < depth[head[v]]) swap(u, v);
        
        // Query the segment tree for u's current chain
        res = combine(res, seg_query(pos[head[u]], pos[u]));
        
        // Jump u up to the parent of its chain head
        u = parent[head[u]];
    }
    
    // Now they are on the same chain.
    if (depth[u] > depth[v]) swap(u, v);
    
    // Final query for the segment between them
    res = combine(res, seg_query(pos[u], pos[v]));
    
    return res;
}
Developer Pitfall — Edge vs Node Queries:

The code above assumes values are stored on the Nodes. If your values are stored on the Edges (e.g., edge weights), the logic changes slightly. When $u$ and $v$ land on the same chain, the Lowest Common Ancestor (the highest node) does not contain an edge weight belonging to the path. You must query `seg_query(pos[u] + 1, pos[v])`. Adding `+ 1` skips the LCA node, avoiding including an edge leading out of the path.


5. Frequently Asked Questions

Q1: What is the time complexity of an HLD update or query?

A path between any two nodes is split into at most $O(\log N)$ heavy chain segments. For each segment, we perform a Segment Tree query or update, which takes $O(\log N)$ time. Therefore, the total time complexity per query or update is $O(\log^2 N)$. Building the structure takes $O(N)$ for the two DFS passes, plus $O(N \log N)$ or $O(N)$ to build the underlying Segment Tree.

Q2: Can HLD handle tree structural changes (adding/removing edges)?

No. HLD relies on a static tree structure because the DFS assignment array (`pos`) is immutable once built. If edges are dynamically added or removed, subtree sizes change, invalidating the heavy/light edge assignments and destroying the contiguous array mappings. If you need dynamic tree topology (e.g., link-cut operations), you must use a much more complex data structure called a Link-Cut Tree.

Q3: How do we handle subtree queries in HLD?

HLD natively supports subtree queries flawlessly! Because our DFS visits all nodes in a subtree before backtracking, all nodes within the subtree of node $u$ are guaranteed to occupy a single contiguous block in the flattened array, starting exactly at `pos[u]`. The end of the block is `pos[u] + size(u) - 1`. To query a subtree, you just perform a single $O(\log N)$ segment tree query over that range.

Q4: Are there alternatives to HLD for path queries?

Yes. If your operations are commutative and invertable (like path sums, where you can add and subtract), you can use Euler Tour Trees or Binary Lifting combined with prefix sums. However, if your operation is non-invertable (like path maximums), Binary Lifting is too slow for updates ($O(N)$), leaving HLD and Link-Cut Trees as the only viable options.


Written by Professor Pixel · CodingPancake · Algorithms & Data Structures Series

1. The Intuition: Express Trains and Local Stops

Before we introduce mathematical formalism, let's build a mental model. Imagine the tree as a massive subway system. Nodes are stations, and edges are the tracks connecting them. If you want to travel from a station in the deep suburbs to a station on the other side of the city, taking a local train that stops at every single station (a standard graph traversal) takes $O(N)$ time.

To speed this up, the transit authority introduces Express Lines. An Express Line is a contiguous, non-branching track that goes straight towards the city center. When you travel, you ride the Express Line as far as you can. When the Express Line no longer takes you where you want to go, you step off, walk across the platform (a Local Transfer), and board another Express Line.

Heavy-Light Decomposition is the algorithm that designs this subway map. It intelligently assigns edges to be either "Heavy" (part of an Express Line) or "Light" (a Local Transfer). The magic of HLD is how it assigns these edges to guarantee that no matter which two stations you travel between, you will never have to make more than $O(\log N)$ local transfers.

Developer Pitfall — Misunderstanding the Goal:

Many developers think HLD is a data structure. It is not. HLD is a tree traversal and numbering strategy. Its only job is to map a 2D graph structure into a 1D array. Once the mapping is done, HLD steps back, and you use a standard 1D Segment Tree (or Fenwick Tree) over that flattened array to actually answer the queries.


2. Defining Heavy and Light Edges

2.1 Subtree Sizes

To determine which edges become heavy and which become light, we must first root the tree arbitrarily (node 0 or 1 is fine). We then run a Depth First Search (DFS) to calculate the subtree size of every node. The subtree size of node $u$, denoted as $size(u)$, is the total number of nodes in the subtree rooted at $u$, including $u$ itself.

2.2 The Selection Rule

For every non-leaf node $u$, we look at all of its immediate children. We find the child $v$ that has the strictly largest subtree size. (If there is a tie, we can pick any of the tied children).

  • The edge from $u$ to this largest child $v$ is classified as a Heavy Edge.
  • The edges from $u$ to all other children are classified as Light Edges.

Because every node selects exactly one Heavy Edge leading down to a child, these Heavy Edges naturally link together to form non-branching paths going down the tree. We call these Heavy Chains. A node that is connected to its parent via a Light Edge is considered the "head" of a new Heavy Chain.

2.3 The Core Theorem: Why $O(\log N)$ Light Edges?

If we walk from any node $u$ up to the root, how many Light Edges will we traverse? The mathematics is beautifully simple.

Suppose we traverse a Light Edge from node $v$ up to its parent $u$. Because the edge $(u, v)$ is Light, we know that $u$ must have some other child $w$ that was chosen as the Heavy child. By definition, $size(w) \ge size(v)$.

Therefore, the total size of the parent's subtree must be at least the size of $v$'s subtree plus the size of $w$'s subtree, plus the parent itself:

$$size(u) > size(v) + size(w) \ge 2 \times size(v)$$

This inequality is the secret to HLD. Every time we walk up a Light Edge, the size of the subtree we are standing in at least doubles. Since the maximum possible subtree size is $N$ (the total number of nodes in the tree), we can only double the size $\log_2(N)$ times before we hit the root. Therefore, any path from a node to the root contains at most $O(\log N)$ Light Edges!

Because Heavy Chains are contiguous segments, a path to the root consists of jumping up a Heavy Chain, taking a Light Edge transfer, jumping up the next Heavy Chain, and so on. This means the path is broken into at most $O(\log N)$ contiguous Heavy Chain segments.


3. The DFS Strategy: Flattening the Tree

We know that Heavy Chains are contiguous paths. To make Segment Trees work, nodes on the same Heavy Chain must be assigned contiguous indices in our 1D array. We achieve this using a very specific Depth First Search (DFS) order.

Standard DFS visits children in whatever order they appear in the adjacency list. For HLD, we modify our DFS to always visit the Heavy Child first, before visiting any Light Children. By diving down the Heavy Chain completely before backtracking, we ensure that all nodes in a Heavy Chain receive sequential DFS discovery timestamps.

3.1 The Two-Pass DFS Implementation

Implementation generally requires two DFS passes:

  • DFS 1 (Information Gathering): Computes depths, parents, and subtree sizes. Crucially, it identifies the "heavy child" for each node and swaps it to the 0th index of the adjacency list so it gets visited first in the next pass.
  • DFS 2 (Flattening): Traverses the tree, tracking the "head" (top-most node) of the current Heavy Chain. It assigns sequential array positions to nodes as it visits them.
vector<int> parent, depth, heavy, head, pos;
int current_pos = 0;

// DFS 1: Calculate subtree sizes and heavy edges
int dfs1(int v, int p) {
    int size = 1, max_child_size = 0;
    for (int c : adj[v]) {
        if (c != p) {
            parent[c] = v, depth[c] = depth[v] + 1;
            int c_size = dfs1(c, v);
            size += c_size;
            if (c_size > max_child_size) {
                max_child_size = c_size;
                heavy[v] = c; // Record the heavy child
            }
        }
    }
    return size;
}

// DFS 2: Assign segment tree positions and track chain heads
void dfs2(int v, int p, int chain_head) {
    head[v] = chain_head;     // The top of this node's express line
    pos[v] = current_pos++;   // Flattened array index
    
    if (heavy[v] != -1) {
        // ALWAYS visit heavy child first to keep the chain contiguous
        dfs2(heavy[v], v, chain_head);
    }
    
    for (int c : adj[v]) {
        if (c != p && c != heavy[v]) {
            // Light children start their own new chains
            dfs2(c, v, c);
        }
    }
}
Developer Pitfall — Base Array Mapping:

After `dfs2` finishes, you cannot just build the Segment Tree on your original value array `V`. Node 3 might have `pos[3] = 7`. You must create a new array `mapped_V` where `mapped_V[7] = V[3]`, and then build your Segment Tree over `mapped_V`. Forgetting this mapping will result in querying completely randomized data.


4. Executing the Query

Once the tree is flattened, how do we query the path between $u$ and $v$? We use a technique similar to finding the Lowest Common Ancestor (LCA). We examine the `head` of the chains that $u$ and $v$ are currently on.

  • If `head[u]` and `head[v]` are different, they are on different Express Lines. We take the node whose chain head is deeper in the tree (has a greater depth) and "jump" it up.
  • We query the Segment Tree for the contiguous range from `pos[head[u]]` to `pos[u]`. (Because it's a Heavy Chain, they are contiguous in the array!)
  • We then update $u$ to be `parent[head[u]]`, jumping across the Light Edge to the bottom of the next chain.
  • We repeat this until $u$ and $v$ finally land on the exact same Heavy Chain (`head[u] == head[v]`).
  • Once they are on the same chain, the path between them is just a single contiguous segment! We do one final Segment Tree query between `pos[u]` and `pos[v]` (making sure to query from the smaller position to the larger).
int query(int u, int v) {
    int res = 0; // Or -INFINITY for max queries
    // While they are on different chains...
    while (head[u] != head[v]) {
        // Force u to be the one deeper in the tree
        if (depth[head[u]] < depth[head[v]]) swap(u, v);
        
        // Query the segment tree for u's current chain
        res = combine(res, seg_query(pos[head[u]], pos[u]));
        
        // Jump u up to the parent of its chain head
        u = parent[head[u]];
    }
    
    // Now they are on the same chain.
    if (depth[u] > depth[v]) swap(u, v);
    
    // Final query for the segment between them
    res = combine(res, seg_query(pos[u], pos[v]));
    
    return res;
}
Developer Pitfall — Edge vs Node Queries:

The code above assumes values are stored on the Nodes. If your values are stored on the Edges (e.g., edge weights), the logic changes slightly. When $u$ and $v$ land on the same chain, the Lowest Common Ancestor (the highest node) does not contain an edge weight belonging to the path. You must query `seg_query(pos[u] + 1, pos[v])`. Adding `+ 1` skips the LCA node, avoiding including an edge leading out of the path.


5. Frequently Asked Questions

Q1: What is the time complexity of an HLD update or query?

A path between any two nodes is split into at most $O(\log N)$ heavy chain segments. For each segment, we perform a Segment Tree query or update, which takes $O(\log N)$ time. Therefore, the total time complexity per query or update is $O(\log^2 N)$. Building the structure takes $O(N)$ for the two DFS passes, plus $O(N \log N)$ or $O(N)$ to build the underlying Segment Tree.

Q2: Can HLD handle tree structural changes (adding/removing edges)?

No. HLD relies on a static tree structure because the DFS assignment array (`pos`) is immutable once built. If edges are dynamically added or removed, subtree sizes change, invalidating the heavy/light edge assignments and destroying the contiguous array mappings. If you need dynamic tree topology (e.g., link-cut operations), you must use a much more complex data structure called a Link-Cut Tree.

Q3: How do we handle subtree queries in HLD?

HLD natively supports subtree queries flawlessly! Because our DFS visits all nodes in a subtree before backtracking, all nodes within the subtree of node $u$ are guaranteed to occupy a single contiguous block in the flattened array, starting exactly at `pos[u]`. The end of the block is `pos[u] + size(u) - 1`. To query a subtree, you just perform a single $O(\log N)$ segment tree query over that range.

Q4: Are there alternatives to HLD for path queries?

Yes. If your operations are commutative and invertable (like path sums, where you can add and subtract), you can use Euler Tour Trees or Binary Lifting combined with prefix sums. However, if your operation is non-invertable (like path maximums), Binary Lifting is too slow for updates ($O(N)$), leaving HLD and Link-Cut Trees as the only viable options.


Written by Professor Pixel · CodingPancake · Algorithms & Data Structures Series

1. The Intuition: Express Trains and Local Stops

Before we introduce mathematical formalism, let's build a mental model. Imagine the tree as a massive subway system. Nodes are stations, and edges are the tracks connecting them. If you want to travel from a station in the deep suburbs to a station on the other side of the city, taking a local train that stops at every single station (a standard graph traversal) takes $O(N)$ time.

To speed this up, the transit authority introduces Express Lines. An Express Line is a contiguous, non-branching track that goes straight towards the city center. When you travel, you ride the Express Line as far as you can. When the Express Line no longer takes you where you want to go, you step off, walk across the platform (a Local Transfer), and board another Express Line.

Heavy-Light Decomposition is the algorithm that designs this subway map. It intelligently assigns edges to be either "Heavy" (part of an Express Line) or "Light" (a Local Transfer). The magic of HLD is how it assigns these edges to guarantee that no matter which two stations you travel between, you will never have to make more than $O(\log N)$ local transfers.

Developer Pitfall — Misunderstanding the Goal:

Many developers think HLD is a data structure. It is not. HLD is a tree traversal and numbering strategy. Its only job is to map a 2D graph structure into a 1D array. Once the mapping is done, HLD steps back, and you use a standard 1D Segment Tree (or Fenwick Tree) over that flattened array to actually answer the queries.


2. Defining Heavy and Light Edges

2.1 Subtree Sizes

To determine which edges become heavy and which become light, we must first root the tree arbitrarily (node 0 or 1 is fine). We then run a Depth First Search (DFS) to calculate the subtree size of every node. The subtree size of node $u$, denoted as $size(u)$, is the total number of nodes in the subtree rooted at $u$, including $u$ itself.

2.2 The Selection Rule

For every non-leaf node $u$, we look at all of its immediate children. We find the child $v$ that has the strictly largest subtree size. (If there is a tie, we can pick any of the tied children).

  • The edge from $u$ to this largest child $v$ is classified as a Heavy Edge.
  • The edges from $u$ to all other children are classified as Light Edges.

Because every node selects exactly one Heavy Edge leading down to a child, these Heavy Edges naturally link together to form non-branching paths going down the tree. We call these Heavy Chains. A node that is connected to its parent via a Light Edge is considered the "head" of a new Heavy Chain.

2.3 The Core Theorem: Why $O(\log N)$ Light Edges?

If we walk from any node $u$ up to the root, how many Light Edges will we traverse? The mathematics is beautifully simple.

Suppose we traverse a Light Edge from node $v$ up to its parent $u$. Because the edge $(u, v)$ is Light, we know that $u$ must have some other child $w$ that was chosen as the Heavy child. By definition, $size(w) \ge size(v)$.

Therefore, the total size of the parent's subtree must be at least the size of $v$'s subtree plus the size of $w$'s subtree, plus the parent itself:

$$size(u) > size(v) + size(w) \ge 2 \times size(v)$$

This inequality is the secret to HLD. Every time we walk up a Light Edge, the size of the subtree we are standing in at least doubles. Since the maximum possible subtree size is $N$ (the total number of nodes in the tree), we can only double the size $\log_2(N)$ times before we hit the root. Therefore, any path from a node to the root contains at most $O(\log N)$ Light Edges!

Because Heavy Chains are contiguous segments, a path to the root consists of jumping up a Heavy Chain, taking a Light Edge transfer, jumping up the next Heavy Chain, and so on. This means the path is broken into at most $O(\log N)$ contiguous Heavy Chain segments.


3. The DFS Strategy: Flattening the Tree

We know that Heavy Chains are contiguous paths. To make Segment Trees work, nodes on the same Heavy Chain must be assigned contiguous indices in our 1D array. We achieve this using a very specific Depth First Search (DFS) order.

Standard DFS visits children in whatever order they appear in the adjacency list. For HLD, we modify our DFS to always visit the Heavy Child first, before visiting any Light Children. By diving down the Heavy Chain completely before backtracking, we ensure that all nodes in a Heavy Chain receive sequential DFS discovery timestamps.

3.1 The Two-Pass DFS Implementation

Implementation generally requires two DFS passes:

  • DFS 1 (Information Gathering): Computes depths, parents, and subtree sizes. Crucially, it identifies the "heavy child" for each node and swaps it to the 0th index of the adjacency list so it gets visited first in the next pass.
  • DFS 2 (Flattening): Traverses the tree, tracking the "head" (top-most node) of the current Heavy Chain. It assigns sequential array positions to nodes as it visits them.
vector<int> parent, depth, heavy, head, pos;
int current_pos = 0;

// DFS 1: Calculate subtree sizes and heavy edges
int dfs1(int v, int p) {
    int size = 1, max_child_size = 0;
    for (int c : adj[v]) {
        if (c != p) {
            parent[c] = v, depth[c] = depth[v] + 1;
            int c_size = dfs1(c, v);
            size += c_size;
            if (c_size > max_child_size) {
                max_child_size = c_size;
                heavy[v] = c; // Record the heavy child
            }
        }
    }
    return size;
}

// DFS 2: Assign segment tree positions and track chain heads
void dfs2(int v, int p, int chain_head) {
    head[v] = chain_head;     // The top of this node's express line
    pos[v] = current_pos++;   // Flattened array index
    
    if (heavy[v] != -1) {
        // ALWAYS visit heavy child first to keep the chain contiguous
        dfs2(heavy[v], v, chain_head);
    }
    
    for (int c : adj[v]) {
        if (c != p && c != heavy[v]) {
            // Light children start their own new chains
            dfs2(c, v, c);
        }
    }
}
Developer Pitfall — Base Array Mapping:

After `dfs2` finishes, you cannot just build the Segment Tree on your original value array `V`. Node 3 might have `pos[3] = 7`. You must create a new array `mapped_V` where `mapped_V[7] = V[3]`, and then build your Segment Tree over `mapped_V`. Forgetting this mapping will result in querying completely randomized data.


4. Executing the Query

Once the tree is flattened, how do we query the path between $u$ and $v$? We use a technique similar to finding the Lowest Common Ancestor (LCA). We examine the `head` of the chains that $u$ and $v$ are currently on.

  • If `head[u]` and `head[v]` are different, they are on different Express Lines. We take the node whose chain head is deeper in the tree (has a greater depth) and "jump" it up.
  • We query the Segment Tree for the contiguous range from `pos[head[u]]` to `pos[u]`. (Because it's a Heavy Chain, they are contiguous in the array!)
  • We then update $u$ to be `parent[head[u]]`, jumping across the Light Edge to the bottom of the next chain.
  • We repeat this until $u$ and $v$ finally land on the exact same Heavy Chain (`head[u] == head[v]`).
  • Once they are on the same chain, the path between them is just a single contiguous segment! We do one final Segment Tree query between `pos[u]` and `pos[v]` (making sure to query from the smaller position to the larger).
int query(int u, int v) {
    int res = 0; // Or -INFINITY for max queries
    // While they are on different chains...
    while (head[u] != head[v]) {
        // Force u to be the one deeper in the tree
        if (depth[head[u]] < depth[head[v]]) swap(u, v);
        
        // Query the segment tree for u's current chain
        res = combine(res, seg_query(pos[head[u]], pos[u]));
        
        // Jump u up to the parent of its chain head
        u = parent[head[u]];
    }
    
    // Now they are on the same chain.
    if (depth[u] > depth[v]) swap(u, v);
    
    // Final query for the segment between them
    res = combine(res, seg_query(pos[u], pos[v]));
    
    return res;
}
Developer Pitfall — Edge vs Node Queries:

The code above assumes values are stored on the Nodes. If your values are stored on the Edges (e.g., edge weights), the logic changes slightly. When $u$ and $v$ land on the same chain, the Lowest Common Ancestor (the highest node) does not contain an edge weight belonging to the path. You must query `seg_query(pos[u] + 1, pos[v])`. Adding `+ 1` skips the LCA node, avoiding including an edge leading out of the path.


5. Frequently Asked Questions

Q1: What is the time complexity of an HLD update or query?

A path between any two nodes is split into at most $O(\log N)$ heavy chain segments. For each segment, we perform a Segment Tree query or update, which takes $O(\log N)$ time. Therefore, the total time complexity per query or update is $O(\log^2 N)$. Building the structure takes $O(N)$ for the two DFS passes, plus $O(N \log N)$ or $O(N)$ to build the underlying Segment Tree.

Q2: Can HLD handle tree structural changes (adding/removing edges)?

No. HLD relies on a static tree structure because the DFS assignment array (`pos`) is immutable once built. If edges are dynamically added or removed, subtree sizes change, invalidating the heavy/light edge assignments and destroying the contiguous array mappings. If you need dynamic tree topology (e.g., link-cut operations), you must use a much more complex data structure called a Link-Cut Tree.

Q3: How do we handle subtree queries in HLD?

HLD natively supports subtree queries flawlessly! Because our DFS visits all nodes in a subtree before backtracking, all nodes within the subtree of node $u$ are guaranteed to occupy a single contiguous block in the flattened array, starting exactly at `pos[u]`. The end of the block is `pos[u] + size(u) - 1`. To query a subtree, you just perform a single $O(\log N)$ segment tree query over that range.

Q4: Are there alternatives to HLD for path queries?

Yes. If your operations are commutative and invertable (like path sums, where you can add and subtract), you can use Euler Tour Trees or Binary Lifting combined with prefix sums. However, if your operation is non-invertable (like path maximums), Binary Lifting is too slow for updates ($O(N)$), leaving HLD and Link-Cut Trees as the only viable options.


Written by Professor Pixel · CodingPancake · Algorithms & Data Structures Series

Post a Comment

Previous Post Next Post