Tree DFS
Recursive pre/in/post-order traversal — the foundation of most tree problems.
TL;DR
Tree DFS is recursion with one rule: at every node, decide whether you do work before recursing (pre-order), between recursing left and right (in-order), or after both children return (post-order). Pick the order that matches when your answer is ready.
Pre-order (root, left, right) is the natural fit for top-down work — copy a
tree, serialise it, print an outline, push state from parent to child.
In-order (left, root, right) on a BST visits values in sorted order, which
is why “kth smallest in BST” and “validate BST” both reduce to a one-line
in-order check. Post-order (left, right, root) is the workhorse: anything
where the parent’s answer is a function of its children’s answers — depth,
diameter, balanced, max path sum, “delete leaves matching X” — folds bottom-up.
The other axis is direction of information flow. Bottom-up DFS returns a value from each call and the parent combines them. Top-down DFS passes state down as an argument (remaining target, current path, accumulated sum) and the leaf decides. Most non-trivial tree problems are one of these two shapes wearing different clothes.
When to reach for it
Reach for tree DFS when:
- The answer at a node is a function of its subtree results — depth, diameter, height-balanced, sum of all paths, count of nodes matching a predicate.
- You need to traverse every node exactly once and the order matters (serialise, clone, print).
- A problem says “BST” and asks for sorted/ranked output — in-order DFS is the move.
- You’re carrying state from root to leaf — path sums, ancestor lists, remaining budget.
Reach for BFS instead when the question is about levels (level order, zigzag, right-side view, shortest path in an unweighted graph). Reach for iterative DFS with an explicit stack when recursion depth is a real risk (see pitfalls).
Flavor 1 — pre/in/post-order traversal
The three orders are the same recursion with the visit line moved.
class Node:
def __init__(self, val, left=None, right=None):
self.val = val
self.left = left
self.right = right
def preorder(node, out):
if node is None: return
out.append(node.val) # visit
preorder(node.left, out)
preorder(node.right, out)
def inorder(node, out):
if node is None: return
inorder(node.left, out)
out.append(node.val) # visit
inorder(node.right, out)
def postorder(node, out):
if node is None: return
postorder(node.left, out)
postorder(node.right, out)
out.append(node.val) # visit
Worked example. Run all three on this tree:
1
/ \
2 3
/ \ \
4 5 6
Stepping through pre-order: visit 1, recurse left into 2, visit 2, recurse left into 4, visit 4 (leaf), back up, recurse right into 5, visit 5, back up to 1, recurse right into 3, visit 3, recurse right into 6, visit 6.
| traversal | sequence of visits | mnemonic |
|---|---|---|
| pre-order | 1, 2, 4, 5, 3, 6 | root before children |
| in-order | 4, 2, 5, 1, 3, 6 | left, root, right |
| post-order | 4, 5, 2, 6, 3, 1 | root after children |
If this tree were a BST you’d want in-order to give sorted output. It isn’t (2’s right child 5 < parent 1 fails BST), so the in-order sequence here is just the structural left/root/right walk — useful for printing, not for sort.
Flavor 2 — bottom-up DFS (post-order folds)
The pattern: each call returns one value computed from its children’s return values. The parent folds them. The recursion is the post-order traversal.
def helper(node):
if node is None:
return base_case # 0 for depth, -inf for max, etc.
left = helper(node.left)
right = helper(node.right)
return combine(node, left, right)
Worked example: diameter of binary tree (LC 543). The diameter is the
longest path (in edges) between any two nodes. Key observation: the longest
path that passes through a node v has length left_depth(v) + right_depth(v). The answer is the max of that quantity over all nodes. Each
call returns the depth of the subtree (so the parent can use it) while
updating a global best.
def diameter_of_binary_tree(root):
best = 0
def depth(node):
nonlocal best
if node is None:
return 0
l = depth(node.left)
r = depth(node.right)
best = max(best, l + r) # path through node, in edges
return 1 + max(l, r) # depth contributed to parent
depth(root)
return best
Trace on the tree above. Depth returned is 1 + max(l, r); l + r is the
candidate diameter through that node.
| node | l (left depth) | r (right depth) | l+r | depth returned | best after |
|---|---|---|---|---|---|
| 4 | 0 | 0 | 0 | 1 | 0 |
| 5 | 0 | 0 | 0 | 1 | 0 |
| 2 | 1 | 1 | 2 | 2 | 2 |
| 6 | 0 | 0 | 0 | 1 | 2 |
| 3 | 0 | 1 | 1 | 2 | 2 |
| 1 | 2 | 2 | 4 | 3 | 4 |
Answer: 4 edges (the path 4 → 2 → 1 → 3 → 6). The fold runs in post-order
because node 1 can’t compute l + r until both children have returned their
depths.
The same skeleton solves max depth (return 1 + max(l, r), no global),
balanced-binary-tree (return -1 to short-circuit when an imbalance is
found), and binary-tree-max-path-sum (LC 124) — change only the combine.
Flavor 3 — top-down DFS (passing state down)
When the answer depends on the path from the root rather than the subtree, push state down as an argument.
def helper(node, state):
if node is None:
return False
new_state = update(state, node)
if is_leaf(node):
return check(new_state)
return helper(node.left, new_state) or helper(node.right, new_state)
Example: path sum (LC 112). Does any root-to-leaf path sum to target?
Carry the remaining target down; at a leaf, check if it hit zero.
def has_path_sum(root, target):
if root is None:
return False
remaining = target - root.val
if root.left is None and root.right is None:
return remaining == 0
return has_path_sum(root.left, remaining) or has_path_sum(root.right, remaining)
On the tree above with target = 7: at 1, remaining = 6. Down to 2,
remaining = 4. Down to 4 (leaf), remaining = 0 → True. The state (remaining)
flowed root → leaf; nothing came back up beyond the boolean.
Top-down also covers: collecting all root-to-leaf paths (carry a list, copy on push), counting good nodes (carry max-on-path), and lowest-common-ancestor variants that thread depth or ancestry down.
Why it works — the invariant
Every recursive DFS is using the call stack as an implicit stack of
unfinished work. When you call dfs(node.left), the frame for node is
suspended with its local variables intact (including node, the unread
node.right, and any partial results). When the left subtree finishes, the
frame resumes exactly where it left off. That’s why post-order can fold
results — the parent’s frame is still alive, holding the slot where left
and right will land.
Each node is pushed onto the call stack exactly once and popped exactly once. That’s the visit-each-node-once guarantee.
Complexity
- Time: O(n). Constant work at each node, n nodes.
- Space: O(h), where
his the tree height — the maximum depth of the call stack. Balanced tree → O(log n). Skewed tree (linked-list-shaped) → O(n). The recursion stack dominates; if you also build an output list, add O(n) for that.
Common pitfalls
- Skewed trees blow the recursion limit. Python’s default is 1000. A
10,000-node left-skewed tree (LC test cases love these) crashes with
RecursionError. Either bump it (sys.setrecursionlimit(10**6)) or rewrite iteratively with an explicitstack = [root]loop. The iterative rewrite is unavoidable for production code where recursion depth isn’t bounded. - Forgetting the
Nonebase case.if node is None: returnis the single most common omission. Without it, you’ll dereference.leftonNoneand crash. Write the base case first, before the recursive calls. - Mutating shared state across siblings. If you carry a list down by
reference (e.g. for collecting paths), append before recursing and
pop after — otherwise the right subtree sees the left subtree’s
appended values. The fix is either pop-after-recurse (backtracking) or
pass an immutable copy
path + [node.val]down. - Helper that should fold a value but returns nothing. A bottom-up
helper must
returnfrom every branch — including theNonebase case. A missing return turns into an implicitNoneand the parent’scombine(left, right)crashes on arithmetic withNone. - Confusing pre-order and post-order semantics. Pre-order can’t see children’s results yet; post-order can’t see ancestors’ state unless you threaded it down. Pick the order based on when your answer is ready, not by habit.
Where you see this in production
- Compiler AST traversal — Clang’s
RecursiveASTVisitor. Every C++ static analyser, linter, and refactoring tool walks the AST with a pre-order DFS, withVisitFoo(FooDecl*)hooks at each node type. Same shape aspreorderabove, just with virtual dispatch. - React’s fiber tree reconciler. React walks its component tree depth-first, completing children before their parent (post-order) so the parent can see committed child output before deciding its own DOM mutations. Reconciliation is literally a bottom-up fold.
duandfindin Unix.duis post-order — a directory’s size is the sum of its children’s sizes, computed only after the children are done.findis pre-order by default — print the directory, then recurse into it.- Babel and other JS transpilers. Babel’s
@babel/traversewalks the ESTree AST with bothenter(pre-order) andexit(post-order) hooks per node, so plugins can either rewrite tokens on the way down or rebuild nodes from transformed children on the way up. BeautifulSoup’sdescendantsiterator does the same for HTML.
Practice problems
| # | Problem | Difficulty | LeetCode | NeetCode |
|---|---|---|---|---|
| 1 | Maximum Depth of Binary Tree | Easy | LC 104 | walk-through |
| 2 | Invert Binary Tree | Easy | LC 226 | walk-through |
| 3 | Same Tree | Easy | LC 100 | walk-through |
| 4 | Diameter of Binary Tree | Easy | LC 543 | walk-through |
| 5 | Balanced Binary Tree | Easy | LC 110 | walk-through |
| 6 | Binary Tree Maximum Path Sum | Hard | LC 124 | walk-through |
Resources
- NeetCode roadmap — neetcode.io/roadmap — pattern-by-pattern problem sets organised exactly like this site.
- NeetCode practice grid — neetcode.io/practice — track which problems you’ve solved per pattern.
- NeetCode YouTube — @NeetCode — clear, whiteboard-style walkthroughs for almost every LeetCode problem above.