Decompose a tree into vertex-disjoint paths so that any root-to-leaf walk crosses at most of them. Laying those paths out contiguously in an array turns tree path queries into range queries.

The decomposition

For each vertex, call the child with the largest subtree its heavy child; the edge to it is a heavy edge, all others are light.

Key lemma. Any root-to-leaf path crosses at most light edges — because crossing a light edge at least halves the subtree size (the light child has half the parent’s subtree, else it would be heavy).

Heavy edges chain into heavy paths. Number vertices so that each heavy path occupies a contiguous range of positions.

Implementation

vector<int> par, dep, sz, heavy, head, pos;
int curPos = 0;
 
int dfsSize(int v) {                                  // find heavy children
    sz[v] = 1;
    int maxSub = 0;
    for (int c : adj[v]) {
        if (c == par[v]) continue;
        par[c] = v; dep[c] = dep[v] + 1;
        int sub = dfsSize(c);
        sz[v] += sub;
        if (sub > maxSub) { maxSub = sub; heavy[v] = c; }
    }
    return sz[v];
}
 
void decompose(int v, int h) {                        // assign positions
    head[v] = h;
    pos[v] = curPos++;
    if (heavy[v] != -1) decompose(heavy[v], h);       // continue this chain FIRST
    for (int c : adj[v])
        if (c != par[v] && c != heavy[v]) decompose(c, c);
}

Recursing into the heavy child first is what makes each chain contiguous.

Path queries

long long queryPath(int u, int v) {
    long long res = IDENTITY;
    for (; head[u] != head[v]; v = par[head[v]]) {
        if (dep[head[u]] > dep[head[v]]) swap(u, v);
        res = combine(res, seg.query(pos[head[v]], pos[v]));
    }
    if (dep[u] > dep[v]) swap(u, v);
    res = combine(res, seg.query(pos[u], pos[v]));     // final chain
    return res;
}

chains, each costing in the segment tree per query. Range updates work identically with a lazy segment tree.

Non-commutative operations

The loop above visits chain segments in an arbitrary order, which is fine for sum, min, gcd and other commutative merges. For non-commutative merges (matrix products, string concatenation) you must accumulate the two sides separately — one going up from , one from — and reverse one of them at the end.

Subtree queries come free

Because decompose numbers each subtree contiguously (it is a DFS order), the subtree of is exactly [pos[v], pos[v] + sz[v] - 1]. So one HLD gives both path queries and subtree queries.

Edge values instead of vertex values

Store each edge’s value at its child endpoint, then exclude the LCA from path queries:

if (u == v) return IDENTITY;                    // no edges
res = combine(res, seg.query(pos[u] + 1, pos[v]));   // skip the LCA itself

Forgetting the +1 is the most common HLD bug.

HLD vs the alternatives

NeedStructureComplexity
LCA onlybinary lifting
Path queries, static treebinary lifting with aggregates
Path queries and updates, static treeHLD + lazy segtree
Subtree queries and updatesEuler tour + segment tree
Tree changes (link/cut)link-cut tree am.
Path counting / distance problemscentroid decomposition
Queries on a small vertex subsetvirtual tree

HLD is the workhorse: static tree, path updates and queries, , and about 60 lines.

Getting to

Using a sparse table per chain gives queries for idempotent operations without updates. With updates, requires link-cut trees or a global balanced BST over the chains — rarely worth the complexity.

See also: Centroid Decomposition · Euler Tour · Link-Cut Tree