
Every dynamic programming problem is, underneath, a question about a graph. Not the graph in the input, but the subproblem graph: the directed graph whose vertices are the states and whose edges point from a state to the states it depends on. Dynamic programming works exactly when that graph is acyclic and small, because acyclicity gives a valid evaluation order and a small vertex set makes memoization pay off. In 1D DP and in 2D grid DP we impose that structure ourselves, by indexing states with a prefix length or a pair of coordinates and then choosing a fill order that respects the dependencies. On a tree we do not have to impose anything. The input itself is already an acyclic dependency structure, and the recursion that visits it is already the evaluation order we need.
This article is about dynamic programming whose state space is carved out of a tree or a graph. We start from the shared skeleton, the post-order computation in which every node returns a small tuple of alternatives and its parent combines them, and we push it in three directions. The first is the enrichment of the state, from a single number per node to a node plus a mode, a small label that records the commitment the node has made towards its parent. The second is the observation that nothing forces the value of a DP state to be a number: a state can hold a list of objects, and the recurrence can assemble structures rather than optimize a scalar. The third is rerooting, the technique that converts an answer computed for one root into the answer for all roots with a single additional traversal, turning a naive repetition into a linear algorithm. We close on general graphs, which are not naturally amenable to DP at all, and on the two ways of making them so: finding or inducing a directed acyclic graph, and expanding the state space until the cycles disappear.
Fix a root and orient every edge away from it. A rooted tree now defines, for each node , a canonical subproblem: solve the problem restricted to , the set of nodes reachable from through downward edges. These subproblems have two properties that together make DP not just applicable but almost automatic.
The first is optimal substructure by construction. The subtrees of the children of are disjoint, and every edge of either joins to a child or lies entirely inside one child subtree. So any decision taken at interacts with the children only through the edges , and once we know, for every child, the best value attainable under each relevant assumption about that edge, the value at follows. This is a stronger statement than it looks. In a general graph a decision at a vertex can propagate to arbitrarily distant vertices through many different paths, and the subproblems overlap in ways that no small state can summarize. In a tree the interaction between a node and the rest of the world passes through a single edge, its edge to the parent, and that bottleneck is what keeps the state small.
The second property is that the dependency order is already materialized. The subproblem graph has one vertex per node and an edge from to each of its children, so it is literally the tree itself, reversed. A post-order depth-first traversal is therefore a topological order of the dependency graph: when the traversal returns to , every subproblem depends on has already been solved. There is no table to fill in the right order, no ambiguity about whether to iterate forwards or backwards, and no need for an explicit memo, since each subproblem is reached exactly once through its unique parent edge. Recursion is the tabulation.
The canonical shape of a tree DP is therefore a single function that returns, for a node, the vector of values of all its states, computed from the vectors returned by the children.
type NodeStates = number[];
function treeDp(node: TreeNode | null): NodeStates {
if (node === null) {
return neutralStates();
}
const left = treeDp(node.left);
const right = treeDp(node.right);
return combine(node.val, left, right);
}
Everything specific to a problem lives in three places: what the states mean, what the neutral value of a missing child
is, and how combine merges the children under each state.
The rest of this article is a series of answers to those three questions.
A single number per node is rarely enough. The moment a problem forbids some combination of choices between a node and its children, the value of a subtree stops being well defined on its own: it depends on what the node promised to its parent. The fix is to index the state by a node and a mode, where the mode enumerates the finitely many commitments a node can make towards the outside world. Formally, the state becomes , the optimum over given that is in mode , and the number of modes is a constant that does not grow with .
The cleanest example is the tree version of the house robber problem. Each node holds an amount of money, and we may not take money from two nodes joined by an edge. The forbidden interaction is exactly "parent taken and child taken", so two modes suffice: is robbed, or is skipped. Under the first mode the children are forced to be skipped, under the second each child is free to pick whichever of its own two modes is better.
The base case is the missing child, which contributes zero under both modes, and the answer is the maximum of the two modes at the root. Notice the asymmetry that makes this correct: the robbed mode constrains the children, the skipped mode does not, and it is precisely because the skipped mode takes a maximum over the child modes that a node can never be robbed together with its parent. Returning a tuple rather than writing to a table keeps the implementation to a dozen lines.
function rob(root: TreeNode | null): number {
function robSubtree(node: TreeNode | null): [robbed: number, skipped: number] {
if (!node) {
return [0, 0];
}
const [leftRobbed, leftSkipped] = robSubtree(node.left);
const [rightRobbed, rightSkipped] = robSubtree(node.right);
const robbed = node.val + leftSkipped + rightSkipped;
const skipped = Math.max(leftRobbed, leftSkipped) + Math.max(rightRobbed, rightSkipped);
return [robbed, skipped];
}
return Math.max(...robSubtree(root));
}
Two observations generalize far beyond this problem. The first is that the tuple returned by the recursion is the entire interface between a subtree and the rest of the tree, so designing a tree DP is mostly the exercise of finding the smallest such interface. If you find yourself needing to know something about a subtree that is not in the tuple, the tuple is incomplete and the recurrence is wrong. The second is that the modes are not required to be mutually exclusive descriptions of the optimum. They are conditional optima, one per assumption, and the caller decides which assumption it wants to buy. This is the same reasoning that turns a greedy dead end into a correct DP, and it costs only a constant factor.
Modes become considerably more interesting when they do not describe a choice but a debt. Consider placing cameras on the nodes of a binary tree, where a camera monitors the node it sits on, its parent and its direct children, and we want the minimum number of cameras that monitors every node. A node is now in one of three situations, and none of them is a decision in the sense of the previous section: it holds a camera, it holds no camera but is already monitored by one of its children, or it holds no camera and is not yet monitored, in which case it is asking its parent for coverage.
The full DP is the natural one. Let be the minimum number of cameras that covers with a camera placed at , the minimum that covers entirely with no camera at , and the minimum that covers every node of except possibly itself.
In the camera at covers all children, so each child only needs its own subtree handled and may be left uncovered itself, which is why is admissible there. In the node is covered, so some child must hold a camera, and no child may be left uncovered. In nobody covers , so every child must be covered without help from above, and none of them may hold a camera, since a camera at a child would have covered and put us in state . The answer is , because the root has no parent to ask.
This DP is correct and runs in linear time, but the three values are so tightly related that they collapse. The key inequality is that placing a camera as late as possible, at the parent of an uncovered node rather than at the node itself, is never worse: a camera at the parent covers everything a camera at the node would cover, except the children of that node, which a post-order traversal has already taken care of by the time the parent decides. So the optimal strategy is forced, and instead of three numbers each node can return a single label plus a global counter. A leaf returns "needs cover", its parent is therefore obliged to place a camera, and the obligation propagates upwards two levels at a time.
const NEEDS_COVER = 0;
const HAS_CAMERA = 1;
const COVERED = 2;
function minCameraCover(root: TreeNode | null): number {
let cameras = 0;
function cover(node: TreeNode | null): number {
if (!node) {
return COVERED;
}
const left = cover(node.left);
const right = cover(node.right);
if (left === NEEDS_COVER || right === NEEDS_COVER) {
cameras++;
return HAS_CAMERA;
}
if (left === HAS_CAMERA || right === HAS_CAMERA) {
return COVERED;
}
return NEEDS_COVER;
}
if (cover(root) === NEEDS_COVER) {
cameras++;
}
return cameras;
}
The detail that makes this work is the base case.
A missing child returns COVERED and not NEEDS_COVER, because a node that does not exist imposes no obligation,
and returning NEEDS_COVER would waste a camera at every leaf.
The other detail is the check on the root after the traversal, which pays the one camera that nobody above can pay.
This solution is usually labelled greedy, and it is, but the greedy is only a justified collapse of the three-mode DP.
Deriving the DP first and proving the collapse afterwards is the reliable order of operations.
Nothing in the definition of dynamic programming requires the value of a state to be a number. A state can hold a set, a string, or a list of objects, and the recurrence can combine those values with a cartesian product instead of a or a . The problem of generating all structurally unique binary search trees over the keys is the archetype.
The search space looks daunting until we use the defining property of a binary search tree: the in-order traversal is the sorted key sequence. Choosing a root for the key range therefore forces the keys into the left subtree and into the right subtree, with no freedom whatsoever about which keys go where. The only remaining freedom is the shape of each side, and the two sides are independent. So the recurrence is a cartesian product over contiguous key ranges.
with when , the single empty tree, which is the value that makes the
product non-degenerate at the boundary.
Returning a list containing null rather than an empty list is the whole base case, and getting it wrong collapses the
product to nothing.
function generateTrees(n: number): Array<TreeNode | null> {
const memo = new Map<string, Array<TreeNode | null>>();
function build(start: number, end: number): Array<TreeNode | null> {
if (start > end) {
return [null];
}
const key = `${start}:${end}`;
const cached = memo.get(key);
if (cached) {
return cached;
}
const trees: Array<TreeNode | null> = [];
for (let root = start; root <= end; root++) {
const leftSubtrees = build(start, root - 1);
const rightSubtrees = build(root + 1, end);
for (const left of leftSubtrees) {
for (const right of rightSubtrees) {
trees.push(new TreeNode(root, left, right));
}
}
}
memo.set(key, trees);
return trees;
}
return build(1, n);
}
The state here is an interval of keys, not a node, so this is interval DP wearing the clothes of a tree problem, and the number of distinct states is only . The memo is what makes the algorithm sane: the range appears as the right part of many different roots, and recomputing its trees each time would multiply the cost by an exponential factor with no benefit. Caching has a consequence worth stating explicitly, because it is the kind of thing that is easy to miss. The cached list contains node objects that are shared across all the trees using that range as a subtree. Structure sharing is what keeps the memory proportional to the number of distinct subtrees rather than to the number of trees times their size, and it is harmless as long as nobody mutates a returned tree. If the caller needs independent trees, the memo must return deep copies, and the benefit of sharing disappears.
The size of the output is a Catalan number, , which no algorithm can beat, since it has to print them all. The counting variant of the same problem, which asks only for , is a pure 1D DP with the recurrence , and it is the same recurrence with the cartesian product replaced by a product of cardinalities. That correspondence is the clean way to remember the relationship between enumerating and counting: counting is enumeration with the cardinality operator applied to every combinator.
Every technique so far computes an answer that is relative to the chosen root. Many problems instead ask for an answer at every node, and the naive approach of rerunning the rooted DP times costs , which is already too slow when reaches . Rerooting is the two-pass technique that fixes this, and it is worth deriving carefully because the derivation generalizes far beyond the example.
Take the problem of computing, for every node of a tree with nodes, the quantity
where is the number of edges on the unique path between two nodes. Root the tree arbitrarily at node and define two rooted quantities: and , the distance sum restricted to the subtree below .
The first pass computes both bottom-up. For a child of , every node of satisfies , because the path from to is the edge followed by the path from to . Summing over contributes , and summing over the children gives the recurrence.
At the root, and only at the root, the subtree is the whole tree, so . For every other node is a partial answer, missing all the nodes that do not lie below .
The second pass repairs that, one edge at a time, using the following transfer.
Claim. If is a child of , then .
Proof. Removing the edge from a tree disconnects it into exactly two components, because a tree has edges and no cycles, so every one of its edges is a bridge. Call , of size , the component containing , and , of size , the component containing . For , the unique path from to must leave , and the only edge leaving towards is , so that path is followed by the path from to , and therefore . Symmetrically, for the unique path from to starts with , so . Summing over the partition ,
The claim converts the answer at a node into the answer at a neighbour in , so a second depth-first traversal starting from the root, where is already known, propagates correct answers to the entire tree in linear time.
function sumOfDistancesInTree(n: number, edges: number[][]): number[] {
const graph: number[][] = Array.from({ length: n }, () => []);
for (const [from, to] of edges) {
graph[from].push(to);
graph[to].push(from);
}
const subtreeSize: number[] = Array(n).fill(1);
const answer: number[] = Array(n).fill(0);
function collect(node: number, parent: number): void {
for (const child of graph[node]) {
if (child === parent) {
continue;
}
collect(child, node);
subtreeSize[node] += subtreeSize[child];
answer[node] += answer[child] + subtreeSize[child];
}
}
function reroot(node: number, parent: number): void {
for (const child of graph[node]) {
if (child === parent) {
continue;
}
answer[child] = answer[node] - subtreeSize[child] + (n - subtreeSize[child]);
reroot(child, node);
}
}
collect(0, -1);
reroot(0, -1);
return answer;
}
The answer array carries two different meanings during the run, after the first pass and after the
second, which saves an array at the price of a subtlety that deserves a comment in production code.
The parent argument is the standard way of walking an undirected adjacency list as if it were rooted, and it replaces
a visited set, because in a tree the only way back is the edge we came from.
The general framework behind the claim is worth stating in the abstract, because most rerooting problems have nothing to do with distances. Write the answer at as the merge of a downward contribution, computed by the first pass, and an upward contribution that accounts for everything reachable through the parent edge. The second pass computes for a child from and from the downward contributions of the siblings of , since from the point of view of the outside world is exactly the outside world of the parent, plus the parent itself, plus the subtrees of the siblings. When the merge operator is invertible, like addition, the aggregate of the siblings is obtained by subtracting the contribution of from the aggregate of the parent, which is what the term does above. When the merge is not invertible, like , subtraction is unavailable and the standard fix is to precompute prefix and suffix aggregates over the ordered list of children of , so that the aggregate excluding the -th child is . That restores per edge and keeps the whole algorithm linear, and it is the reason rerooting is described as a technique rather than as a trick specific to one problem.
A general graph is hostile to dynamic programming for a single reason: the subproblem graph inherits its cycles. If the state is "the best value at vertex " and lies on a cycle, then depends on itself, the recurrence is not a definition but an equation, and the recursion does not terminate. There are exactly two ways out, and every graph DP is one of them, or a combination of both.
The first is to work on a directed acyclic graph. On a DAG the dependency order exists and is computed by a topological sort, which plays the role that the post-order traversal plays on a tree, and any recurrence over incoming edges can be evaluated in that order. A DAG may come from the problem statement, or from a preprocessing step: condensing the strongly connected components of a digraph yields a DAG, and so does adding a monotone coordinate to the state, such as time or the number of edges used, which is the second way out. State-space expansion replaces the vertex with the pair where is a quantity that strictly increases along every transition, and the expanded graph is acyclic by construction, because a cycle would require to return to a value it already had. The cost is a state space multiplied by the range of , which is why the Bellman-Ford style DP over "shortest walk using at most edges" costs .
The most elegant instance of the first way out is a DAG that another algorithm induces on an arbitrary weighted graph. Given a graph with strictly positive weights and a source , run Dijkstra to obtain for every vertex, then keep only the edges that a shortest path could possibly use.
This shortest-path DAG is acyclic: along any edge of the value of strictly increases, since , and a cycle would force to come back down to a value it has already exceeded. Its paths from are exactly the shortest paths of the original graph, and therefore counting shortest paths becomes a plain path-counting recurrence on a DAG.
We could materialize and topologically sort it, but there is no need, because Dijkstra already produces a valid evaluation order for free. The order in which vertices are finalized, meaning extracted from the priority queue with their final distance, is non-decreasing in , and every predecessor of in has a strictly smaller distance, so it has already been finalized when is. The DP can therefore be folded into the relaxation loop: when a vertex is popped its count is final, and it pushes that count into its successors. Each relaxation is one of two cases. A strictly shorter route to a neighbour invalidates everything counted so far for it, so the count is overwritten. A route of exactly the same length is an additional family of shortest paths, so the count is accumulated.
function countPaths(n: number, roads: number[][]): number {
const graph: RoadEdge[][] = Array.from({ length: n }, () => []);
for (const [from, to, time] of roads) {
graph[from].push({ node: to, cost: time });
graph[to].push({ node: from, cost: time });
}
const distances: number[] = Array(n).fill(Infinity);
const ways: bigint[] = Array(n).fill(BigInt(0));
distances[0] = 0;
ways[0] = BigInt(1);
const minCostHeap = new Heap<RoadHeapNode>((a, b) => a.cost - b.cost);
minCostHeap.insert({ node: 0, cost: 0 });
while (minCostHeap.size() > 0) {
const { node, cost } = minCostHeap.extract()!;
if (cost > distances[node]) {
continue;
}
for (const { node: next, cost: time } of graph[node]) {
const newCost = cost + time;
if (newCost < distances[next]) {
distances[next] = newCost;
ways[next] = ways[node];
minCostHeap.insert({ node: next, cost: newCost });
} else if (newCost === distances[next]) {
ways[next] = (ways[next] + ways[node]) % MODULO;
}
}
}
return Number(ways[n - 1] % MODULO);
}
Three details carry the correctness of this loop.
The guard cost > distances[node] discards stale heap entries, and it is
enough to guarantee that each vertex is expanded exactly once, because an entry is inserted only when the distance
strictly decreases, so all the entries for a vertex carry distinct costs and only the smallest of them passes the guard.
Expanding a vertex exactly once is what prevents a predecessor from contributing its count twice.
The strict positivity of the weights is load bearing as well: with a zero-weight edge two vertices could share the same
distance and each could be a predecessor of the other, the induced graph would contain a cycle, and the finalization
order would no longer be a topological order of it.
Finally the counts are reduced modulo at every accumulation.
The sum of two reduced values stays far inside the range of integers exactly representable by a double, so BigInt is a
belt-and-braces choice here rather than a necessity, and at these sizes it costs nothing.
The same template covers a wide family of questions. Replacing the sum with a or a over the shortest-path DAG answers "among all shortest paths, which one minimizes the number of edges, or the largest single edge, or some secondary cost", and it does so without ever enumerating the paths, whose number can be exponential. Whenever a problem asks for an aggregate over all optimal solutions rather than for one optimal solution, the reflex should be to look for the DAG of optimal solutions and to run a DP on it.
The cost of a tree DP follows the universal DP formula, the number of states multiplied by the work per state. A tree DP with modes per node has states, and resolving the states of a node requires combining the tuples of its children, which costs . Since in a tree, the total is , and with constant this is simply . Both the house robber variant and the camera placement therefore run in time, each node being visited once and performing a constant amount of arithmetic on the two tuples returned by its children. Their space is the recursion stack, where is the height of the tree, which is for a balanced tree but degrades to for a degenerate path-shaped tree, the worst case an adversarial input will always pick. Converting the traversal to an explicit stack does not change the asymptotics, it only moves the memory from the call stack to the heap, which matters in practice when the recursion depth would overflow.
Generating all unique binary search trees is a different regime, because the output itself is exponential. The number of shapes over keys is the Catalan number , so no algorithm can run in less than time. The memoized recurrence matches that bound up to polynomial factors. It has distinct states, one per key range, and the nodes it allocates for a range of length number , so the total allocation count is , dominated by the top-level range since the Catalan numbers grow by a factor of roughly four per step. Time and space are therefore both , and the space figure holds only thanks to structure sharing: the returned trees contain nodes each, so a version that deep-copied every subtree would need nodes. The counting-only variant, by contrast, is a time and space 1D DP, which is a good reminder of how much of the cost here belongs to the enumeration and not to the reasoning.
Rerooting is linear, and its whole point is that it is. Building the adjacency list from the edge list is since a tree has edges, the first pass visits every node once and every edge twice, and the second pass does the same with work per edge thanks to the transfer formula. The total is time against the of running the rooted DP from each of the roots, and the space is for the adjacency list and the two arrays, plus worst-case recursion depth on a path-shaped tree. The variant with a non-invertible merge adds prefix and suffix aggregates over the children of each node, whose sizes sum to across the whole tree and therefore do not change the bound.
Counting shortest paths inherits the cost of Dijkstra plus a constant amount of extra work. With a binary heap that admits duplicate entries rather than a decrease-key operation, every edge can push at most one entry, so the heap holds items and the loop performs extractions at each, for time, usually written since makes the two logarithms differ by a constant factor. The counting adds one addition or one assignment per relaxation, so it is absorbed. Space is for the adjacency list, the distance array, the count array and the heap. The crucial point is what we did not pay: the number of distinct shortest paths can be exponential in , and the DP aggregates over all of them in time proportional to the size of the graph, which is the entire reason for running a dynamic program over the shortest-path DAG instead of enumerating its paths.
| Exercise | Difficulty | Description |
|---|---|---|
| Binary Tree Cameras | Hard | Place the minimum number of cameras on a binary tree so that every node is monitored, knowing that a camera watches its own node, its parent and its direct children. |
| House Robber III | Medium | Maximise the money robbed from a binary tree of houses, knowing that two directly connected houses cannot both be robbed on the same night. |
| Number of Ways to Arrive at Destination | Medium | Count the shortest routes between the two ends of a city road network, folding a path-counting recurrence into the relaxation loop of Dijkstra. |
| Sum of Distances in Tree | Hard | Compute, for every node of an undirected tree, the sum of the distances to all the other nodes, using a rooted pass followed by a rerooting pass. |
| Unique Binary Search Trees II | Medium | Generate every structurally unique binary search tree holding the keys from one to n, building the trees with a memoized recursion over key ranges. |