Tree Problem Patterns — Comprehensive Guide
Trees are everywhere in competitive programming. This guide covers every major technique for solving tree problems, organized by what kind of query or operation you need to perform.
Prerequisites & scope Sections 1–2 (traversals, Euler tour) and the basic Tree DP patterns in Section 9 are beginner-friendly and assume only that you can write a recursive DFS. Everything else — HLD (§3), Binary Lifting LCA (§4), Centroid Decomposition (§7), DSU-on-Tree / small-to-large (§8), Virtual Tree (§10), and Prufer sequences (§11) — is intermediate→advanced and assumes you are already comfortable with DFS, recursion, and basic tree DP. If those aren’t solid yet, master Sections 1–2 and 9 first, then come back.
Quick Navigation: “I need to…”
| I need to… | Technique | Section |
|---|---|---|
| Traverse a tree | DFS / BFS | 1 |
| Query values in a subtree | Euler Tour + Segment Tree | 2 |
| Query values on a path | HLD + Segment Tree | 3 |
| Find Lowest Common Ancestor | Binary Lifting / Euler+RMQ | 4 |
| Compute DP for every node as root | Rerooting DP | 5 |
| Find diameter / center of tree | Two-BFS or Tree DP | 6 |
| Answer distance-based queries | Centroid Decomposition | 7 |
| Merge child info efficiently | Small-to-Large (DSU on Tree) | 8 |
| Compute DP on subtrees | Tree DP | 9 |
| Work with only key nodes | Virtual Tree | 10 |
| Count/encode labeled trees | Prufer Sequence | 11 |
| Check if two trees are identical | Tree Hashing | 12 |
From-Scratch Idea: What Makes Trees Special
A tree is a graph with no cycles — N nodes, N-1 edges, exactly one path between any two nodes. That “exactly one path” is the entire reason tree algorithms are simpler and faster than general-graph ones: there is nothing to revisit. Once a DFS moves from a node into a child, it can never come back around a cycle, so a single parent guard replaces the whole visited[] set you’d need on a graph.
Almost every tree technique is a variation on one move: post-order DFS, where a node computes its answer after its children have computed theirs.
Compute leaves first, combine upward:
answer(node) = f( answer(child₁), answer(child₂), ... )
▲ ▲
already known already known
(children done before parent)
That single recurrence — “a node’s value is a function of its children’s values” — is subtree DP, diameter, rerooting, hashing, and small-to-large all at once. What differs is what f combines and what each node returns.
★ Insight ─────────────────────────────────────
- The
parentargument IS the visited set. On an undirected adjacency list,for child in adj[node]: if child == parent: continueis the only thing stopping infinite recursion. Forget it and the DFS bouncesnode → child → node → childforever. LeetCode binary trees dodge this entirely becauseleft/rightpointers already point away from the parent. - Post-order = bottom-up = “children before parent.” Any problem phrased as “for each subtree, compute X” is post-order. Any problem phrased as “for each node, using info from above” (rerooting, depth) has a pre-order (top-down) phase too. Knowing which direction the information flows tells you where to put your work — before the child recursion or after it.
─────────────────────────────────────────────────
LeetCode ↔ CP translation
This guide uses CP conventions (adjacency lists, 0-indexed nodes, a parent guard). Most LeetCode tree problems hand you a TreeNode with .left / .right instead. The pattern is identical — only the plumbing differs:
| CP style (this guide) | LeetCode TreeNode style |
|---|---|
for child in adj[node]: if child==parent: continue |
for child in (node.left, node.right): if child: |
pass parent to avoid looping back |
pointers already face away — no parent needed |
| node = integer index into arrays | node = object; use a dict or attribute for memo |
| post-order aggregation returns a number/tuple | same — the recursion returns per-subtree info |
Every walkthrough below is written in the LeetCode TreeNode form (that’s what you’ll practice on), then tied back to the CP technique it instantiates.
Master LeetCode Comparison Table
One numbered problem per core technique — solve these in order and you have touched every beginner→intermediate tree pattern. Each is expanded into a full walkthrough later in its section.
| LC # | Problem | Technique (§) | Difficulty | What each DFS returns | One-line transition |
|---|---|---|---|---|---|
| 104 | Maximum Depth of Binary Tree | Traversal / height (§1) | Easy | height of subtree | 1 + max(left, right) |
| 543 | Diameter of Binary Tree | Path through node (§6, §9) | Easy | height; side-effect tracks best | best = max(best, Lh + Rh) |
| 337 | House Robber III | Select/Skip Tree DP (§9) | Medium | pair (rob, skip) |
rob = val + skipL + skipR |
| 236 | Lowest Common Ancestor | LCA (§4) | Medium | which target(s) found below | both sides non-null → this node is LCA |
| 834 | Sum of Distances in Tree | Rerooting DP (§5) | Hard | subtree size + dist sum, then reroot | ans[c] = ans[node] − size[c] + (n − size[c]) |
| 1519 | Nodes in Subtree with Same Label | Subtree merge / small-to-large (§8) | Medium | label-frequency map of subtree | merge child maps into parent |
★ Insight ─────────────────────────────────────
- Read the “what each DFS returns” column top to bottom — it’s the whole skill. A tree problem is “solved” the moment you can name the single value each node hands its parent. Height (a number),
(rob, skip)(a pair), a frequency map (a dict): pick the smallest object that lets the parent finish its own computation. - Notice 543 returns a height but answers a diameter. The return value and the answer are often different things: the diameter lives in a side-effect variable while the recursion keeps returning heights so the parent can keep climbing. That split — “return what the parent needs, record the answer on the side” — is the most reused idea in tree DP.
─────────────────────────────────────────────────
Table of Contents
- Tree Traversals
- Euler Tour (Subtree Queries)
- Heavy-Light Decomposition (Path Queries)
- Lowest Common Ancestor
- Rerooting DP
- Tree Diameter and Center
- Centroid Decomposition
- Small-to-Large Merging (DSU on Tree)
- Tree DP Patterns
- Virtual Tree (Auxiliary Tree)
- Prufer Sequence
- Tree Hashing and Isomorphism
- Pattern Recognition Cheat Sheet
1. Tree Traversals
The foundation of everything. Every tree technique starts with a traversal.
DFS (Depth-First Search)
Goes deep before going wide. The workhorse of tree algorithms.
def dfs(node, parent, adj):
# PRE-ORDER: process node before children
print(f"Enter {node}")
for child in adj[node]:
if child == parent:
continue
dfs(child, node, adj)
# POST-ORDER: process node after children
print(f"Leave {node}")
Tree: 1
/ \
2 3
/ \
4 5
Pre-order: 1, 2, 4, 5, 3 (enter order — top down)
Post-order: 4, 5, 2, 3, 1 (leave order — bottom up)
BFS (Breadth-First Search)
Goes level by level. Used for shortest paths in unweighted trees.
from collections import deque
def bfs(root, adj):
dist = [-1] * len(adj)
dist[root] = 0
queue = deque([root])
while queue:
node = queue.popleft()
for child in adj[node]:
if dist[child] == -1:
dist[child] = dist[node] + 1
queue.append(child)
return dist
When to Use Which
| DFS | BFS |
|---|---|
| Subtree computations | Level-order processing |
| Path problems | Shortest distance (unweighted) |
| Euler tour, HLD, LCA | Finding tree diameter |
| Most tree DP | Multi-source BFS on trees |
Iterative DFS (for large trees in Python)
def dfs_iterative(root, adj):
parent = [-1] * len(adj)
order = [] # stores nodes in DFS pre-order
stack = [(root, -1, False)]
while stack:
node, par, visited = stack.pop()
if visited:
# POST-ORDER work here
continue
parent[node] = par
order.append(node)
stack.append((node, par, True)) # for post-order
for child in adj[node]:
if child != par:
stack.append((child, node, False))
return order, parent
Walkthrough — LC 104 Maximum Depth of Binary Tree
This template solves: LC 104 (Max Depth), LC 559 (Max Depth N-ary Tree), LC 111 (Min Depth), LC 110 (Balanced Binary Tree).
Given the
rootof a binary tree, return its maximum depth — the number of nodes along the longest path from the root down to the farthest leaf.
This is the “hello world” of trees, and it is pure post-order aggregation: a node’s height is 1 + the taller of its two children. A child must finish before the parent can add its +1, so the work happens after the two recursive calls.
def maxDepth(root):
if not root:
return 0 # empty subtree has height 0
return 1 + max(maxDepth(root.left), maxDepth(root.right))
tree: 3
/ \
9 20
/ \
15 7
maxDepth(9) = 1 + max(0,0) = 1 leaf
maxDepth(15) = 1 leaf
maxDepth(7) = 1 leaf
maxDepth(20) = 1 + max(1,1) = 2 children 15,7
maxDepth(3) = 1 + max(1,2) = 3 children 9,20 ← answer
The whole recursion is one line because the base case (None → 0) and the combine step (1 + max(...)) are all a height problem has. Swap max for min (and guard the one-child case) and you have LC 111 Minimum Depth; return the height and a “is-balanced” flag as a tuple and you have LC 110.
★ Insight ─────────────────────────────────────
Nonereturning 0 is the base case that makes the recursion terminate. Every tree DFS needs an answer for the empty subtree; here it’s the identity for “height so far.” Get this wrong (e.g. returning 1 forNone) and every depth is inflated by the number of missing children — a classic off-by-one.- This is the height archetype at the root of the Practice Order ladder. Subtree size (
1 + sum(children)), subtree sum, and node count are the same shape withmaxswapped forsum. Master this and every §9 subtree-aggregation DP is a one-symbol change.─────────────────────────────────────────────────
2. Euler Tour (Subtree Queries)
Problem type: “Query/update all nodes in the subtree of X”
Not the same Euler tour as the LCA one. This section records each node once, as a
tin/toutpair, giving an N-entry array in which a subtree is a contiguous range. The LCA guide’s Euler tour re-records a node on every backtrack, giving a 2N−1 array whose purpose is range-minimum over depths. Same DFS, different bookkeeping, different payoff — do not copy one convention into the other’s code.
The Idea
DFS assigns each node an enter time (tin) and exit time (tout). A node’s entire subtree falls within [tin[node], tout[node]] in the flattened array.
Tree: 0
/ \
1 2
/ \
3 4
DFS order: 0 -> 1 -> 3 -> 4 -> 2
tin: [0, 1, 4, 2, 3]
tout: [4, 3, 4, 2, 3]
Flat array: [0] [1] [3] [4] [2]
0 1 2 3 4
Subtree of 1: tin[1]=1, tout[1]=3 -> range [1..3] -> nodes 1,3,4 ✓
Implementation
class EulerTour:
def __init__(self, n, adj, root=0):
self.tin = [0] * n
self.tout = [0] * n
self.timer = 0
self.parent = [-1] * n
self._dfs(root, -1, adj)
def _dfs(self, node, par, adj):
self.parent[node] = par
self.tin[node] = self.timer
self.timer += 1
for child in adj[node]:
if child != par:
self._dfs(child, node, adj)
self.tout[node] = self.timer - 1
def is_ancestor(self, u, v):
"""O(1) ancestor check."""
return self.tin[u] <= self.tin[v] <= self.tout[u]
def subtree_range(self, node):
"""Range in flat array covering node's subtree."""
return self.tin[node], self.tout[node]
Combine with Segment Tree
# Subtree sum query
seg.query(tin[node], tout[node])
# Update node value
seg.update(tin[node], new_value)
# Update all nodes in subtree (lazy segment tree)
seg.range_update(tin[node], tout[node], delta)
What Euler Tour Can Do
| Operation | How | Complexity |
|---|---|---|
| Subtree sum/min/max | Segment tree on [tin, tout] | O(log N) |
| Subtree update | Lazy segment tree | O(log N) |
| Is u ancestor of v? | tin[u] <= tin[v] <= tout[u] |
O(1) |
| Subtree size | tout[u] - tin[u] + 1 |
O(1) |
Not on this list: LCA. The O(1)-LCA-by-RMQ trick needs the other Euler tour — the
2N-1-entry walk that re-records a node every time you backtrack into it — because LCA is the minimum-depth entry between two visits. Thetin/touttour above stores each node once, so that range simply does not contain the ancestor you need. See LCA §Euler Tour + RMQ for the variant that does support it.
3. Heavy-Light Decomposition (Path Queries)
Problem type: “Query/update all nodes on the path from X to Y”
The Idea
Decompose the tree into heavy chains (paths following the heaviest child). Each chain maps to a contiguous range in a flat array. Path queries break into O(log N) chain segments.
Tree: 0
/ \
heavy/ \light
1 2
heavy/ \light
3 4
heavy/
5
Chain A: 0->1->3->5 (contiguous in array)
Chain B: 4
Chain C: 2
Implementation
class HLD:
def __init__(self, n, adj, root=0):
self.n = n
self.adj = adj
self.parent = [-1] * n
self.depth = [0] * n
self.subtree_size = [1] * n
self.heavy_child = [-1] * n
self.chain_head = [0] * n
self.pos = [0] * n
self.timer = 0
self._dfs_size(root, -1)
self.chain_head[root] = root
self._dfs_hld(root)
def _dfs_size(self, node, par):
self.parent[node] = par
self.depth[node] = 0 if par == -1 else self.depth[par] + 1
self.subtree_size[node] = 1
max_size = 0
for nb in self.adj[node]:
if nb == par:
continue
self._dfs_size(nb, node)
self.subtree_size[node] += self.subtree_size[nb]
if self.subtree_size[nb] > max_size:
max_size = self.subtree_size[nb]
self.heavy_child[node] = nb
def _dfs_hld(self, node):
self.pos[node] = self.timer
self.timer += 1
hc = self.heavy_child[node]
if hc != -1:
self.chain_head[hc] = self.chain_head[node]
self._dfs_hld(hc)
for nb in self.adj[node]:
if nb == self.parent[node] or nb == hc:
continue
self.chain_head[nb] = nb
self._dfs_hld(nb)
def query_path(self, u, v, seg):
"""Sum on path u->v using a segment tree."""
result = 0
while self.chain_head[u] != self.chain_head[v]:
if self.depth[self.chain_head[u]] < self.depth[self.chain_head[v]]:
u, v = v, u
result += seg.query(self.pos[self.chain_head[u]], self.pos[u])
u = self.parent[self.chain_head[u]]
if self.depth[u] > self.depth[v]:
u, v = v, u
result += seg.query(self.pos[u], self.pos[v])
return result
def lca(self, u, v):
"""LCA as a bonus from HLD."""
while self.chain_head[u] != self.chain_head[v]:
if self.depth[self.chain_head[u]] < self.depth[self.chain_head[v]]:
u, v = v, u
u = self.parent[self.chain_head[u]]
return u if self.depth[u] <= self.depth[v] else v
What HLD Can Do
| Operation | Complexity |
|---|---|
| Path sum/min/max query | O(log^2 N) |
| Path update | O(log^2 N) |
| LCA | O(log N) |
| Subtree query (bonus!) | O(log N) via pos[node]..pos[node]+size-1 |
Euler Tour vs HLD
| Euler Tour | HLD | |
|---|---|---|
| Subtree queries | Yes (native) | Yes (bonus) |
| Path queries | No | Yes (native) |
| Build complexity | O(N) | O(N) |
| Query complexity | O(log N) | O(log^2 N) |
Rule of thumb: Need only subtree queries? Euler Tour is simpler. Need path queries (or both)? Use HLD.
4. Lowest Common Ancestor
Problem type: “Find the deepest common ancestor of two nodes”
See the detailed LCA Pattern Guide for full coverage. Summary:
Binary Lifting — owned by the LCA guide
The full binary-lifting class, the Euler-tour + sparse-table O(1)-query build, Tarjan’s offline algorithm, and a numeric trace of every one of them live in LCA Patterns — that guide is canonical for ancestor queries. Duplicating the table here would just give you two versions to keep in sync.
What you need to carry back into this guide: binary lifting stores up[k][v] = the 2^k-th
ancestor of v, built in O(N log N), and answers lca(u, v) in O(log N) by first equalising the
two depths, then lifting both nodes together from the high bit down, only while their ancestors
differ. dist(u, v) = depth[u] + depth[v] - 2 * depth[lca(u, v)] — that identity is what makes
LCA the workhorse behind path queries on trees.
Technique Comparison
| Method | Preprocess | Query | Best for |
|---|---|---|---|
| Binary Lifting | O(N log N) | O(log N) | General purpose, simple |
| Euler Tour + Sparse Table | O(N log N) | O(1) | Many queries |
| HLD | O(N) | O(log N) | Already using HLD |
| Tarjan’s (offline) | O(N + Q) | Amortized O(1) | All queries known upfront |
Walkthrough — LC 236 Lowest Common Ancestor of a Binary Tree
This template solves: LC 236 (LCA Binary Tree), LC 235 (LCA of a BST — use the order property to pick a side), LC 1650 (LCA with parent pointers).
Given a binary tree and two nodes
pandqpresent in it, return their lowest common ancestor — the deepest node that has bothpandqin its subtree.
Binary Lifting (above) is the heavy machinery for many LCA queries on a static tree. For a single LCA query on a LeetCode binary tree, there’s a beautifully short recursion that needs no preprocessing: search for p and q; the first node that finds one target on its left and the other on its right is the split point — the LCA.
def lowestCommonAncestor(root, p, q):
if root is None or root is p or root is q:
return root # found a target (or dead end)
left = lowestCommonAncestor(root.left, p, q)
right = lowestCommonAncestor(root.right, p, q)
if left and right:
return root # p and q split here → this is the LCA
return left or right # both on one side → bubble that side up
tree: 3
/ \
5 1
/ \ / \
6 2 0 8
/ \
7 4
LCA(5, 1): 5 is in 3's left subtree, 1 in 3's right
dfs(3): left=dfs(5)=5 (hits root is p), right=dfs(1)=1 (hits root is q)
both non-null → return 3 ✓
LCA(5, 4): 4 lives inside 5's own subtree
dfs(3): left=dfs(5) → dfs(5) hits `root is p` FIRST → returns 5 immediately
right=dfs(1)=None
left=5, right=None → return "left or right" = 5 ✓
The trace shows the two cases: when the targets split (5 and 1 sit in different subtrees of 3), the splitting node is the answer; when one target is an ancestor of the other (5 is above 4), the early root is p return makes the ancestor bubble up as the answer.
★ Insight ─────────────────────────────────────
- The recursion returns “what did I find below me,” and the LCA is where the two finds meet. A node returns non-null if either target is in its subtree. The unique node receiving a non-null result from both children is the lowest ancestor of both — everything above it also sees both, but only through one child, so it correctly forwards that side instead of claiming to be the LCA.
- The
root is p or root is qearly return quietly handles the ancestor case. Ifpis an ancestor ofq, the DFS returnspthe moment it touches it and never descends to findq— which is correct, becausepis the LCA. That is why the problem can promise “both nodes exist” and skip the messy “what if only one is present” bookkeeping.─────────────────────────────────────────────────
5. Rerooting DP
Problem type: “Compute some value for every node as if it were the root”
Who owns what. This section owns the generic two-pass template and the farthest-node flavour (two-longest-downward), which is the one rerooting shape that does not reduce to counting edges. The problem catalog — LC 834, 979, 2049, 2858, and grouped same-colour distances — plus the per-edge counting argument that explains why the slide formula is valid, belongs to Edge Contribution & Rerooting.
The Problem
Normal tree DP computes the answer rooted at one node. Rerooting computes the answer for all N roots in O(N) total (not O(N^2)).
"For each node, what is the farthest node from it?"
"For each node, what is the sum of distances to all other nodes?"
Naive: run tree DP N times -> O(N^2)
Rerooting: 2 DFS passes -> O(N)
The Idea
Phase 1 (DFS down): Compute dp_down[node] = answer from node's subtree
Phase 2 (DFS up): Compute dp_up[node] = answer from everything OUTSIDE node's subtree
Final: answer[node] = combine(dp_down[node], dp_up[node])
0 dp_down[2]: info from subtree of 2
/ \ dp_up[2]: info from 0's other children + above 0
1 2
/ \ \
3 4 5
answer[2] = combine(dp_down[2], dp_up[2])
= answer as if 2 were the root
Example: Farthest Node from Each Node (Tree Distances I)
def farthest_from_each(n, adj):
# dp_down[node] = [longest_path, second_longest_path] going downward
dp_down = [[0, 0] for _ in range(n)]
dp_down_child = [[-1, -1] for _ in range(n)] # which child gave longest
dp_up = [0] * n
def dfs_down(node, par):
for child in adj[node]:
if child == par:
continue
dfs_down(child, node)
d = dp_down[child][0] + 1
if d > dp_down[node][0]:
dp_down[node][1] = dp_down[node][0]
dp_down_child[node][1] = dp_down_child[node][0]
dp_down[node][0] = d
dp_down_child[node][0] = child
elif d > dp_down[node][1]:
dp_down[node][1] = d
dp_down_child[node][1] = child
def dfs_up(node, par):
for child in adj[node]:
if child == par:
continue
# if child gave the longest path, use second longest
if dp_down_child[node][0] == child:
best_down = dp_down[node][1]
else:
best_down = dp_down[node][0]
dp_up[child] = 1 + max(dp_up[node], best_down)
dfs_up(child, node)
dfs_down(0, -1)
dfs_up(0, -1)
return [max(dp_down[i][0], dp_up[i]) for i in range(n)]
Rerooting Template
1. Define what dp_down[node] computes (info from subtree)
2. Define what dp_up[node] computes (info from outside subtree)
3. DFS 1 (post-order): compute dp_down for all nodes
4. DFS 2 (pre-order): compute dp_up using parent's dp_down + dp_up
KEY: when computing dp_up[child], remove child's contribution from
parent's dp_down (to avoid double-counting)
5. answer[node] = combine(dp_down[node], dp_up[node])
Walkthrough — LC 834 Sum of Distances in Tree
This template solves: LC 834 (Sum of Distances), LC 2477 (Minimum Fuel — subtree-size rerooting), CSES “Tree Distances I” (one root) and “Tree Distances II” (all roots).
A caution on diameter problems: LC 543 needs only the post-order half of this template, not the
reroot pass. A single bottom-up DFS that returns each node’s height while updating a global
best = max(best, left + right) is the whole solution — reaching for rerooting there is overkill.
Rerooting earns its keep only when you need a per-node answer, e.g. CSES “Tree Distances I”
(farthest node from every node). For the rest of the family — LC 979 (Distribute Coins), 2049 (Highest Score), 2858 (Minimum Edge Reversals), grouped same-colour distances — see Edge Contribution & Rerooting.
There is an undirected tree of
nnodes. Return an arrayanswhereans[i]is the sum of distances from nodeito every other node.
Computing one node’s answer is an easy O(n) DFS. Doing it for all n roots naively is O(n²). Rerooting collapses it to O(n) with the classic two-pass structure: one post-order pass to solve the root, one pre-order pass to slide the answer from a parent to each child.
The slide is the whole trick. When you move the root from node to its child c:
- every node inside
c’s subtree gets 1 closer → subtractsize[c], - every node outside
c’s subtree gets 1 farther → add(n − size[c]).
def sumOfDistancesInTree(n, edges):
adj = [[] for _ in range(n)]
for a, b in edges:
adj[a].append(b); adj[b].append(a)
size = [1] * n
ans = [0] * n
def dfs1(node, par): # post-order: solve root 0
for c in adj[node]:
if c != par:
dfs1(c, node)
size[node] += size[c]
ans[node] += ans[c] + size[c] # child's cost + 1 per subtree node
def dfs2(node, par): # pre-order: slide to each child
for c in adj[node]:
if c != par:
ans[c] = ans[node] - size[c] + (n - size[c])
dfs2(c, node)
dfs1(0, -1)
dfs2(0, -1)
return ans
n = 6, edges = [[0,1],[0,2],[2,3],[2,4],[2,5]]
0
/ \
1 2
/|\
3 4 5
dfs1 (post-order from 0):
size = [6,1,4,1,1,1]
ans[0] = 8 (0→1 is 1; 0→2 is 1; 0→3,4,5 are 2 each = 1+1+2+2+2 = 8)
dfs2 (slide 0 → each child):
ans[1] = ans[0] - size[1] + (6-size[1]) = 8 - 1 + 5 = 12
ans[2] = ans[0] - size[2] + (6-size[2]) = 8 - 4 + 2 = 6
then from 2 → 3,4,5:
ans[3] = ans[2] - 1 + 5 = 6 - 1 + 5 = 10 (same for 4, 5)
result = [8, 12, 6, 10, 10, 10] ✓
★ Insight ─────────────────────────────────────
- Rerooting = “reuse the neighbor’s answer instead of recomputing.” The pre-order slide
ans[c] = ans[node] − size[c] + (n − size[c])is an O(1) edit of the parent’s already-known answer. This is the same overlap-reuse idea as a sliding window, lifted onto a tree: don’t rescan, adjust. - The two passes carry opposite information.
dfs1(post-order) gathers bottom-up facts (subtree size, subtree cost).dfs2(pre-order) pushes top-down the correction for “everything outside my subtree.” Any rerooting problem is: decide the bottom-up aggregate, then decide the O(1) formula that converts a parent’s answer into a child’s. If that conversion isn’t O(1), rerooting won’t beat the naive O(n²).─────────────────────────────────────────────────
6. Tree Diameter and Center
Diameter: Longest Path in Tree
Method 1: Two BFS/DFS (simplest)
from collections import deque
def tree_diameter(n, adj):
def bfs_farthest(start):
dist = [-1] * n
dist[start] = 0
queue = deque([start])
farthest = start
while queue:
node = queue.popleft()
for nb in adj[node]:
if dist[nb] == -1:
dist[nb] = dist[node] + 1
queue.append(nb)
if dist[nb] > dist[farthest]:
farthest = nb
return farthest, dist
# Step 1: BFS from any node to find one endpoint
u, _ = bfs_farthest(0)
# Step 2: BFS from u to find the other endpoint + diameter
v, dist = bfs_farthest(u)
return dist[v] # diameter
Why does this work? Starting from any node, the farthest node must be an endpoint of some diameter. From that endpoint, the farthest node gives the other endpoint.
Method 2: Tree DP (also finds the node where diameter passes through)
def diameter_dp(n, adj):
diameter = [0]
def dfs(node, par):
first = second = 0 # two longest downward paths
for child in adj[node]:
if child == par:
continue
d = dfs(child, node) + 1
if d > first:
second = first
first = d
elif d > second:
second = d
diameter[0] = max(diameter[0], first + second)
return first
dfs(0, -1)
return diameter[0]
Walkthrough — LC 543 Diameter of Binary Tree
This template solves: LC 543 (Diameter), LC 124 (Max Path Sum — same shape, sums instead of edge counts, clamp negatives to 0), LC 687 (Longest Univalue Path), LC 1245 (Tree Diameter general).
Given the
rootof a binary tree, return the length of its diameter: the number of edges on the longest path between any two nodes (the path need not pass through the root).
The insight that makes this O(n): the longest path bending at a node v is height(v.left) + height(v.right) (in edges). So run a height DFS, and at every node update a global best with “left height + right height.” The recursion still returns a height (so the parent can keep climbing), but records the diameter on the side.
def diameterOfBinaryTree(root):
best = [0]
def height(node):
if not node:
return 0
L = height(node.left)
R = height(node.right)
best[0] = max(best[0], L + R) # longest path bending HERE (edges)
return 1 + max(L, R) # height handed to the parent
height(root)
return best[0]
tree: 1
/ \
2 3
/ \
4 5
height(4)=1, height(5)=1
node 2: L=1,R=1 → best=max(0, 1+1)=2 ; returns 1+max(1,1)=2
node 3: leaf → returns 1 ; best unchanged (0+0=0)
node 1: L=height(2)=2, R=height(3)=1 → best=max(2, 2+1)=3 ; returns 3
diameter = 3 (path 4-2-1-3, three edges) ✓
★ Insight ─────────────────────────────────────
- Return one thing, record another. The parent needs the single longest downward reach (
1 + max(L,R)) to extend its own path — it can only use one side going up. But the answer at a node uses both sides (L + R), because a path can turn around atvand go down both branches. ReturningL+Rwould be wrong (a path can’t fork); recording only the return value would miss bent paths. Splitting the two is the crux. - LC 124 Max Path Sum is this exact template with two tweaks: carry node values instead of
+1edge counts, and clamp a negative branch to0(max(0, childGain)) because you can always choose to not extend into a branch that only hurts you. Recognizing that 124-Hard is 543-Easy plus “clamp negatives” is a big transfer win.─────────────────────────────────────────────────
Center of Tree
The center is the node(s) that minimize the maximum distance to any other node. It’s always 1 or 2 nodes, located at the middle of the diameter.
def tree_center(n, adj):
"""Peel leaves layer by layer until 1-2 nodes remain."""
if n <= 2:
return list(range(n))
degree = [len(adj[i]) for i in range(n)]
leaves = deque(i for i in range(n) if degree[i] <= 1)
remaining = n
while remaining > 2:
new_leaves = deque()
for leaf in leaves:
remaining -= 1
for nb in adj[leaf]:
degree[nb] -= 1
if degree[nb] == 1:
new_leaves.append(nb)
leaves = new_leaves
return list(leaves)
This is essentially topological peeling: remove all leaves, then remove the new leaves, repeat until 1-2 nodes remain.
7. Centroid Decomposition
Problem type: “For each node, count/query something based on distances to other nodes”
The Idea
The centroid of a tree is the node whose removal splits the tree into subtrees each of size <= N/2.
Centroid decomposition recursively finds centroids to build a centroid tree of depth O(log N). Every path in the original tree passes through the LCA in the centroid tree.
Original: Centroid Tree (depth O(log N)):
1 3
/ \ / | \
2 3 1 5 6
/ \ / \
4 5 2 4
/
6
Centroid of full tree = 3
Centroid of {1,2} = 1 (or 2)
Centroid of {4,5,6} = 5 (or 4)
Finding the Centroid
def find_centroid(node, par, adj, subtree_size, tree_size):
for child in adj[node]:
if child != par and subtree_size[child] > tree_size // 2:
# this subtree is too big, centroid lies inside it
return find_centroid(child, node, adj, subtree_size, tree_size)
return node
More robust version:
def centroid_decomposition(n, adj):
subtree_size = [0] * n
removed = [False] * n
centroid_parent = [-1] * n
def get_size(node, par):
subtree_size[node] = 1
for child in adj[node]:
if child != par and not removed[child]:
get_size(child, node)
subtree_size[node] += subtree_size[child]
return subtree_size[node]
def get_centroid(node, par, tree_size):
for child in adj[node]:
if child != par and not removed[child]:
if subtree_size[child] > tree_size // 2:
return get_centroid(child, node, tree_size)
return node
def build(node, par):
size = get_size(node, -1)
centroid = get_centroid(node, -1, size)
removed[centroid] = True
centroid_parent[centroid] = par
for child in adj[centroid]:
if not removed[child]:
build(child, centroid)
build(0, -1)
return centroid_parent
Example: Count Pairs with Distance = K
def count_pairs_distance_k(n, adj, k):
"""Count pairs (u,v) where dist(u,v) = k."""
removed = [False] * n
subtree_size = [0] * n
total = 0
def get_size(node, par):
subtree_size[node] = 1
for child in adj[node]:
if child != par and not removed[child]:
get_size(child, node)
subtree_size[node] += subtree_size[child]
def get_centroid(node, par, tree_size):
for child in adj[node]:
if child != par and not removed[child]:
if subtree_size[child] > tree_size // 2:
return get_centroid(child, node, tree_size)
return node
def get_depths(node, par, depth, depths):
depths.append(depth)
for child in adj[node]:
if child != par and not removed[child]:
get_depths(child, node, depth + 1, depths)
def solve(node):
nonlocal total
get_size(node, -1)
centroid = get_centroid(node, -1, subtree_size[node])
removed[centroid] = True
# count paths passing through centroid
seen = {0: 1} # distances from centroid already counted
for child in adj[centroid]:
if removed[child]:
continue
depths = []
get_depths(child, centroid, 1, depths)
# count pairs: one from this subtree, one from previous subtrees
for d in depths:
need = k - d
if need >= 0 and need in seen:
total += seen[need]
# add this subtree's depths to seen
for d in depths:
seen[d] = seen.get(d, 0) + 1
for child in adj[centroid]:
if not removed[child]:
solve(child)
solve(0)
return total
Why Centroid Decomposition Works
Every path between two nodes passes through their LCA in the centroid tree. Since the centroid tree has depth O(log N), each node is involved in at most O(log N) centroid computations.
| Operation | Without CD | With CD |
|---|---|---|
| Count pairs at distance K | O(N^2) | O(N log N) |
| Closest marked node query | O(N) per query | O(log^2 N) per query |
| Update + distance queries | O(N) per query | O(log^2 N) per query |
8. Small-to-Large Merging (DSU on Tree)
Problem type: “For each node, answer a query about its subtree” — especially when maintaining a data structure (set, map, frequency array) per subtree.
The Idea
Naive approach: each node has its own set. After computing children, merge all child sets into the parent. This is O(N^2) worst case.
Small-to-large trick: Always merge the smaller set into the larger one. Each element is moved at most O(log N) times (because each move at least doubles the set size it joins).
1 (want: set of colors in subtree)
/ \
2 3
/| \
4 5 6
Without optimization: merge 4,5 into 2, then merge 2,3,6 into 1
If sizes are uneven, one merge could be O(N)
With small-to-large: always merge smaller into bigger
Each color moves at most O(log N) times across all merges
Example: Distinct Colors in Each Subtree
def distinct_colors(n, adj, colors):
"""For each node, count distinct colors in its subtree."""
answer = [0] * n
# Each node owns a set. We'll pass sets up the tree.
node_set = [None] * n
def dfs(node, par):
node_set[node] = {colors[node]}
for child in adj[node]:
if child == par:
continue
dfs(child, node)
# small-to-large merge
if len(node_set[child]) > len(node_set[node]):
node_set[node], node_set[child] = node_set[child], node_set[node]
# merge smaller into larger
node_set[node].update(node_set[child])
node_set[child] = None # free memory
answer[node] = len(node_set[node])
dfs(0, -1)
return answer
Euler Tour + Offline Approach (Alternative)
For “distinct values in subtree” problems, you can also:
- Flatten tree with Euler Tour
- Process subtree ranges
[tin[v], tout[v]]using offline techniques (Mo’s algorithm)
Complexity
| Approach | Time | Space |
|---|---|---|
| Naive merge | O(N^2) | O(N) |
| Small-to-large | O(N log N) | O(N) |
| Euler Tour + Mo’s | O(N sqrt(N)) | O(N) |
Walkthrough — LC 1519 Number of Nodes in the Sub-Tree With the Same Label
This template solves: LC 1519 (same-label count), LC 508 (subtree sum frequencies), and any “for each node, some statistic over its subtree” problem — the natural home of small-to-large when the statistic is a whole map.
A tree of
nnodes rooted at0; each node has a lowercase-letter label. For every nodei, return how many nodes ini’s subtree (includingi) sharei’s label.
Each node needs a frequency map of the labels in its subtree, then reads off its own label’s count. The clean version merges each child’s map into the parent’s; the fast version applies small-to-large (always merge the smaller map into the larger) to hit O(N log N). Here is the direct merge — correct, and O(N·26) because there are only 26 labels:
from collections import defaultdict
def countSubTrees(n, edges, labels):
adj = [[] for _ in range(n)]
for a, b in edges:
adj[a].append(b); adj[b].append(a)
ans = [0] * n
def dfs(node, par):
freq = defaultdict(int)
freq[labels[node]] += 1
for c in adj[node]:
if c != par:
child_freq = dfs(c, node)
for k, v in child_freq.items(): # merge child's map up
freq[k] += v
ans[node] = freq[labels[node]] # my label's count in my subtree
return freq
dfs(0, -1)
return ans
n=7 edges=[[0,1],[0,2],[1,4],[1,5],[2,3],[2,6]] labels = "abaedcd"
0(a)
/ \
1(b) 2(a)
/ \ / \
4(e)5(d)3(e)6(d)
leaves 4,5,3,6 → each freq {own:1}, ans=1
node 1(b): merge {e},{d} + self b → {b:1,e:1,d:1}; ans[1]=freq['b']=1
node 2(a): merge {e},{d} + self a → {a:1,e:1,d:1}; ans[2]=freq['a']=1
node 0(a): merge child1 + child2 + self a
→ {a:2, b:1, e:2, d:2}; ans[0]=freq['a']=2 (nodes 0 and 2)
result = [2,1,1,1,1,1,1] ✓
★ Insight ─────────────────────────────────────
- The unit each node returns here is a whole data structure (a map), not a scalar. That’s the leap from §9 aggregation (return a number) to §8 small-to-large (return a set/map). The correctness is identical to any post-order merge; the only performance worry is how expensively maps combine — which is exactly what small-to-large fixes by never copying the bigger side.
- Small-to-large’s O(N log N) comes from a counting argument, not a cleverer merge. Every element moves only when it lands in a map at least twice its old size, so it moves at most log N times over the whole tree. When labels are bounded (26 letters), a plain merge is already O(26N) and small-to-large is overkill — reach for it when the per-subtree structure can be large (arbitrary colors, values, coordinates).
─────────────────────────────────────────────────
9. Tree DP Patterns
A collection of the most common tree DP patterns.
Who owns what. Dynamic Programming §10 is canonical for generic tree DP — the select/skip recurrence, LC 337 in full, matching, and colouring — because that is where it belongs among the other DP families. This section keeps the same patterns in tree vocabulary and connects them to the machinery that is unique to trees: subtree aggregation feeding Euler-tour flattening, rerooting (§5), and virtual trees (§10). If the two ever disagree, DP §10 wins.
Pattern 1: Subtree Aggregation
dp[node] = f(dp[children])
Direction: post-order (bottom up)
Example: Subtree Sum
def subtree_sum(node, par, adj, val):
total = val[node]
for child in adj[node]:
if child != par:
total += subtree_sum(child, node, adj, val)
return total
Pattern 2: Select/Skip (Independent Set)
dp[node][0] = best when node is NOT selected
dp[node][1] = best when node IS selected
dp[node][0] = sum(max(dp[child][0], dp[child][1]))
dp[node][1] = val[node] + sum(dp[child][0])
def max_independent_set(n, adj, val, root=0):
dp = [[0, 0] for _ in range(n)]
def dfs(node, par):
dp[node][1] = val[node]
for child in adj[node]:
if child == par:
continue
dfs(child, node)
dp[node][0] += max(dp[child][0], dp[child][1])
dp[node][1] += dp[child][0]
dfs(root, -1)
return max(dp[root])
Walkthrough — LC 337 House Robber III
This template solves: LC 337 (House Robber III), LC 968 (Binary Tree Cameras — 3-state select/skip), LC 1519 (subtree aggregation cousin), and CSES “Tree Matching” / max-independent-set on trees.
The houses form a binary tree. The root is the entrance; two directly-linked houses cannot both be robbed on the same night. Given the tree, return the maximum money you can rob.
This is the linear House Robber (LC 198) tree-ified: “can’t rob adjacent” becomes “can’t rob a node and its child.” The select/skip state carries up as a pair per node — (rob this node, skip this node) — so the parent can decide with full information.
def rob(root):
def dfs(node):
if not node:
return (0, 0) # (rob_here, skip_here)
rl, sl = dfs(node.left)
rr, sr = dfs(node.right)
rob_here = node.val + sl + sr # rob node → children MUST be skipped
skip_here = max(rl, sl) + max(rr, sr) # skip node → children free to choose
return (rob_here, skip_here)
return max(dfs(root))
tree: 3
/ \
2 3
\ \
3 1
dfs(leaf 3 under 2): (3, 0)
dfs(leaf 1 under right-3): (1, 0)
node 2: rob = 2 + skip(child) = 2 + 0 = 2 ; skip = max(3,0) = 3 → (2, 3)
node right-3: rob = 3 + 0 = 3 ; skip = max(1,0) = 1 → (3, 1)
root 3: rob = 3 + skip(2) + skip(right3) = 3 + 3 + 1 = 7
skip = max(2,3) + max(3,1) = 3 + 3 = 6
→ (7, 6)
answer = max(7, 6) = 7 ✓ (rob root + the two grandchildren 3 and 1)
★ Insight ─────────────────────────────────────
- Return a pair, not a number. If
dfsreturned only “best for this subtree,” the parent couldn’t tell whether that best used the child — and it needs to know, because robbing the parent forbids robbing the child. Carrying both(rob, skip)up is what lets the parent choose correctly in O(1). Whenever a decision at a node constrains its children, the return type grows from a scalar to a tuple of “one entry per state.” skip_heretakesmax(rob_child, skip_child);rob_hereis forced toskip_child. That asymmetry is the adjacency rule. It’s the identical recurrence as §9 Pattern 2 Maximum Independent Set — LC 337 is literally weighted MIS on a tree, which is why a linear-DP problem and a graph-theory problem share one template.─────────────────────────────────────────────────
Pattern 3: Matching on Tree
dp[node][0] = max matching in subtree, node is NOT matched
dp[node][1] = max matching in subtree, node IS matched (to one child)
dp[node][0] = sum(max(dp[child][0], dp[child][1]))
dp[node][1] = max over children c of:
(dp[c][0] + 1) + sum(max(dp[other][0], dp[other][1]))
def max_matching(n, adj, root=0):
dp = [[0, 0] for _ in range(n)]
def dfs(node, par):
sum_unmatched = 0
for child in adj[node]:
if child == par:
continue
dfs(child, node)
sum_unmatched += max(dp[child][0], dp[child][1])
dp[node][0] = sum_unmatched
# try matching node with each child
for child in adj[node]:
if child == par:
continue
gain = dp[child][0] + 1 - max(dp[child][0], dp[child][1])
dp[node][1] = max(dp[node][1], sum_unmatched + gain)
dfs(root, -1)
return max(dp[root])
Pattern 4: Coloring / Partition
dp[node][color] = ways to color subtree with node = color
dp[node][c] = product(sum(dp[child][c'] for valid c') for each child)
Pattern 5: Path Through Node
Track the two longest paths through each node to find diameter or similar.
def paths_through_node(n, adj, root=0):
"""For each node, find the longest path passing through it."""
best = [0] * n
def dfs(node, par):
first = second = 0
for child in adj[node]:
if child == par:
continue
d = dfs(child, node) + 1
if d > first:
first, second = d, first
elif d > second:
second = d
best[node] = first + second # longest path through this node
return first # longest downward path
dfs(root, -1)
return best
Tree DP Summary
| Pattern | States | Common Problems |
|---|---|---|
| Subtree aggregation | dp[node] | Subtree sum, size, count |
| Select/Skip | dp[node][0/1] | Independent set, house robber on tree |
| Matching | dp[node][0/1] | Maximum matching |
| Coloring | dp[node][color] | Chromatic polynomial, valid colorings |
| Path through node | first/second longest | Diameter, longest path |
| Rerooting | dp_down + dp_up | Answer for every root |
10. Virtual Tree (Auxiliary Tree)
Problem type: “Given Q queries, each involving a small subset of K nodes, answer something about the tree structure connecting them”
The Idea
Instead of working on the full N-node tree, build a virtual tree containing only:
- The K query nodes
- Their pairwise LCAs
This reduces the tree to at most 2K - 1 nodes.
Full tree (10 nodes): Virtual tree for query nodes {3, 9, 10}:
1 1
/ \ / \
2 3 3 5
/ \ / \
4 5 9 10
/ / \
6 7 8
/ \
9 10
Only 5 nodes: {1, 3, 5, 9, 10} = {3, 9, 10} query nodes + {1, 5} LCAs
Why exactly those two extra nodes: LCA(9, 10) = 5 (their paths join at 5), and LCA(3, 9) =
LCA(3, 10) = 1. Nodes 2, 7, and 8 are on the connecting paths but are not LCAs of any query
pair, so they get compressed away — edge 1 → 5 stands in for the path 1 → 2 → 5, and 5 → 9
stands in for 5 → 7 → 9. Every query node must survive into the virtual tree; if one goes
missing, you have built the wrong node set.
Building a Virtual Tree
def build_virtual_tree(query_nodes, tin, lca_func):
"""
query_nodes: list of node IDs
tin: euler tour enter times
lca_func: function that computes LCA of two nodes
Returns: adjacency list of virtual tree
"""
# Step 1: sort by tin
nodes = sorted(query_nodes, key=lambda x: tin[x])
# Step 2: add LCAs of consecutive pairs
all_nodes = list(nodes)
for i in range(len(nodes) - 1):
l = lca_func(nodes[i], nodes[i + 1])
all_nodes.append(l)
# Step 3: deduplicate and sort by tin
all_nodes = sorted(set(all_nodes), key=lambda x: tin[x])
# Step 4: add LCAs from stack-based construction
stack = [all_nodes[0]]
vtree = {node: [] for node in all_nodes}
for i in range(1, len(all_nodes)):
node = all_nodes[i]
l = lca_func(node, stack[-1])
if l != stack[-1]:
while len(stack) > 1 and tin[stack[-2]] >= tin[l]:
vtree[stack[-2]].append(stack[-1])
stack.pop()
if stack[-1] != l:
vtree[l].append(stack[-1])
stack[-1] = l
stack.append(node)
while len(stack) > 1:
vtree[stack[-2]].append(stack[-1])
stack.pop()
return vtree, stack[0] # virtual tree adj + root
When to Use
- K « N (small query set, large tree)
- Multiple queries, each on a different subset
- Need to run tree DP only on relevant nodes
11. Prufer Sequence
A way to encode a labeled tree of N nodes as a sequence of N-2 numbers.
Tree to Prufer Sequence
Tree (0-indexed, to match the code below):
0 - 2 - 1 - 3
|
4
Repeatedly remove the leaf with the smallest label, record its neighbor:
Remove 0 (leaf), neighbor = 2 -> sequence: [2]
Remove 2 (now leaf), neighbor = 1 -> sequence: [2, 1]
Remove 3 (leaf), neighbor = 1 -> sequence: [2, 1, 1]
Remaining: {1, 4} -> stop (N-2 = 3 elements)
Prufer sequence: [2, 1, 1]
def tree_to_prufer(n, adj):
degree = [len(adj[i]) for i in range(n)]
sequence = []
leaf = -1
# find smallest leaf
for i in range(n):
if degree[i] == 1:
leaf = i
break
ptr = leaf
for _ in range(n - 2):
# find neighbor of current leaf
for nb in adj[ptr]:
if degree[nb] > 0:
neighbor = nb
break
sequence.append(neighbor)
degree[ptr] = 0
degree[neighbor] -= 1
if degree[neighbor] == 1 and neighbor < ptr:
ptr = neighbor # optimization: this neighbor is now a leaf
else:
ptr += 1
while ptr < n and degree[ptr] != 1:
ptr += 1
return sequence
Prufer Sequence to Tree
def prufer_to_tree(sequence):
n = len(sequence) + 2
degree = [1] * n
for x in sequence:
degree[x] += 1
edges = []
ptr = 0
while degree[ptr] != 1:
ptr += 1
leaf = ptr
for x in sequence:
edges.append((leaf, x))
degree[leaf] -= 1
degree[x] -= 1
if degree[x] == 1 and x < ptr:
leaf = x
else:
ptr += 1
while ptr < n and degree[ptr] != 1:
ptr += 1
leaf = ptr
edges.append((leaf, n - 1))
return edges
Key Properties
| Property | Value |
|---|---|
| Number of labeled trees on N nodes | N^(N-2) (Cayley’s formula) |
| Node i appears in Prufer sequence | degree(i) - 1 times |
| Leaves of the tree | Nodes NOT in the sequence |
| Bijection | Every sequence of N-2 values in [0, N-1] gives a unique tree |
12. Tree Hashing and Isomorphism
Problem type: “Are these two trees structurally identical?” or “Find all subtrees with the same structure”
The Idea
Assign each subtree a hash based on its children’s hashes (sorted). Two subtrees are isomorphic if and only if they have the same hash.
1 5
/ \ / \
2 3 6 7
/ /
4 8
Subtree at 2: hash of (hash(4)) = H_chain2
Subtree at 6: hash of (hash(8)) = H_chain2 (same structure!)
Implementation
def tree_hash(n, adj, root=0):
"""Compute a canonical hash for each subtree.
The hash is a *structural* string built from the sorted child hashes,
so identical shapes map to identical strings across DIFFERENT trees
(no per-call id counter — that would make two trees incomparable).
"""
node_hash = [""] * n
def dfs(node, par):
child_hashes = []
for child in adj[node]:
if child == par:
continue
dfs(child, node)
child_hashes.append(node_hash[child])
child_hashes.sort() # canonical ordering: child order must not matter
# A leaf becomes "()"; internal nodes wrap their sorted children.
node_hash[node] = "(" + "".join(child_hashes) + ")"
dfs(root, -1)
return node_hash
Why a string, not an integer id? A canonical hash must give the same symbol to the same shape across every tree you compare. Interning shapes to integers with a counter that restarts each call breaks that — id
3in one tree and id3in another are unrelated shapes. The nested-parenthesis string is self-contained and comparable anywhere. For large trees, replace the raw string with a rolling hash of it (or intern strings into a shared dict) to keep comparisons O(1).
Rooted vs Unrooted Isomorphism
Rooted: Hash from the given root. Two rooted trees are isomorphic if root hashes match.
Unrooted: Root both trees at their center (the center is unique up to 2 nodes). If 2 centers, try both. Compare hashes.
def are_isomorphic(adj1, adj2):
n1, n2 = len(adj1), len(adj2)
if n1 != n2:
return False
centers1 = tree_center(n1, adj1)
centers2 = tree_center(n2, adj2)
for c1 in centers1:
h1 = tree_hash(n1, adj1, c1)
for c2 in centers2:
h2 = tree_hash(n2, adj2, c2)
if h1[c1] == h2[c2]:
return True
return False
13. Pattern Recognition Cheat Sheet
By Query Type
| Query | Technique | Complexity |
|---|---|---|
| Subtree sum/min/max | Euler Tour + Segment Tree | O(log N) |
| Subtree update | Euler Tour + Lazy Segment Tree | O(log N) |
| Path sum/min/max | HLD + Segment Tree | O(log^2 N) |
| Path update | HLD + Lazy Segment Tree | O(log^2 N) |
| LCA query | Binary Lifting / Euler+RMQ | O(log N) / O(1) |
| Distance(u, v) | LCA + depth | O(log N) |
| Answer for all roots | Rerooting DP | O(N) total |
| Count pairs at distance K | Centroid Decomposition | O(N log N) |
| Distinct values in subtree | DSU on Tree / Mo’s | O(N log N) |
| Is u ancestor of v? | Euler Tour tin/tout | O(1) |
| Tree diameter | Two-BFS or DP | O(N) |
| Tree isomorphism | Tree hashing at center | O(N) |
By Problem Keywords
| You see… | Think… |
|---|---|
| “subtree query” | Euler Tour |
| “path query” | HLD |
| “distance between nodes” | LCA |
| “for every node as root” | Rerooting DP |
| “farthest node” | Tree Diameter + Rerooting |
| “count paths with property” | Centroid Decomposition |
| “distinct in subtree” | DSU on Tree |
| “maximum independent set” | Tree DP (select/skip) |
| “tree matching” | Tree DP (match/unmatch) |
| “same structure” | Tree Hashing |
| “labeled tree counting” | Cayley’s formula / Prufer |
| “small subset of nodes” | Virtual Tree |
Technique Compatibility
Multiple techniques are often combined:
Euler Tour + Segment Tree -> subtree queries with updates
HLD + Segment Tree -> path queries with updates
HLD + LCA -> path queries (LCA is free with HLD)
Centroid Decomp + BIT/Fenwick -> distance-based queries with updates
Binary Lifting + Path aggregation -> weighted path queries
Rerooting DP + Tree DP -> answer for all roots
Virtual Tree + Tree DP -> DP on selected subset efficiently
Decision Flowchart
What do you need to query?
|
+-- Subtree? --> Euler Tour + Segment Tree
|
+-- Path? --> HLD + Segment Tree
|
+-- LCA? --> Binary Lifting (simple) or Euler+Sparse (O(1))
|
+-- Every root? --> Rerooting DP
|
+-- Distance-based counting? --> Centroid Decomposition
|
+-- Merging child data structures? --> Small-to-Large
|
+-- Subtree DP value? --> Tree DP (select/skip or aggregate)
|
+-- Only K << N nodes matter? --> Virtual Tree
Common Pitfalls
| Pitfall | Fix |
|---|---|
| Python recursion-depth limit blows up on deep/chain-like trees (default ~1000) | sys.setrecursionlimit(300000) and raise the thread stack, or rewrite the DFS iteratively (see §1 Iterative DFS) |
| 1-based vs 0-based node labels mixed together | Pick one convention for the whole solution; if input is 1-based, subtract 1 on read (the code here is 0-based) |
Forgetting to skip the parent when iterating undirected adjacency |
Always guard if child == parent: continue — otherwise you recurse straight back and loop forever |
Using depth[par] + 1 when par == -1 (root) |
Special-case the root’s depth to 0 before the recursion touches par |
| Recomputing per-root DP from scratch (O(N^2)) | Use rerooting (§5): two passes give every root in O(N) |
Practice Order
Start here
│
▼
Traversal / height ──── DFS enter/leave; height = 1 + max(child heights)
│
▼
Subtree sizes (tree DP) ──── first real post-order aggregation: size[node]=1+Σ size[child]
│
▼
Tree diameter ──── track two longest downward paths per node (§6)
│
▼
LCA (binary lifting) ──── sparse table of 2^k ancestors; lift-then-together (§4)
│
▼
Path queries (HLD) ──── decompose into heavy chains, query each chain range (§3)
│
▼
Count pairs at dist k ──── centroid decomposition: count paths through each centroid (§7)
LeetCode Practice Ladder (the 6 walkthroughs)
If you want a concrete, click-and-solve path on LeetCode rather than the CP ladder above, do these in order — each teaches one return-value shape:
104 (Easy) ── height: return a number 1 + max(L, R)
│
▼
543 (Easy) ── return height, record diameter best = max(best, L + R)
│
▼
337 (Medium) ── return a PAIR (rob, skip) adjacency constraint on a tree
│
▼
236 (Medium) ── return "found below?" split point = LCA
│
▼
1519 (Medium) ── return a MAP, merge upward subtree statistic (small-to-large)
│
▼
834 (Hard) ── rerooting: slide parent→child ans[c] = ans[p] − size[c] + (n − size[c])
Then generalize: 124 (Max Path Sum) reuses 543; 968 (Cameras) extends 337 to 3 states; 235 (BST LCA) simplifies 236 using order.
The Tree Toolbox at a Glance
Every tree algorithm is post-order DFS with a different return value:
return a NUMBER → height (104), size, subtree sum, diameter-height (543)
return a PAIR → select/skip DP (337), matching, "balanced?" flag
return "found?" → LCA search (236)
return a MAP/SET → subtree statistics, small-to-large (1519)
add a 2nd pass → rerooting: bottom-up then top-down slide (834)
Then the heavy machinery, when a plain DFS is too slow:
many subtree queries + updates → Euler tour + segment tree (§2)
many path queries + updates → HLD + segment tree (§3)
many LCA / distance queries → binary lifting (§4)
distance-based path counting → centroid decomposition (§7)
only K ≪ N nodes matter per query → virtual tree (§10)
When This Fails
Most tree techniques rely on properties that vanish the moment the structure is not a tree:
- The graph has a cycle. Exactly one path between any two nodes is what makes subtree aggregation, LCA, and rerooting work. With cycles you are in general graph territory.
- The tree is a forest. LCA is undefined across components. Root each component separately and answer “same component?” first.
- Recursion depth. A DFS on a path-shaped tree of 10⁵ nodes overflows Python’s default limit
of 1000. Either
sys.setrecursionlimit(200_000)or write the traversal iteratively. - Rerooting where the parent-to-child update is not O(1). If converting a parent’s answer to a child’s requires re-aggregating, rerooting is no better than the naive O(n²).
- The two-BFS diameter trick on a non-tree or on negative weights. It is valid for trees with non-negative edges only.
Self-Test
Answer these from memory, out loud or on paper, before looking. Recognition is not recall: rereading an explanation feels like knowing, and it is not. A question you cannot answer cold names the exact section to revisit — you do not need to reread the guide.
1. What single structural fact do subtree aggregation, LCA, and rerooting all rely on?
Answer
There is exactly one simple path between any two nodes. That is what makes a post-order DFS see each subtree exactly once, makes “the” ancestor well-defined, and makes removing an edge split the tree into precisely two components.
2. What do tin and tout buy you, and what is the ancestor test?
Answer
They flatten the tree so that a node’s entire subtree occupies a contiguous index range — which lets a segment tree or BIT answer subtree queries. u is an ancestor of v exactly when tin[u] <= tin[v] <= tout[u].
3. Two things in this repo are called an “Euler tour”. How do they differ?
Answer
This guide’s §2 records each node once as a tin/tout pair — n entries, subtree becomes a range. The LCA guide records a node again on every backtrack — 2n−1 entries, enabling range-minimum over depths. Same DFS, different bookkeeping, different payoff.
4. Rerooting: what do the two passes each compute?
Answer
The post-order pass computes, for every node, the answer restricted to its own subtree (bottom-up facts). The pre-order pass pushes down the correction for everything outside that subtree, converting the parent’s complete answer into the child’s in O(1).
5. In sum-of-distances, why is the slide ans[c] = ans[node] − size[c] + (n − size[c])?
Answer
Moving the root from node to child c: every node inside c’s subtree gets one step closer (there are size[c] of them, so subtract), and every node outside gets one step farther (there are n − size[c], so add).
6. Why does two-BFS find the diameter, and when is that argument invalid?
Answer
From any start, the farthest node is provably an endpoint of some diameter; a second BFS from there finds the other endpoint. The argument needs the unique-path property, so it holds for trees with non-negative edges and fails on general graphs.
7. What does small-to-large merging buy, and what makes the bound work?
Answer
Merging each node’s children’s sets while always inserting the smaller set into the larger. Any single element can be moved only when the set containing it at least doubles, so it moves at most log n times — total O(n log n).
8. When is centroid decomposition the right tool?
Answer
Counting or querying over paths that may pass anywhere in the tree. Removing a centroid splits the tree into pieces of at most half the size, so the recursion is O(log n) deep and every path is examined at exactly one level.
See Also
- LCA — the canonical guide for ancestor queries: five techniques compared, with the binary-lifting and Euler-tour implementations in full.
- Edge Contribution & Rerooting — the canonical guide for distance/cost rerooting (LC 834, 2049, 2858) and the per-edge counting argument.
- Dynamic Programming §10 — the canonical guide for generic tree DP (select/skip, matching, colouring).
- Segment Tree / Fenwick Tree (BIT) — the range structures that §2’s
tin/toutflattening and §3’s HLD chains feed into. - Graph Patterns — the general case; drop the acyclicity and most of this guide’s shortcuts disappear.
- Pattern Decision Map — the router: which technique does a cold problem call for?
- Pattern Mastery Program — the spaced-repetition schedule, mastery checklist, and drill formats that turn reading into recall.
Pattern mastered — a tree has one path between any two nodes, so a single post-order DFS with the right return value answers almost everything; the rest is machinery for when “almost” isn’t fast enough.