Trees¶
Trees model hierarchy: a family tree, a file system, an organization chart, the parse of an expression, the decisions of a search. Whenever a problem talks about ancestors and descendants, parents and children, containment or dominance, there is probably a tree in it.
But the real reason trees deserve careful study is that they are the purest example of a recursive object. A binary tree is either empty, or a root together with two smaller binary trees. Almost every tree algorithm is written by trusting that definition: solve the problem for the two subtrees, then combine. If you become fluent at that one move, the vast majority of tree problems become short exercises.
Recursion Is Induction¶
Recursion can feel like magic the first time you see it: the function calls itself on a smaller input and somehow gets the right answer. Mathematical induction can feel the same way: you assume the claim for smaller cases and somehow prove it for the general one. They feel alike because they are the same thing. A recursive function is correct if:
- Base case: it returns the right answer on the smallest inputs (the empty tree, a leaf).
- Inductive step: assuming the recursive calls return correct answers for the subtrees, the function combines them into the correct answer for the whole tree.
That's the entire method. You never trace the recursion all the way down; you trust the recursive calls exactly the way an inductive proof trusts the induction hypothesis.
from __future__ import annotations
from dataclasses import dataclass
@dataclass
class TreeNode:
val: int = 0
left: TreeNode | None = None
right: TreeNode | None = None
def max_depth(root: TreeNode | None) -> int:
"""LC 104. Base: the empty tree has depth 0. Step: 1 + the deeper subtree."""
if root is None:
return 0
return 1 + max(max_depth(root.left), max_depth(root.right))
Take-Home Lesson
To write a tree function, answer two questions: what should it return for an empty tree? and given correct answers for the left and right subtrees, how do I build the answer for the whole tree? Don't trace the recursion; trust it.
Strengthening the hypothesis¶
Sometimes the answers from the subtrees aren't enough to build the answer for the tree. Then the fix is the same as in an induction proof that gets stuck: prove something stronger. Make the recursive function return more information than the problem asked for.
Balanced Binary Tree (LC 110). A tree is height-balanced if every node's subtrees differ in height by at most one. The definition transcribes directly:
def is_balanced_naive(root: TreeNode | None) -> bool:
if root is None:
return True
return (abs(max_depth(root.left) - max_depth(root.right)) <= 1
and is_balanced_naive(root.left) and is_balanced_naive(root.right))
It's correct, but every node recomputes the heights of its whole subtree, which is \(O(n^2)\) on a path-shaped tree. The trouble is that the recursive call answers only "is this subtree balanced?", and the parent also needs its height. So return both. A convenient encoding returns the height, or \(-1\) for "unbalanced":
def is_balanced(root: TreeNode | None) -> bool:
def height(node: TreeNode | None) -> int:
"""Height of node's subtree, or -1 if it isn't balanced."""
if node is None:
return 0
lh, rh = height(node.left), height(node.right)
if lh < 0 or rh < 0 or abs(lh - rh) > 1:
return -1
return 1 + max(lh, rh)
return height(root) >= 0
The strengthened version is one pass, \(O(n)\).
Diameter of Binary Tree (LC 543). The longest path between any two nodes either passes through the root or lies entirely inside one subtree. If it passes through a node, it's the deepest path down the left plus the deepest path down the right. So the recursive function returns depth (what the parent needs) while recording the best diameter seen (what the problem asks for):
def diameter_of_binary_tree(root: TreeNode | None) -> int:
best = 0
def depth(node: TreeNode | None) -> int:
nonlocal best
if node is None:
return 0
left, right = depth(node.left), depth(node.right)
best = max(best, left + right) # longest path bending at this node
return 1 + max(left, right) # longest path going down from here
depth(root)
return best
Binary Tree Maximum Path Sum (LC 124) has exactly the same shape, with sums instead of lengths — and one twist: a branch with a negative sum should be dropped, so each child contributes max(0, gain).
def max_path_sum(root: TreeNode) -> int:
best = root.val
def gain(node: TreeNode | None) -> int:
nonlocal best
if node is None:
return 0
left = max(0, gain(node.left))
right = max(0, gain(node.right))
best = max(best, node.val + left + right) # path bending here
return node.val + max(left, right) # path continuing to the parent
gain(root)
return best
This "return one thing, record another" pattern is the single most important idea for hard tree problems. The value returned to the parent must describe a path that can be extended upward (so it can use only one branch); the value recorded globally can bend at the current node.
Traversals¶
Visiting every node is the foundation of everything else. The three depth-first orders differ only in when the node itself is processed relative to its subtrees:
| Order | Sequence | Typical use |
|---|---|---|
| Pre-order | node, left, right | Copying or serializing a tree; top-down propagation |
| In-order | left, node, right | BST in sorted order |
| Post-order | left, right, node | Anything needing both subtrees' answers first: height, deletion, evaluation |
def inorder(root: TreeNode | None) -> list[int]:
out: list[int] = []
def visit(node: TreeNode | None) -> None:
if node:
visit(node.left)
out.append(node.val) # move this line up for pre-order, down for post-order
visit(node.right)
visit(root)
return out
All three are \(O(n)\) time and \(O(h)\) space for the recursion stack, where \(h\) is the height.
Iterative traversals¶
Recursion uses the call stack implicitly; an explicit stack does the same job and avoids Python's recursion limit on deep trees. Pre-order is simplest — push the right child before the left so the left is processed first:
def preorder_iter(root: TreeNode | None) -> list[int]:
out, stack = [], [root] if root else []
while stack:
node = stack.pop()
out.append(node.val)
if node.right:
stack.append(node.right)
if node.left:
stack.append(node.left)
return out
In-order must first dive left as far as possible, remembering the path, then process and turn right:
def inorder_iter(root: TreeNode | None) -> list[int]:
out: list[int] = []
stack: list[TreeNode] = []
node = root
while node or stack:
while node: # dive left, remembering the path
stack.append(node)
node = node.left
node = stack.pop() # leftmost unvisited node
out.append(node.val)
node = node.right # then its right subtree
return out
Post-order is easiest as "reverse of a modified pre-order": visit node, right, left, then reverse the output.
(Morris traversal achieves \(O(1)\) extra space by temporarily threading right pointers back to ancestors. It's clever, rarely necessary, and modifies the tree during the walk.)
Level-Order Traversal (BFS)¶
Visiting nodes level by level uses a queue instead of a stack. Processing the queue in batches — one batch per level — gives per-level results:
from collections import deque
def level_order(root: TreeNode | None) -> list[list[int]]:
"""LC 102."""
if root is None:
return []
levels, queue = [], deque([root])
while queue:
level = []
for _ in range(len(queue)): # exactly the nodes of this level
node = queue.popleft()
level.append(node.val)
if node.left:
queue.append(node.left)
if node.right:
queue.append(node.right)
levels.append(level)
return levels
Many problems are one-line variations: the right side view (LC 199) is the last element of each level; zigzag order (LC 103) reverses every other level; minimum depth (LC 111) is the level of the first leaf BFS reaches — and BFS finds it without exploring deeper levels, which DFS can't promise.
Top-Down vs. Bottom-Up¶
There are two ways to move information through a tree recursion, and recognizing which one a problem needs is half the solution.
Top-down (pre-order): pass information down as arguments — the depth so far, the sum so far, the allowed range of values. Each node receives context from its ancestors.
def has_path_sum(root: TreeNode | None, target: int) -> bool:
"""LC 112: is there a root-to-leaf path summing to 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)
Bottom-up (post-order): return information up as results — height, subtree size, subtree sum, "is this subtree valid." Each node combines what its children report. max_depth, is_balanced, and diameter above are all bottom-up.
Some problems need both: pass the running state down, and collect results as the recursion unwinds. Path Sum II (LC 113) passes the current path down, appends at each node, and undoes the append on the way back up — the backtracking pattern (see Combinatorial Search).
Path Sum III (LC 437) counts downward paths (not necessarily from the root) summing to a target. Along any root-to-node path, this is exactly "count subarrays with sum \(k\)" — so it's solved with the prefix-sum-plus-hash-map idea from Two Pointers & Sliding Window, passing the prefix counts down and removing each prefix as the recursion leaves its node:
def path_sum_iii(root: TreeNode | None, target: int) -> int:
counts = {0: 1} # prefix sums on the current root-to-node path
def dfs(node: TreeNode | None, prefix: int) -> int:
if node is None:
return 0
prefix += node.val
found = counts.get(prefix - target, 0)
counts[prefix] = counts.get(prefix, 0) + 1
found += dfs(node.left, prefix) + dfs(node.right, prefix)
counts[prefix] -= 1 # leaving this node: its prefix is no longer on the path
return found
return dfs(root, 0)
Binary Search Trees¶
Sorted arrays offer fast search (\(O(\log n)\) by binary search) but slow updates (\(O(n)\) to shift elements). Linked lists offer fast updates but slow search. Binary search needs quick access to the element in the middle of the remaining range — and a binary search tree provides it by storing the median of each range at the top, as a linked structure with two pointers per node.
BST property: for every node \(x\), all keys in \(x\)'s left subtree are less than \(x\), and all keys in its right subtree are greater.
Note the word all. It's not enough for each node to be bigger than its left child: every key in the entire left subtree must be smaller.
Search, minimum, successor¶
Search compares the key with the root and descends into the only subtree that could contain it. The minimum is the leftmost node; the maximum is the rightmost. All take \(O(h)\).
def search_bst(root: TreeNode | None, key: int) -> TreeNode | None:
node = root
while node and node.val != key:
node = node.left if key < node.val else node.right
return node
def insert_bst(root: TreeNode | None, key: int) -> TreeNode:
"""Insert where an unsuccessful search for key falls off the tree."""
if root is None:
return TreeNode(key)
if key < root.val:
root.left = insert_bst(root.left, key)
else:
root.right = insert_bst(root.right, key)
return root
An in-order traversal of a BST visits keys in sorted order — this follows directly from the BST property, by induction. It makes the \(k\)-th smallest element (LC 230) an in-order traversal that stops after \(k\) nodes, and validation a check that the in-order sequence is strictly increasing.
Validation (LC 98)¶
The classic bug is to check only that each node is between its two children. That misses violations deeper down:
5
/ \
1 6 each node is fine relative to its children,
/ \ but 3 is in 5's RIGHT subtree and 3 < 5
3 7
The fix is to pass bounds down (top-down): every node in the right subtree of 5 must be greater than 5.
def is_valid_bst(root: TreeNode | None) -> bool:
def valid(node: TreeNode | None, lo: float, hi: float) -> bool:
if node is None:
return True
if not lo < node.val < hi:
return False
return valid(node.left, lo, node.val) and valid(node.right, node.val, hi)
return valid(root, float("-inf"), float("inf"))
Deletion (LC 450)¶
Deleting a node has three cases:
- No children: just remove it.
- One child: replace it with that child. The child's subtree already sits entirely on the correct side of the parent, so the BST property survives.
- Two children: the tricky case. Replace the node's key with its in-order successor — the smallest key in its right subtree, which is the leftmost node there. That successor has no left child, so deleting it from the right subtree falls into case 1 or 2.
def delete_node(root: TreeNode | None, key: int) -> TreeNode | None:
if root is None:
return None
if key < root.val:
root.left = delete_node(root.left, key)
elif key > root.val:
root.right = delete_node(root.right, key)
else:
if root.left is None:
return root.right
if root.right is None:
return root.left
successor = root.right
while successor.left:
successor = successor.left
root.val = successor.val
root.right = delete_node(root.right, successor.val)
return root
How good are binary search trees?¶
Every operation costs \(O(h)\). The height \(h\) ranges from \(\log_2 n\) (perfectly balanced) to \(n\) (a path) — and which one you get depends entirely on the insertion order, which the data structure doesn't control. Insert keys in sorted order, a very common situation, and each new key becomes the right child of the previous one: the BST degenerates into a linked list with \(O(n)\) operations.
If the keys arrive in random order, the tree is good: the average depth of a node is about \(2 \ln n \approx 1.39 \log_2 n\) — only 39% more than in a perfectly balanced tree — and the height is \(O(\log n)\) with high probability. (The analysis is the same as for randomized quicksort: the root of a random BST plays the role of the first pivot.) But "random order" is an assumption about the user, not a guarantee.
Balanced search trees (AVL trees, red–black trees, B-trees, skip lists) restructure themselves slightly on each update to guarantee \(h = O(\log n)\), so every operation is \(O(\log n)\) in the worst case. You should know they exist and what they guarantee; you will almost never need to implement one. For algorithm analysis, assume a balanced tree is available as a black box.
Balanced trees in Python
Python has no balanced BST in its standard library. The usual substitutes: bisect on a sorted list (fast search, \(O(n)\) insert — often fine in practice because the shifting is a fast memmove), heapq if you only need the minimum, or the third-party sortedcontainers.SortedList (\(O(\log n)\)-ish everything), which LeetCode makes available.
Take-Home Lesson
Picking the wrong data structure can be disastrous — an unbalanced BST on sorted input is a linked list in disguise. Picking the very best one is usually less critical, because several good choices perform similarly. Aim first to avoid the disaster.
Stop and Think: Sorting with a search tree
Problem: You have a balanced BST supporting insert, delete, search, minimum, maximum, successor, and predecessor, each in \(O(\log n)\). How can you sort \(n\) numbers in \(O(n \log n)\) using only (a) insert and in-order traversal? (b) minimum, successor, and insert? (c) minimum, insert, and delete?
Solution: Every version begins by inserting all \(n\) items: \(O(n \log n)\). Then:
- (a) An in-order traversal lists the keys in sorted order: \(O(n)\).
- (b) Start at the minimum and call successor \(n - 1\) times: \(O(n \log n)\).
- (c) Repeatedly find and delete the minimum: \(O(n \log n)\). This is heapsort's strategy, with a tree standing in for the heap.
In every case, building the structure is the bottleneck. And (c) shows that a balanced BST can do everything a priority queue can.
Lowest Common Ancestor¶
The lowest common ancestor (LCA) of nodes \(p\) and \(q\) is the deepest node that has both of them as descendants (a node counts as its own descendant).
In a BST (LC 235), the BST property steers the search. If both \(p\) and \(q\) are smaller than the current node, the LCA is in the left subtree; if both are larger, it's in the right. Otherwise they split here (or one of them is here), so this is the LCA.
def lca_bst(root: TreeNode, p: TreeNode, q: TreeNode) -> TreeNode:
node = root
while True:
if p.val < node.val and q.val < node.val:
node = node.left
elif p.val > node.val and q.val > node.val:
node = node.right
else:
return node
In a general binary tree (LC 236), there are no values to steer by, so ask each subtree bottom-up: does it contain \(p\) or \(q\)? The recursive function returns \(p\) or \(q\) if it finds one, the LCA if it finds both, and None otherwise.
def lca(root: TreeNode | None, p: TreeNode, q: TreeNode) -> TreeNode | None:
if root is None or root is p or root is q:
return root
left = lca(root.left, p, q)
right = lca(root.right, p, q)
if left and right: # p and q found in different subtrees: we're the split point
return root
return left or right # pass up whichever was found (or None)
Why is it correct when \(p\) is an ancestor of \(q\)? The recursion returns \(p\) as soon as it reaches \(p\), without looking for \(q\) below it. That's the right answer: \(q\) is guaranteed to exist in the tree, and if it isn't found elsewhere, it must be under \(p\).
Building and Serializing Trees¶
Why one traversal isn't enough¶
The pre-order sequence [1, 2, 3] could come from five different three-node trees. A single traversal loses the shape. Two traversals can pin it down: pre-order + in-order determine a binary tree with distinct values uniquely. Pre-order tells you the root (its first element); finding that root in the in-order sequence splits everything else into the left and right subtrees (LC 105).
def build_tree(preorder: list[int], inorder: list[int]) -> TreeNode | None:
index = {v: i for i, v in enumerate(inorder)}
next_pre = 0
def build(lo: int, hi: int) -> TreeNode | None:
"""Build the subtree whose in-order values are inorder[lo:hi]."""
nonlocal next_pre
if lo >= hi:
return None
root = TreeNode(preorder[next_pre])
next_pre += 1
mid = index[root.val]
root.left = build(lo, mid) # left subtree comes next in pre-order
root.right = build(mid + 1, hi)
return root
return build(0, len(inorder))
The hash map avoids an \(O(n)\) search for each root, making the whole construction \(O(n)\). (Pre-order + post-order is not enough in general: a node with a single child can't tell whether it's a left or right child.)
Serialization (LC 297)¶
To write a tree to a string and read it back, a single traversal is enough — if you also record the empty subtrees. With null markers, the pre-order sequence describes the shape completely:
def serialize(root: TreeNode | None) -> str:
out: list[str] = []
def walk(node: TreeNode | None) -> None:
if node is None:
out.append("#")
return
out.append(str(node.val))
walk(node.left)
walk(node.right)
walk(root)
return ",".join(out)
def deserialize(data: str) -> TreeNode | None:
tokens = iter(data.split(","))
def build() -> TreeNode | None:
tok = next(tokens)
if tok == "#":
return None
node = TreeNode(int(tok))
node.left = build()
node.right = build()
return node
return build()
The deserializer reads tokens in exactly the order the serializer wrote them, so no indices or lengths are needed. Serialization also gives a neat solution for finding duplicate subtrees (LC 652): two subtrees are identical exactly when their serializations are equal.
Common Mistakes¶
-
Checking only immediate children for BST validity. Pass bounds down, or check that the in-order sequence is strictly increasing.
-
Recomputing heights at every node. Calling
max_depthinside another recursion makes the algorithm \(O(n^2)\) on skewed trees. Return the height alongside the other result. -
Confusing "returned" and "recorded" values. In diameter and max-path-sum problems, the parent can only extend a one-branch path; the bent path through a node is a candidate answer, not a return value.
-
Forgetting the backtracking step. When passing a mutable path or counter down, undo the change after the recursive calls return.
-
Leaf vs.
Noneconfusion. "Root-to-leaf" means stopping at nodes with no children, not atNone— otherwise a node with one child counts as a path end. -
Deep recursion. A 10⁵-node skewed tree exceeds Python's default recursion limit. Use an explicit stack or BFS when depth can be large.
-
Assuming a BST is balanced. \(O(\log n)\) holds only for balanced trees. Sorted insertions produce \(O(n)\) height.
Practice Problems¶
| Problem | Idea |
|---|---|
| Maximum Depth of Binary Tree (LC 104) | Bottom-up height |
| Invert Binary Tree (LC 226) | Swap children recursively |
| Same Tree (LC 100) / Subtree of Another Tree (LC 572) | Simultaneous recursion |
| Binary Tree Level Order Traversal (LC 102) | BFS in batches |
| Binary Tree Right Side View (LC 199) | Last node per level |
| Balanced Binary Tree (LC 110) | Return height or −1 |
| Diameter of Binary Tree (LC 543) | Return depth, record diameter |
| Binary Tree Maximum Path Sum (LC 124) | Return gain, record bent path |
| Path Sum II (LC 113) | Top-down with backtracking |
| Path Sum III (LC 437) | Prefix sums on the root path |
| Validate Binary Search Tree (LC 98) | Bounds passed down |
| Kth Smallest Element in a BST (LC 230) | In-order, stop at \(k\) |
| Delete Node in a BST (LC 450) | Successor replacement |
| Lowest Common Ancestor of a BST (LC 235) | Steer by values |
| Lowest Common Ancestor of a Binary Tree (LC 236) | Bottom-up search |
| Construct Tree from Preorder and Inorder (LC 105) | Root from pre-order, split by in-order |
| Serialize and Deserialize Binary Tree (LC 297) | Pre-order with null markers |
| Count Good Nodes in Binary Tree (LC 1448) | Top-down running max |