Trees & BSTs
Hierarchical data. DFS (pre/in/post-order) and BFS. BST enables O(log n) search.
Trees & Binary Search Trees
Tree Taxonomy
| Type | Definition | |------|-----------| | Binary tree | Each node has at most 2 children | | BST | Binary tree where left < node < right (strict) | | Balanced tree | Heights of subtrees differ by at most 1 — guarantees O(log n) ops. Examples: AVL tree, Red-Black tree | | Complete binary tree | Every level filled except possibly the last, which fills left-to-right | | Full binary tree | Every node has exactly 0 or 2 children | | Perfect binary tree | Full AND complete — all leaves at same level. Has exactly 2^k − 1 nodes where k = number of levels |
Tree Properties
- Height: Longest path from root to leaf (edges)
- Depth: Distance from root to a specific node (edges)
- Level: Root is level 0, children are level 1, etc.
- Diameter: Longest path between any two nodes (may not pass through root)
BST Complexity
| Operation | Average (balanced) | Worst (skewed) | |-----------|--------------------|----------------| | Search | O(log n) | O(n) | | Insert | O(log n) | O(n) | | Delete | O(log n) | O(n) |
Python Node Template
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
DFS Traversals
def preorder(node): # current → left → right (preserves tree structure)
if not node: return
print(node.val)
preorder(node.left)
preorder(node.right)
def inorder(node): # left → current → right (SORTED for BST!)
if not node: return
inorder(node.left)
print(node.val)
inorder(node.right)
def postorder(node): # left → right → current (children before parent)
if not node: return
postorder(node.left)
postorder(node.right)
print(node.val)
All traversals can also be done iteratively using an explicit stack — avoids Python recursion depth limits on skewed trees.
BFS (Level-Order)
from collections import deque
def level_order(root):
if not root: return []
result, q = [], deque([root])
while q:
level = []
for _ in range(len(q)): # process exactly one level
node = q.popleft()
level.append(node.val)
if node.left: q.append(node.left)
if node.right: q.append(node.right)
result.append(level)
return result
Tree Height & Balance Check
def height(node):
if not node: return 0
return 1 + max(height(node.left), height(node.right))
def is_balanced(node):
"""O(n) — return -1 if unbalanced, height otherwise."""
def check(n):
if not n: return 0
l = check(n.left)
r = check(n.right)
if l == -1 or r == -1 or abs(l - r) > 1:
return -1
return 1 + max(l, r)
return check(node) != -1
Validate BST (min/max bounds — the right approach)
Local parent-child checks are not enough — a node must satisfy ALL ancestor constraints:
def is_valid_bst(root, lo=float('-inf'), hi=float('inf')):
if not root: return True
if root.val <= lo or root.val >= hi:
return False
return (is_valid_bst(root.left, lo, root.val) and
is_valid_bst(root.right, root.val, hi))
Tree Construction: Sorted Array → Minimal BST
Always pick the middle element as root — recursively for each half:
def sorted_array_to_bst(arr):
if not arr: return None
mid = len(arr) // 2
node = TreeNode(arr[mid])
node.left = sorted_array_to_bst(arr[:mid])
node.right = sorted_array_to_bst(arr[mid+1:])
return node
# Time: O(n), Space: O(n) for tree + O(log n) for recursion stack
You can also reconstruct trees from:
- Preorder + inorder: root from preorder, split inorder at root
- Postorder + inorder: root from postorder, split inorder at root
In-Order Successor in BST
Given a node, find the next node in in-order traversal:
def inorder_successor(node):
# Case 1: node has a right subtree
# → successor is the LEFTMOST node in the right subtree
if node.right:
curr = node.right
while curr.left:
curr = curr.left
return curr
# Case 2: no right subtree
# → go up parent chain until we come from a LEFT child
# → that parent is the successor
child = node
parent = node.parent
while parent and child is parent.right:
child = parent
parent = parent.parent
return parent # None if node is the maximum
# Time: O(h), Space: O(1)
Lowest Common Ancestor (LCA)
With parent pointers:
- Get depth of both nodes
- Move deeper node up until same depth
- Move both up together until they meet
Without parent pointers (DFS):
def lca(root, p, q):
"""Returns LCA node if both p and q are in tree."""
if not root 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:
return root # p in one subtree, q in the other
return left or right
# Time: O(n), Space: O(h)
Subtree Matching
Naive: find T2's root in T1 via BFS, then compare trees simultaneously — O(n × m).
Better approach (serialize + substring):
Serialize both trees using preorder with null markers (e.g. "4 2 # # 6 # #"), then check if T2's serialized form is a substring of T1's. Use KMP for O(n + m).
Binary Heaps
A min-heap is a complete binary tree where every parent ≤ its children (root = minimum). Max-heap: every parent ≥ its children (root = maximum).
Array implementation (space-efficient):
Parent at index i → children at 2i+1 and 2i+2
Child at index i → parent at (i-1)//2
import heapq
# Min-heap in Python
heap = []
heapq.heappush(heap, 3)
heapq.heappush(heap, 1)
heapq.heappush(heap, 2)
heapq.heappop(heap) # → 1 (minimum)
# Max-heap: negate values
heapq.heappush(heap, -val)
max_val = -heapq.heappop(heap)
| Operation | Time | |-----------|------| | Insert (push) | O(log n) | | Extract min/max | O(log n) | | Peek | O(1) |
Use cases: Dijkstra's algorithm, merge k sorted lists, find k largest/smallest, event simulation.
Tries (Prefix Trees)
An N-ary tree where each node represents a character. End of word marked with a * flag.
class TrieNode:
def __init__(self):
self.children = {}
self.is_end = False
class Trie:
def __init__(self): self.root = TrieNode()
def insert(self, word):
node = self.root
for c in word:
node = node.children.setdefault(c, TrieNode())
node.is_end = True
def search(self, word):
node = self.root
for c in word:
if c not in node.children: return False
node = node.children[c]
return node.is_end
def starts_with(self, prefix):
node = self.root
for c in prefix:
if c not in node.children: return False
node = node.children[c]
return True
Lookup is O(k) where k = length of string — similar to a hash map (which still hashes each character).
Word search / word break: Build a Trie, run DFS+backtracking from each cell. At each step, check if current path is a valid prefix — prune early if not, which drastically reduces search space.
Key Interview Tips
- Inorder traversal of a BST produces a sorted sequence — use this for validation, k-th smallest, etc.
- Pre/post order preserve tree structure — useful for serialisation
- Recursive solutions are natural; switch to iterative on skewed trees to avoid stack overflow
- BST deletion: leaf → remove directly; one child → replace; two children → swap with in-order successor then delete successor
- For "find k-th smallest in BST": inorder traversal + counter