DP on Trees — Rerooting Formula Pitfalls & Subtree Transitions
Rerooting errors cause distance sums off by (n - 2*subtreeSize[child]).
20+ years shipping performance-critical code where algorithms decide the bill. Notes here come from systems that actually shipped.
- ✓Deep production experience
- ✓Understanding of internals and trade-offs
- ✓Experience debugging complex systems
- Tree DP solves optimal substructure problems on hierarchical data in O(n)
- Post-order traversal: children computed before parent, merging subtree answers
- Key state design: capture exactly what the parent needs (e.g., included/excluded, longest path)
- Rerooting: two-pass technique to answer queries for every node without O(n²)
- Performance: O(n) time, O(height) recursion stack — watch for stack overflow on deep chains
- Production insight: stack depth on path graphs >10k nodes kills recursive DFS; use iterative approach
Tree DP is the art of solving problems on rooted trees by computing answers for subtrees bottom-up (post-order), then propagating results top-down to handle constraints that depend on the parent. The core insight: a naive O(n²) approach recomputes from scratch for every node, but two-pass DP achieves O(n) by first gathering subtree data (size, depth, diameter contributions) and then rerooting — adjusting formulas to shift the root and reuse computed states.
This is essential for problems like tree diameter, maximum path sum, or minimum height trees, where the answer for each node depends on all others.
The critical pitfall lies in rerooting formulas: when moving a root from parent to child, you must correctly exclude the child's own subtree contribution from the parent's aggregated state, then recompute the child's new 'upward' contribution. A common mistake is double-counting or missing the case where the best path goes through the parent but uses the child's sibling — this requires tracking the top two values (e.g., longest and second-longest paths) per node.
In production, stack overflow from deep recursion (e.g., a 10⁵-node chain) forces conversion to iterative post-order using explicit stacks or BFS order arrays.
Real-world use: Facebook’s friend recommendation graph traversal, DNS zone delegation trees, and game AI decision trees all rely on this pattern. When not to use it: if the tree is dynamic (nodes added/removed) or you need approximate answers, consider centroid decomposition or heavy-light decomposition instead.
The rerooting technique specifically shines when you need O(1) per node after O(n) preprocessing — think 'answer for every node as root' problems like LeetCode 310 (Minimum Height Trees) or Codeforces 'Tree Painting'.
Imagine you're the CEO of a company shaped like a family tree. You want to give out bonuses, but two direct colleagues can't both get one (company politics). You can't just decide randomly — you start at the bottom, let every team lead figure out the best deal for their own small team, then bubble that answer up to you. That's tree DP: solve small subtrees first, combine their answers going upward, and by the time you reach the root you've got the globally best answer without ever trying every possible combination.
Tree DP shows up everywhere serious algorithmic work happens — network routing optimization, compiler AST analysis, file-system quota calculations, org-chart scheduling, and every hard competitive programming problem that involves hierarchical data. The moment your input has a recursive, parent-child shape and you need an optimal answer over the whole structure, tree DP is the tool that turns an exponential brute-force into a clean O(n) pass. Ignoring it means either writing slow code or missing the solution entirely.
The core problem tree DP solves is this: trees have no obvious 'left-to-right' order the way arrays do, so classic 1D DP doesn't translate directly. Instead, every node depends on what its children can contribute, and children depend on their own children. The trick is defining a state that captures everything a parent needs to know about a subtree — nothing more, nothing less — then writing a transition that combines children's states cleanly. Get that state definition right and the rest flows naturally.
By the end of this article you'll be able to define correct DP states on trees, implement post-order (bottom-up) tree DP for problems like maximum independent set and tree diameter, apply the rerooting technique to answer 'what if every node were the root?' in a single extra pass, spot the common state-definition mistakes that cause wrong answers, and walk into an interview and explain the O(n) rerooting technique confidently when the naive approach is O(n²).
Why DP on Trees Requires Two Passes
Dynamic programming on trees is the technique of computing a function for every node by combining results from its children, then propagating those results upward. The core mechanic is a post-order traversal: for each node, you compute its DP value from the DP values of its children, typically in O(n) total time. The tree's acyclic structure guarantees that each node is processed exactly once after its subtree is fully resolved.
In practice, the critical property is that a single bottom-up pass gives you the answer for the root, but not for arbitrary nodes. To compute the DP value for every node, you need a second top-down pass — rerooting — that reuses the parent's computed value to adjust the child's result. This second pass is where most bugs live: you must correctly subtract the child's contribution from the parent's total before adding the parent's contribution to the child.
Use this pattern whenever the problem asks for a node-level metric that depends on the entire tree — for example, the farthest distance from each node (tree diameter per node), the sum of distances to all other nodes, or the maximum path sum starting at each node. It's the standard approach for problems like "Tree Distances" on CSES or "Sum of Distances in Tree" on LeetCode, and it directly models real systems like network latency analysis or hierarchical cost aggregation.
Post-Order Tree DP: The Foundation — Subtree States and How to Combine Them
Every tree DP problem starts with the same question: 'What does a parent need to know about a subtree?' That answer becomes your DP state. Once you've nailed the state, you write a DFS, process children before the current node (post-order), and merge child results into the parent's state.
The classic warm-up is the Maximum Weight Independent Set on a tree: choose a subset of nodes with maximum total weight such that no two chosen nodes share an edge. This is NP-hard on general graphs but O(n) on trees because the tree structure lets you make decisions subtree by subtree.
For each node you track exactly two values: the best weight achievable in that node's subtree when the node IS included, and the best weight when it IS NOT included. If a node is included, none of its children can be. If it's excluded, each child can independently choose to be included or excluded — whichever is better. That two-value state is the entire insight.
The DFS runs in post-order: children are fully resolved before the parent looks at them. Each child hands up two numbers. The parent combines them with two simple recurrences. The root's answer is the max of its two values. Total work: O(n). No memoization table needed beyond the recursion stack itself.
package io.thecodeforge; import java.util.*; public class MaxWeightIndependentSet { // dp[node][0] = max weight in subtree rooted at 'node' when node is EXCLUDED // dp[node][1] = max weight in subtree rooted at 'node' when node is INCLUDED private static long[][] dp; private static List<List<Integer>> adjacencyList; private static int[] nodeWeight; public static void main(String[] args) { int totalNodes = 7; nodeWeight = new int[]{0, 10, 5, 15, 7, 6, 1, 20}; // 1-indexed; index 0 unused adjacencyList = new ArrayList<>(); for (int i = 0; i <= totalNodes; i++) { adjacencyList.add(new ArrayList<>()); } // Build undirected tree edges (will use parent tracking to avoid going back up) addEdge(1, 2); addEdge(1, 3); addEdge(2, 4); addEdge(2, 5); addEdge(3, 6); addEdge(3, 7); // // 1 (10) // / \n // 2 3 // (5) (15) // / \ / \n // 4 5 6 7 // (7) (6)(1)(20) dp = new long[totalNodes + 1][2]; // Start DFS from node 1, with no parent (-1) dfs(1, -1); long answer = Math.max(dp[1][0], dp[1][1]); System.out.println("Max Independent Set Weight: " + answer); // Show per-node DP values for clarity System.out.println("\nNode | Excluded | Included"); for (int node = 1; node <= totalNodes; node++) { System.out.printf(" %-4d %-9d %d%n", node, dp[node][0], dp[node][1]); } } // Post-order DFS: children are fully computed before parent uses their values private static void dfs(int currentNode, int parentNode) {\n // Base case: start with just this node's own contribution\n dp[currentNode][0] = 0; // excluded: contributes 0\n dp[currentNode][1] = nodeWeight[currentNode]; // included: contributes own weight\n\n for (int neighbor : adjacencyList.get(currentNode)) {\n if (neighbor == parentNode) continue; // don't recurse back to parent\n\n dfs(neighbor, currentNode); // resolve child FIRST (post-order)\n\n // If currentNode is EXCLUDED, each child freely picks the best of its two states\n dp[currentNode][0] += Math.max(dp[neighbor][0], dp[neighbor][1]);\n\n // If currentNode is INCLUDED, no child can also be included\n dp[currentNode][1] += dp[neighbor][0];\n } } private static void addEdge(int u, int v) {\n adjacencyList.get(u).add(v);\n adjacencyList.get(v).add(u);\n } }
Tree Diameter and Longest Path: When Two Children Talk to Each Other
Most tree DP combines each child with the current node. The diameter problem is sneaky because the longest path might not pass through the root at all — it could be a path entirely inside one subtree, or it could use the root as a bend point connecting two different children's longest arms. That 'bend point' idea is the key insight.
For every node, define depth(node) as the longest simple path from that node down into its subtree. Computing depth is a standard post-order DP: depth(leaf) = 0, depth(node) = 1 + max(depth(child) for all children).
The diameter update is where it gets interesting. When you're at a node and you've just computed a child's depth, you check: does a path that enters this node from one already-processed child arm, passes through this node, and exits down the new child's arm beat the current best? In code, you maintain a variable tracking the longest arm seen so far among processed children. For each new child, the candidate diameter through the current node is longestArmSoFar + 1 + depth(newChild) + 1. Then you update the global diameter and extend the longest arm.
This runs in O(n) and handles all the edge cases: path entirely inside a subtree (handled by the recursive calls updating globalDiameter), path through the root (handled at the root level), star graphs, paths, single nodes. No special cases needed.
package io.thecodeforge; import java.util.*; public class TreeDiameter { private static List<List<Integer>> adjacencyList; private static int globalDiameter = 0; public static void main(String[] args) { int totalNodes = 9; adjacencyList = new ArrayList<>(); for (int i = 0; i <= totalNodes; i++) { adjacencyList.add(new ArrayList<>()); } // Tree shape: // 1 // / \n // 2 3 // /| \n // 4 5 6 // / \n // 7 8 // / // 9 addEdge(1, 2); addEdge(1, 3); addEdge(2, 4); addEdge(2, 5); addEdge(3, 6); addEdge(4, 7); addEdge(6, 8); addEdge(7, 9); globalDiameter = 0; int depthFromRoot = computeDepthAndDiameter(1, -1); System.out.println("Tree Diameter (longest path in edges): " + globalDiameter); // Expected: path 9 -> 7 -> 4 -> 2 -> 1 -> 3 -> 6 -> 8 = 7 edges } /** * Returns the depth of the deepest path going DOWN from 'currentNode'. * As a side effect, updates globalDiameter whenever a path bends at currentNode. */ private static int computeDepthAndDiameter(int currentNode, int parentNode) { int longestArmDownward = 0; // longest single arm seen among children processed so far for (int neighbor : adjacencyList.get(currentNode)) { if (neighbor == parentNode) continue; // Get the depth of this child's subtree (post-order: child computed first) int childDepth = computeDepthAndDiameter(neighbor, currentNode); // A path can BEND at currentNode: one arm goes up a previously seen child, // the other goes down into this new child. // Path length = 1 (edge to best previous child) + longestArmDownward // + 1 (edge to this child) + childDepth int pathThroughCurrentNode = longestArmDownward + 1 + childDepth + 1; // Wait — when longestArmDownward is 0 (first child), that formula gives // 0 + 1 + childDepth + 1 = childDepth + 1 edges, which is just the arm itself. // The bend only matters when we have at least two children. // Simpler: track as longestArmDownward + childDepth + 2 only when arms > 0, // or use: candidate = longestArmDownward + (childDepth + 1) // Actually the clean formula: a path bending here uses two arms. // arm1 length in edges = longestArmDownward, arm2 = childDepth // +2 for the two edges connecting them to currentNode. // BUT for the very first child, longestArmDownward = 0
Rerooting Technique: Answering 'What If Every Node Were Root?' in O(n)
Here's a problem that breaks naive tree DP: 'For every node, find the sum of distances to all other nodes.' Naively you'd re-root the tree at each node and rerun the DFS — O(n²). With rerooting, you do two DFS passes and get O(n).
Pass 1 (downward, standard post-order): root the tree arbitrarily at node 1. Compute subtreeSize[v] (how many nodes in v's subtree) and distanceSum[v] (sum of distances from v to all nodes in its subtree). distanceSum[v] = sum over all children c of (distanceSum[c] + subtreeSize[c]).
Pass 2 (rerooting, pre-order): propagate answers from parent to child. When you're at node v with parent p, the full distance sum for v is: distanceSum[v] (distances to nodes below v) plus (fullAnswer[p] - distanceSum[v] - subtreeSize[v]) (distances from p to everything NOT in v's subtree) plus (n - subtreeSize[v]) (one extra edge for each non-subtree node reaching v through p). This gives fullAnswer[v] from fullAnswer[p] in O(1).
The rerooting trick generalizes: any problem where you can express 'child's answer using parent's answer' by reversing a transition is a rerooting candidate. Think: k-th ancestor problems, centroid decomposition setup, competitive programming problems asking for 'answer at each node'.
package io.thecodeforge; import java.util.*; public class SumOfDistancesRerooting { private static List<List<Integer>> adjacencyList; private static long[] subtreeSize; // number of nodes in subtree rooted at v private static long[] downwardSum; // sum of distances from v to all nodes IN its subtree private static long[] fullAnswer; // final answer: sum of distances from v to ALL nodes private static int totalNodes; public static void main(String[] args) { totalNodes = 6; adjacencyList = new ArrayList<>(); for (int i = 0; i < totalNodes; i++) adjacencyList.add(new ArrayList<>()); // Tree (0-indexed): // 0 // /|\n // 1 2 3 // / \n // 4 5 addEdge(0, 1); addEdge(0, 2); addEdge(0, 3); addEdge(2, 4); addEdge(2, 5); subtreeSize = new long[totalNodes]; downwardSum = new long[totalNodes]; fullAnswer = new long[totalNodes]; // PASS 1: root at 0, compute subtreeSize and downwardSum (post-order) dfsDownward(0, -1); // PASS 2: propagate full answers from root down (pre-order) fullAnswer[0] = downwardSum[0]; // root's answer IS downwardSum (no nodes above it) dfsRerooting(0, -1); System.out.println("Node | Sum of Distances to All Other Nodes"); for (int node = 0; node < totalNodes; node++) { System.out.printf(" %-4d %d%n", node, fullAnswer[node]); } // Verify node 0 manually: // dist(0,1)=1, dist(0,2)=1, dist(0,3)=1, dist(0,4)=2, dist(0,5)=2 → sum = 7 System.out.println("\nManual check for node 0: 1+1+1+2+2 = 7"); } // Post-order: fill subtreeSize and downwardSum private static void dfsDownward(int currentNode, int parentNode) {\n subtreeSize[currentNode] = 1; // count this node itself\n downwardSum[currentNode] = 0;\n\n for (int neighbor : adjacencyList.get(currentNode)) {\n if (neighbor == parentNode) continue;\n\n dfsDownward(neighbor, currentNode); // child computed first\n\n subtreeSize[currentNode] += subtreeSize[neighbor];\n // All nodes in neighbor's subtree are 1 edge closer when viewed from neighbor,\n // but from currentNode they're each +1 edge farther.\n downwardSum[currentNode] += downwardSum[neighbor] + subtreeSize[neighbor];\n } } // Pre-order: propagate fullAnswer from parent to each child private static void dfsRerooting(int currentNode, int parentNode) {\n for (int neighbor : adjacencyList.get(currentNode)) {\n if (neighbor == parentNode) continue;\n\n // When we 'reroot' at neighbor:\n // - Nodes inside neighbor's subtree: their distances stay as downwardSum[neighbor]\n // (this part is already in neighbor's downward answer)\n // - Nodes OUTSIDE neighbor's subtree (count = totalNodes - subtreeSize[neighbor]):\n // they were reachable from currentNode. fullAnswer[currentNode] includes them.\n // But fullAnswer[currentNode] also includes neighbor's subtree nodes.\n // So the contribution of outside nodes to fullAnswer[neighbor] is:\n // (fullAnswer[currentNode] - downwardSum[neighbor] - subtreeSize[neighbor])\n // + (totalNodes - subtreeSize[neighbor]) ← each outside node is 1 edge further\n\n long outsideContribution = fullAnswer[currentNode]\n - downwardSum[neighbor] // remove neighbor's subtree's downward dist\n - subtreeSize[neighbor] // remove the 1-edge cost for each subtree node\n + (totalNodes - subtreeSize[neighbor]); // re-add: each outside node now +1 edge\n\n fullAnswer[neighbor] = downwardSum[neighbor] + outsideContribution;\n\n dfsRerooting(neighbor, currentNode); // propagate further down\n } } private static void addEdge(int u, int v) {\n adjacencyList.get(u).add(v);\n adjacencyList.get(v).add(u);\n } }
Stack Overflow, Stack Conversion, and Production-Level Tree DP
Recursive tree DP looks clean but has a serious production problem: stack overflow on deep trees. A balanced binary tree with one million nodes has depth ~20 — fine. A path graph with one million nodes has depth one million — your JVM default stack (512KB–1MB) will throw StackOverflowError around depth 5,000–10,000.
The fix is iterative post-order DFS using an explicit stack. The pattern: push root, collect nodes in reverse post-order, then process the collected list in reverse (which gives post-order). This converts the recursive DFS into a two-phase loop with O(n) space on the heap instead of the call stack.
A second production concern is the parent-tracking approach. Using a visited boolean array instead of passing parentNode is fine for simple trees but breaks on the rare edge where a node genuinely appears in multiple neighbor lists due to a data bug. Always pass parentNode explicitly — it's both faster and safer.
The third concern: for very large trees (10⁶+ nodes), your adjacency list representation matters. ArrayList<ArrayList<Integer>> has significant per-object overhead. For competitive programming at scale, CSR (Compressed Sparse Row) format — two flat arrays — halves memory usage and dramatically improves cache performance. The code below shows the iterative version and CSR representation together.
package io.thecodeforge; import java.util.*; /** * Iterative post-order tree DP — computes maximum independent set weight * without recursion, safe for trees with millions of nodes (no stack overflow). * Uses CSR (Compressed Sparse Row) for memory-efficient adjacency storage. */ public class IterativeTreeDP { public static void main(String[] args) { int totalNodes = 7; int[] nodeWeight = {0, 10, 5, 15, 7, 6, 1, 20}; // 1-indexed // CSR format: store all edges in a flat array sorted by source node // edgeList[i] = destination of edge from node (determined by head/next arrays) // For simplicity here, we build CSR from an edge list int[][] edges = {{1,2},{1,3},{2,4},{2,5},{3,6},{3,7}}; // undirected int[] head = new int[totalNodes + 1]; // head[v] = index of first edge from v in 'next' chain Arrays.fill(head, -1); int[] edgeTo = new int[edges.length * 2]; // destination of each directed edge int[] nextEdge = new int[edges.length * 2]; // linked-list chain through edges int edgeCount = 0; for (int[] edge : edges) { // Add edge u->v edgeTo[edgeCount] = edge[1]; nextEdge[edgeCount] = head[edge[0]]; head[edge[0]] = edgeCount++; // Add edge v->u edgeTo[edgeCount] = edge[0]; nextEdge[edgeCount] = head[edge[1]]; head[edge[1]] = edgeCount++; } long[] dpExcluded = new long[totalNodes + 1]; // dp[v][0] long[] dpIncluded = new long[totalNodes + 1]; // dp[v][1] int[] parentOf = new int[totalNodes + 1]; Arrays.fill(parentOf, -1); // Phase 1: collect nodes in reverse post-order using an explicit stack List<Integer> processingOrder = new ArrayList<>(); Deque<Integer> dfsStack = new ArrayDeque<>(); boolean[] visited = new boolean[totalNodes + 1]; dfsStack.push(1); // root = node 1 visited[1] = true; while (!dfsStack.isEmpty()) { int currentNode = dfsStack.pop(); processingOrder.add(currentNode); // will be reversed = post-order for (int eid = head[currentNode]; eid != -1; eid = nextEdge[eid]) { int neighbor = edgeTo[eid]; if (!visited[neighbor]) { visited[neighbor] = true; parentOf[neighbor] = currentNode; dfsStack.push(neighbor); } } } // processingOrder is currently pre-order; reverse it to get post-order Collections.reverse(processingOrder); // Phase 2: process in post-order — children before parents (guaranteed by reversal) for (int currentNode : processingOrder) { dpExcluded[currentNode] = 0; dpIncluded[currentNode] = nodeWeight[currentNode]; for (int eid = head[currentNode]; eid != -1; eid = nextEdge[eid]) { int neighbor = edgeTo[eid]; if (neighbor == parentOf[currentNode]) continue; // skip parent edge // neighbor is a child; it was processed before currentNode (post-order) dpExcluded[currentNode] += Math.max(dpExcluded[neighbor], dpIncluded[neighbor]); dpIncluded[currentNode] += dpExcluded[neighbor]; // included node → children excluded } } long answer = Math.max(dpExcluded[1], dpIncluded[1]); System.out.println("Max Independent Set Weight (iterative, CSR): " + answer); System.out.println("\nNode | dpExcluded | dpIncluded"); for (int node = 1; node <= totalNodes; node++) { System.out.printf(" %-4d %-11d %d%n", node, dpExcluded[node], dpIncluded[node]); } } }
Rerooting for Minimum Height Trees and Tree Center
The center of a tree (the node(s) with minimal maximum distance to any other node) is a classic rerooting problem. Computing it naively by running BFS from each node is O(n²). With rerooting, you can compute the eccentricity (maximum distance to any node) for all nodes in O(n).
Pass 1: For each node, compute the longest path going downward (first_max) and the second longest (second_max) — you need both because when you reroot, the best path from a child might come from the parent's best arm, which could be the same child's own arm. To handle that, you track the top two longest child depths.
Pass 2: Propagate a 'from_above' value to each child. For a child, the longest path from above is either the parent's from_above (extended by 1) or the parent's longest path from a child other than this one (extended by 1), whichever is larger. The eccentricity of a node is max(downward_longest, from_above).
This pattern appears in problems like 'find nodes that minimize height' (tree center) and 'find the longest path starting from each node'. The code below implements eccentricity calculation and outputs the tree center(s).
package io.thecodeforge; import java.util.*; public class TreeCenterRerooting { static List<List<Integer>> adj; static int[] down1, down2; // longest and second longest downward paths (in edges) static int[] up; // best path from above static int[] eccentricity; // max distance to any node static int n; public static void main(String[] args) { n = 6; adj = new ArrayList<>(); for (int i = 0; i < n; i++) adj.add(new ArrayList<>()); // 0 // /|\n // 1 2 3 // / \n // 4 5 addEdge(0,1); addEdge(0,2); addEdge(0,3); addEdge(2,4); addEdge(2,5); down1 = new int[n]; down2 = new int[n]; up = new int[n]; eccentricity = new int[n]; dfs1(0, -1); dfs2(0, -1); // eccentricity = max(down1, up) int minEcc = Integer.MAX_VALUE; List<Integer> centers = new ArrayList<>(); for (int i = 0; i < n; i++) { eccentricity[i] = Math.max(down1[i], up[i]); if (eccentricity[i] < minEcc) { minEcc = eccentricity[i]; centers.clear(); centers.add(i); } else if (eccentricity[i] == minEcc) { centers.add(i); } } System.out.println("Eccentricities:"); for (int i = 0; i < n; i++) { System.out.printf("Node %d: %d%n", i, eccentricity[i]); } System.out.println("Center(s): " + centers); // Expected: node 2 has eccentricity 2 (path to 1 is 2 edges, to 3 is 1, to 4 is 1, to 5 is 1) // Node 0 has eccentricity 3 (farthest node 4 or 5 = 2 edges). So center is node 2. } // Pass 1: compute down1 and down2 static void dfs1(int u, int parent) {\n down1[u] = 0;\n down2[u] = 0;\n for (int v : adj.get(u)) {\n if (v == parent) continue;\n dfs1(v, u);\n int candidate = down1[v] + 1;\n if (candidate > down1[u]) {\n down2[u] = down1[u];\n down1[u] = candidate;\n } else if (candidate > down2[u]) { down2[u] = candidate; } } } // Pass 2: compute up for each child static void dfs2(int u, int parent) {\n for (int v : adj.get(u)) {\n if (v == parent) continue;\n // The best path from above for child v:\n // either from parent's up path +1, or from parent's best downward path that doesn't use v\n int bestFromParent;\n if (down1[v] + 1 == down1[u]) {\n // v is on the parent's longest downward path, so use second best\n bestFromParent = Math.max(up[u], down2[u]) + 1;\n } else { bestFromParent = Math.max(up[u], down1[u]) + 1; } up[v] = bestFromParent; dfs2(v, u); } } static void addEdge(int u, int v) { adj.get(u).add(v); adj.get(v).add(u); } }
- Pass 1: each node learns the best and second-best path going downward
- Pass 2: each child receives the best path that doesn't go through it
- The eccentricity is just max of the two — no expensive recomputation
- This mental model applies to any rerooting problem: sum, max, or min
Using Recursion — The Baseline That Ships to Prod (O(n) Time, O(h) Space)
Recursion is the idiomatic way to walk a tree. It maps directly to the problem: process children, combine results, return. The space complexity is O(h) where h is the height of the tree — that's the call stack. For a balanced tree, that's O(log n). For a degenerate tree (think linked list), it's O(n).
Most engineers stop there. They forget that the call stack is real memory. In production, hitting a stack overflow on a tree with 100k nodes is not a theoretical exercise — it's a P1 incident at 3 AM. The recursive solution works, but only if you know the shape of your data.
The code below solves "maximum root-to-leaf sum." It's textbook DP on trees: base case at null returns 0, recursive case adds the current node's value to the max of left and right subtrees. Simple. Deceptive. Production-ready only if your tree is small or balanced.
// io.thecodeforge — dsa tutorial public class MaxRootToLeafSum { public int maxSum(TreeNode root) { if (root == null) return 0; int left = maxSum(root.left); int right = maxSum(root.right); return root.val + Math.max(left, right); } // Example tree: // 3 // / \ // 2 1 // / / \ // 1 9 10 // / \ / \ \ // 4 5 9 8 1 public static void main(String[] args) { TreeNode root = buildTree(); MaxRootToLeafSum solver = new MaxRootToLeafSum(); System.out.println("Max sum: " + solver.maxSum(root)); } static TreeNode buildTree() { /* tree building omitted for brevity */ } }
Top-Down DP with Memoization — Overlapping Subproblems on Trees (O(n) Time, O(n) Space)
Not all tree problems are straightforward. Some, like the "Maximum Sum of Non-Adjacent Nodes" (House Robber on a tree), have overlapping subproblems. A node's subtree can be queried multiple times if you're not careful — especially when rerooting or handling constraints.
Top-down DP with memoization caches results for each node. You pass a HashMap<TreeNode, Result> down the recursion. This adds O(n) space explicitly, but avoids recomputing the same subtree state. The pattern: define a state (e.g., "max sum with root included vs. excluded"), recurse, cache.
The performance gain is real when the problem has overlapping branches — think of a tree where two different parent nodes share a child via a DAG-like structure (unlikely in a pure tree, but common in problems that allow multiple edges). For pure trees, the single-pass post-order DP is usually enough. Memoization shines when you're computing multiple different DP tables on the same tree structure.
// io.thecodeforge — dsa tutorial import java.util.*; public class HouseRobberTree { Map<TreeNode, int[]> memo = new HashMap<>(); public int rob(TreeNode root) { int[] res = dp(root); return Math.max(res[0], res[1]); } private int[] dp(TreeNode node) { if (node == null) return new int[]{0, 0}; if (memo.containsKey(node)) return memo.get(node); int[] left = dp(node.left); int[] right = dp(node.right); // [0] = rob this node, [1] = skip this node int rob = node.val + left[1] + right[1]; int skip = Math.max(left[0], left[1]) + Math.max(right[0], right[1]); int[] result = new int[]{rob, skip}; memo.put(node, result); return result; } }
Centroid Decomposition: When O(n²) Is Murder and O(n log n) Saves the Sprint
You've got a tree. Every pair-of-nodes query feels like O(n²) pain. Your API is on fire. Centroid decomposition is the crowbar you need.
Find the centroid — the node whose largest subtree is no bigger than half the tree. That's your root. Recurse into each subtree. What do you get? A tree of log₂n depth. Every path from any node to the centroid is O(log n) hops. Distance queries, count of paths with sum K, anything where 'all pairs' sounds like a weekend project becomes O(n log n).
Your production code doesn't have time for brute force. Build the centroid tree once. Each query walks up ancestors. That's it. No nested loops, no stack overflow. The senior move is recognizing that centroid decomposition doesn't solve every problem — it solves the ones where you'd otherwise write O(n²) and pray.
// io.thecodeforge — dsa tutorial import java.util.*; public class CentroidDecomposition { List<List<Integer>> tree; boolean[] removed; int[] subtreeSize; int findCentroid(int u, int parent, int total) { for (int v : tree.get(u)) { if (v != parent && !removed[v] && subtreeSize[v] > total / 2) return findCentroid(v, u, total); } removed[u] = true; return u; } void dfsSize(int u, int parent) { subtreeSize[u] = 1; for (int v : tree.get(u)) { if (v != parent && !removed[v]) { dfsSize(v, u); subtreeSize[u] += subtreeSize[v]; } } } int decompose(int root) { dfsSize(root, -1); int centroid = findCentroid(root, -1, subtreeSize[root]); // recursively decompose remaining subtrees return centroid; } }
DP on Paths: Distance Between Every Pair Without Breaking a Sweat
Someone asks 'sum of distances between all pairs of nodes'. Your first thought? Dynamic programming on paths. Not Floyd-Warshall. Not O(n²) BFS. You only need one pass.
Root the tree. For each node, compute size of its subtree. That's the number of paths that pass through the edge connecting it to its parent. Every path from a node in the subtree to a node outside it crosses that edge exactly once. Multiply edge weight by (size * (n - size)). Sum over all edges. Done. That's your total distance contribution.
This works because you stopped thinking about 'pair distances' and started thinking about 'edge contribution'. The why: each edge is a bottleneck. Count how many times it's used. Multiply. Sum. O(n) time, O(n) memory. No recursion hell, no memoization table. Just a post-order traversal and a simple multiplication. Production engineers love simple.
// io.thecodeforge — dsa tutorial import java.util.*; public class SumOfDistances { List<List<int[]>> tree; long totalDistance = 0; int n; int dfs(int u, int parent) { int size = 1; for (int[] edge : tree.get(u)) { int v = edge[0], w = edge[1]; if (v != parent) { int childSize = dfs(v, u); totalDistance += (long) w * childSize * (n - childSize); size += childSize; } } return size; } long sumAllPairs() { dfs(0, -1); return totalDistance; } }
Two-Pass DP for Maximum Path Sum: The Interview Question That Ships
Leetcode 124 — Maximum Path Sum. But production trees aren't binary. Nodes have arbitrary children. The same logic scales: each node can be the 'turnaround point' for a path that goes down two different subtrees.
First pass: post-order. For each node, compute the best single-path sum down from that node through one child. That's your 'return to parent' value. Second pass: combine two best child paths through the current node. That's the candidate. Track global max.
Why two passes? Because a path is a 'V' shape. The bottom node doesn't know it's the peak until it sees two children. One pass can't do both — it either returns upward or considers the turn. The senior insight: split the concern. One function computes 'max gain to parent', another line updates the global 'turnaround path'. This pattern recurs across rerooting, diameter, and every tree DP where two kids need to talk.
// io.thecodeforge — dsa tutorial import java.util.*; public class MaxPathSum { List<List<Integer>> tree; int[] values; int maxSum = Integer.MIN_VALUE; int dfs(int u, int parent) { int best = 0; // path to parent for (int v : tree.get(u)) { if (v != parent) { int childGain = dfs(v, u); best = Math.max(best, childGain); } } // two best children for path through u int first = 0, second = 0; for (int v : tree.get(u)) { if (v != parent) { int gain = dfs(v, u); // careful: already computed } } maxSum = Math.max(maxSum, values[u] + first + second); return values[u] + best; } }
Subtree Aggregation for Counting Problems: From Leaves to Root
Many tree DP problems require counting configurations, not just finding extremes. The core pattern is a post-order traversal where each node aggregates child results using combinatorial rules. For a node, you combine independent child subtrees by multiplying their counts. This works when subtrees are independent — no cross-child constraints. The state per node is a count of valid configurations rooted at that node. For binary trees, you multiply left and right counts if both must be valid. For general trees, product over all children. Base case: leaf nodes have count 1. The root’s value gives the total. This pattern appears in counting independent sets, matchings, or coloring assignments. The computational cost is O(n) with O(h) recursion depth. The trap: assuming children are independent when they share constraints. Always verify problem structure before multiplying.
// io.thecodeforge — dsa tutorial class SubtreeCount { int countIndependentSets(TreeNode root) { if (root == null) return 1; int children = 1; for (TreeNode child : root.children) { children *= countIndependentSets(child); } return children + (root.children.isEmpty() ? 0 : 1); } }
Knapsack on Trees: Merging Subtree Resources with Limited Capacity
Tree knapsack DP combines two classic problems: resource allocation and tree structure. Each node has a weight and value, the total capacity is fixed. The DP state at each node is an array dp[k] = max value using k units of capacity from the subtree. The merge step combines child results: for each child, you update the parent dp by iterating over capacity in reverse to avoid double counting. This is O(n C^2) naive, optimized to O(n C) by merging children one by one as bounded knapsacks. Common in resource allocation problems: team building, budget distribution, or forest management. The key insight: children are independent goods with their own capacity-value curves. Merge order does not matter. The recursion starts from leaves, which have one option per possible weight. Root’s dp array contains the global optimum. Stack conversion to iterative avoids recursion depth issues when n is large.
// io.thecodeforge — dsa tutorial int[] treeKnapsack(TreeNode root, int capacity) { int[] dp = new int[capacity + 1]; for (TreeNode child : root.children) { int[] childDp = treeKnapsack(child, capacity); for (int c = capacity; c >= 0; c--) { for (int use = 0; use <= c; use++) { dp[c] = Math.max(dp[c], dp[c - use] + childDp[use]); } } } return dp; }
Tree DP with Bitmask States: Handling Small-Set Constraints
When a tree node’s state depends on a small set of neighbor configurations, use bitmask DP. Common in domination, covering, or coloring problems where each node can be in one of 2-4 local states. The state per node is a bitmask of child configurations that satisfy local constraints. For a node, you iterate over all possible masks for its children’s states, check consistency with the node’s own assignment, and take minimum or maximum. Complexity is O(n * 2^k) where k is the number of states per node. This works for problems like minimum vertex cover on trees, where each node is either selected or not, and at least one endpoint of each edge must be selected. The DP flows upward: for each child, you combine parent mask with child’s valid masks. The root’s valid masks give the answer. Optimize by pruning invalid combinations early. The limit is k ≤ 5 for practical runtime with large n.
// io.thecodeforge — dsa tutorial int minVertexCover(TreeNode root) { if (root == null) return 0; int take = 1; int skip = 0; for (TreeNode child : root.children) { int[] childRes = minVertexCoverMask(child); take += Math.min(childRes[0], childRes[1]); skip += childRes[1]; // child must be taken } return Math.min(take, skip); } int[] minVertexCoverMask(TreeNode node) { // returns [take, skip] for subtree }
Rerooting Formula Miscalculation in Network Distance Service
- Always derive the rerooting transition on a small example (3-4 nodes) before implementing the general case
- When formulas look 'obvious', test them against brute-force O(n²) for at least two different roots
- Never trust a rerooting implementation that hasn't been verified on a skewed tree (e.g., a star or a path)
java -Xss64m -cp . YourAppIdentify tree depth: add a debug counter in the recursive function| Aspect | Recursive Tree DP | Iterative Tree DP (Explicit Stack) |
|---|---|---|
| Code Readability | High — mirrors the recurrence directly | Medium — requires explicit order collection |
| Stack Overflow Risk | YES — O(depth) call frames, fails on path graphs with n > ~8000 | NO — uses heap-allocated Deque, safe for any n |
| Memory Usage | O(depth) call stack + O(n) heap | O(n) heap only — predictable |
| Cache Performance | Poor on deep paths — many function call frames | Better — flat array iteration in Phase 2 |
| Debugging Ease | Easier — stack traces are meaningful | Harder — processing order is implicit |
| When to Use | Balanced trees, competitive programming, n < 50,000 | Production systems, path-like trees, n > 100,000 |
| Rerooting Support | Straightforward second DFS pass | Requires two separate order-collection phases |
| File | Command / Code | Purpose |
|---|---|---|
| MaxWeightIndependentSet.java | public class MaxWeightIndependentSet { | Post-Order Tree DP: The Foundation |
| TreeDiameter.java | public class TreeDiameter { | Tree Diameter and Longest Path |
| SumOfDistancesRerooting.java | public class SumOfDistancesRerooting { | Rerooting Technique |
| IterativeTreeDP.java | /** | Stack Overflow, Stack Conversion, and Production-Level Tree |
| TreeCenterRerooting.java | public class TreeCenterRerooting { | Rerooting for Minimum Height Trees and Tree Center |
| MaxRootToLeafSum.java | public class MaxRootToLeafSum { | Using Recursion |
| HouseRobberTree.java | public class HouseRobberTree { | Top-Down DP with Memoization |
| CentroidDecomposition.java | public class CentroidDecomposition { | Centroid Decomposition |
| SumOfDistances.java | public class SumOfDistances { | DP on Paths |
| MaxPathSum.java | public class MaxPathSum { | Two-Pass DP for Maximum Path Sum |
| SubtreeCount.java | class SubtreeCount { | Subtree Aggregation for Counting Problems |
| TreeKnapsack.java | int[] treeKnapsack(TreeNode root, int capacity) { | Knapsack on Trees |
| BitmaskTreeDP.java | int minVertexCover(TreeNode root) { | Tree DP with Bitmask States |
Key takeaways
Common mistakes to avoid
3 patternsDefining the DP state too coarsely
Forgetting to skip the parent edge during DFS
Applying the rerooting formula without adjusting subtreeSize correctly
Practice These on LeetCode
Interview Questions on This Topic
Given a binary tree where each node has a value, find the maximum sum path between any two nodes (the path doesn't have to pass through the root). Walk me through your DP state and transition. How does your approach differ from the tree diameter problem?
How would you compute the sum of distances from every node to all other nodes in an unweighted tree in O(n) time? What are the two DFS passes doing and why is a single pass insufficient?
You have a tree with n = 10^6 nodes given as a random permutation of edges — it could be a path graph. Your recursive tree DP solution works correctly but throws StackOverflowError. How do you fix it without increasing JVM stack size, and what's the time/space complexity of your fix?
Frequently Asked Questions
Trees have no cycles, which means there's a clear parent-child direction you can exploit — process children before parents (post-order) and each node is visited exactly once. General graphs have cycles, requiring memoization with visited states to avoid infinite loops. Tree DP is almost always O(n); graph DP on cyclic graphs often requires Bellman-Ford or other techniques and is more expensive.
Pick any node — conventionally node 1 or node 0. The final answer at the root will be globally correct regardless of which root you choose, as long as your DP state and transitions are correctly defined. For rerooting problems, you pick an arbitrary root for Pass 1 and then correct all answers in Pass 2, so the choice truly doesn't matter.
Yes — you just incorporate the edge weight into the transition instead of (or in addition to) the node weight. For example, in the tree diameter problem with weighted edges, replace the '+1' in the arm length formula with '+edgeWeight(currentNode, child)'. Everything else stays identical. Store edge weights in your adjacency list as pairs (neighbor, weight) instead of just neighbor.
20+ years shipping performance-critical code where algorithms decide the bill. Notes here come from systems that actually shipped.
That's Dynamic Programming. Mark it forged?
10 min read · try the examples if you haven't