Algorithms · Dynamic Programming

Tree DP and Rerooting: Computing an Answer at Every Node

Subtree DP evaluates a rooted tree in post-order, and a second top-down pass propagates the parent contribution so that every node gets the answer for the tree rooted at it.

A tree has no cycles, so the state graph of a tree DP is acyclic by construction and the only question is the traversal order. Root the tree arbitrarily, let each subtree be a subproblem, and the recursion runs from leaves upward: a node’s answer is a function of its children’s answers. That is the whole first pass, and it is O(n) for a state of constant size.

The difficulty begins with the second kind of question. Many problems ask for an answer at every node — the sum of distances from each node to all others, the eccentricity of each node, the number of nodes closer to v than to any other. Running the first pass once per node gives O(n²). Rerooting computes all of them in O(n) by running a second pass downward, and the technique requires the per-node combination to be invertible or to be handled with prefix and suffix aggregates.

Rooting and post-order

Pick any node as root. Orient every edge away from it. Each node v has a parent p(v) and a set of children c_1 … c_k. The subtree T_v is v together with the subtrees of its children, and the subproblems are exactly these subtrees — n of them, one per node, which is why tree DP is O(n) rather than O(n²) when the per-node state is small.

Post-order is the topological order: children before parents. It is the tree-shaped case of the ordering requirement discussed in recurrences as DAGs, and it is the only thing tree DP adds to ordinary tabulation. An explicit stack is safer than recursion, since a path graph of 10^6 nodes is a tree of depth 10^6 and will exhaust any default thread stack.

def subtree_sizes(adj, root):
    n = len(adj)
    parent = [-1] * n
    order = []
    stack = [root]
    parent[root] = root
    while stack:                       # build a rooted order, parents first
        v = stack.pop()
        order.append(v)
        for u in adj[v]:
            if parent[u] == -1:
                parent[u] = v
                stack.append(u)
    size = [1] * n
    for v in reversed(order):          # children before parents
        for u in adj[v]:
            if u != parent[v]:
                size[v] += size[u]
    return parent, size

The parent[u] == -1 test replaces a visited set: in a tree, the only already-visited neighbour of v is its parent, so marking parents is sufficient. In a general graph the same loop would revisit nodes and loop forever, which is the difference between tree DP and graph DP.

Merging children is a knapsack

When the state per node is not a scalar but a table over a resource — the number of selected nodes, the budget consumed, the height — the node combines its children’s tables by a convolution. Suppose v has children with subtree sizes s_1, …, s_k. Convolving the accumulated table, of width 1 + s_1 + … + s_{i-1}, with child i’s table of width s_i costs the product of the two widths. If the accumulated table is allocated at its final width n from the start, every merge costs O(n · s_i) and the node costs O(n · Σ s_i) = O(n²). Summed over the whole tree that is O(n³) in the worst case, which is a factor of n worse than necessary.

The standard improvement is to size the accumulated table at 1 + s_1 + … + s_i after each merge, so the convolution is over the actual merged width rather than n. Merging the first i children then costs (1 + s_1 + … + s_i) · s_{i+1}, and summing over i gives O((Σ s_i)²) for the subtree. Summed over the whole tree the total is O(n²), because every node appears in the subtree of each of its ancestors and the sum is bounded by counting each pair of nodes once at their lowest common ancestor. The structure is the interval-style merge enumeration of interval DP, applied over a node’s children instead of over a contiguous range.

The state itself has to be justified by the sufficiency rule in state sufficiency: a subtree is a complete summary of everything outside it only because the tree has no cycles, so the only route between the subtree and the rest of the graph is the single edge to the parent. On a general graph the same state is insufficient, and the repair is a boundary set — the treewidth formulation.

Rerooting: the answer for the tree rooted elsewhere

The first pass computes, for each node, the answer restricted to its subtree. The second pass computes the contribution of everything outside the subtree, and combines the two.

Define down[v] as the answer for T_v with v as root, computed bottom-up. Define up[v] as the answer for the whole tree with v as root, but computed from up[p(v)] and the siblings of v. Then the final answer at v combines down[v] with the contribution of up[v]’s side.

For the sum-of-distances problem the combination is simple because the operation is addition:

down[v] = Σ over children u of ( down[u] + size[u] )
up[v]   = (n - size[v]) + Σ over siblings u of ( down[u] + size[u] )

Read up[v] as “the sum of distances from v to every node outside T_v”. The first term accounts for the n − size[v] nodes outside T_v, each of which is one edge further from v than from p. The second term is the siblings’ contributions, which are exactly the parts of down[p] that do not come from v. The total at v is down[v] + up[v], and the answer at the root is just down[root] since up is empty there.

Subtracting down[v] + size[v] from down[p] recovers that sum in O(1), and the whole pass is O(n). This is the invertible case, and it is why the sum-of-distances rerooting is the standard worked example.

When the combination is not invertible

The subtraction fails for operations without inverses: maximum, minimum, greatest common divisor, bitwise OR, the second-largest value. For a max-combination, down[p] is the max over children, and “the max excluding v” cannot be recovered from the max including v.

The repair is prefix and suffix aggregates over the children’s contributions. For a node with children c_1 … c_k, compute pre[i] as the combination of c_1 … c_i and suf[i] as the combination of c_i … c_k; then the contribution excluding c_i is pre[i-1] ⊕ suf[i+1]. Building both arrays costs O(k) and answering all k exclusions costs O(k), so the pass stays linear in the number of edges. Storage is O(k) per node transiently, and the arrays can be reused per node if the traversal is iterative.

Two details matter in the implementation. The identity element for the combination must be defined — 0 for sum, −∞ for max, 1 for a product — and the prefix array must be initialized with it, or the first exclusion is wrong. And the operation must be one where “excluding one contributor” is well-defined: for a maximum attained by two children, excluding one leaves the maximum unchanged, which is the right answer for a maximum query but the wrong answer for a query that counts how many children attain it. Counts are not invertible in this sense, and prefix/suffix aggregation over the pairs (value, count) is the sound alternative.

For the maximum, a second scan over the same degree-sized list answers every exclusion, which is O(deg) per node and O(n) overall, and needs no prefix array at all. Keeping only the best value and the identity of its owner answers each exclusion in O(1), but only if ties are broken by a rule that survives them: the naive top-two scan must not attribute a value to the wrong neighbour when two neighbours tie. For a combination that is neither invertible nor summarizable by a small number of extremes, the prefix/suffix arrays are the general solution.

def farthest_down(adj, parent, order):
    down = [0] * len(adj)
    for v in reversed(order):
        best = 0
        for u in adj[v]:
            if u != parent[v]:
                best = max(best, down[u] + 1)
        down[v] = best
    return down

def farthest_all(adj, parent, order, down):
    n = len(adj)
    up = [0] * n
    for v in order:                       # parents before children
        # contribution of each neighbour: the distance it sits at, seen from v
        contrib = []
        for u in adj[v]:
            if u == parent[v]:
                contrib.append((up[v], u))     # the outside world, seen from v
            else:
                contrib.append((down[u] + 1, u))
        # two passes over a degree-sized list keep the worst case O(n)
        for u in adj[v]:
            if u != parent[v]:
                best = 0
                for val, w in contrib:
                    if w != u and val > best:
                        best = val
                up[u] = 1 + best
    return [max(down[v], up[v]) for v in range(n)]

The contrib list is rebuilt at every node and discarded after its children are assigned, so the transient storage is O(deg(v)) and the total work is proportional to the number of edges.

What rerooting actually assumes

The technique rests on a decomposability property: the answer at v is a combination of one contribution per incident edge, and each edge’s contribution depends only on the component on the far side. This holds for path-based quantities — distances, eccentricities, subtree sizes, counts of nodes within a radius — because deleting v splits the tree into exactly deg(v) components, and the route from v to any other node leaves through exactly one of them.

It fails when a quantity couples two components at once. The number of pairs of nodes at distance k, for a fixed k, decomposes into pairs inside a single component plus pairs split across two components whose distances sum to k - 2. The crossing term is a convolution over the components, and the per-node work becomes O(deg · k) rather than O(deg) — the same shape as the child-merging convolution above, with the distance bound playing the role of the table width. Prefix and suffix aggregation still applies, but over tables rather than scalars.

A third category is quantities that do not depend on the root at all. The tree’s diameter is the same number whichever node is designated root, so rerooting it computes one value many times. The root- dependent quantity worth computing instead is the eccentricity: the maximum distance from v to any other node, which does decompose per incident edge because the route to a node passes through exactly one of them. Distinguishing root-independent quantities from root-dependent ones is the first step in deciding whether a second pass is needed at all.

Common failure modes

Depth. Recursion on a path graph is O(n) deep. An iterative post-order with an explicit stack, as in the first code block, removes the limit at the cost of a heap allocation for the order array.

Parent handling in the second pass. The up array must be filled in pre-order — parents before children — and the value up[v] used for the outside contribution must be the value for v, not the value for p(v). Mixing the two is the most common off-by-one-level error, and it produces answers that are correct for the root and wrong elsewhere.

Combining up and down inconsistently. The final answer at v combines down[v], which covers T_v, with up[v], which covers the complement. If either includes the node v itself twice, or neither does, the result is off by a constant per node — a bug that passes tests on a two-node tree and fails on larger ones.

Order of the two passes. The invariant is that when up[u] is assigned for a child u, three things are already final: up[v] for its parent, and the down values of every child of v, including the siblings of u. The two-loop form above satisfies this by construction. A single recursive traversal also satisfies it, provided up[u] is assigned after the whole node’s post-order work is complete and before descending into u’s own second-pass work. Assigning up[u] before the siblings’ down values are known breaks the invariant, and the resulting answers are correct for the root and its immediate children and wrong below that.

The technique generalizes beyond trees to any graph of bounded treewidth, where the DP state is a boundary set rather than a single node and the table is exponential in the width. At width one — a tree — the state is constant-sized and the whole computation is linear; each unit of additional width multiplies the table by the size of the state alphabet.

References

  1. Introduction to Algorithms, 4th edition — Chapter 14, Dynamic Programming — Cormen, Leiserson, Rivest, Stein; MIT Press
  2. The Art of Computer Programming, Volume 1 — Section 2.3.1, tree traversal — Donald Knuth; Addison-Wesley
  3. The monadic second-order logic of graphs I: Recognizable sets of finite graphs — Bruno Courcelle, Information and Computation 85(1), 1990