What’s one thing you learned? What’s still confusing?
Trees & BST, Part 2: Balanced Trees & Trie
BST deletion (all three cases), validation with min/max bounds, self-balancing AVL/Red-Black trees, Tries for autocomplete, and expression-tree evaluation.
Heaps, Priority Queues & Graphs
Min/max heaps, heapq, Dijkstra, topological sort, Union-Find.
Testing with pytest: Beyond Basic assert
Parametrize, fixtures, pytest.raises, and pytest-cov.
Interactive Labs for This Track
Loop Visualizer
You're a factory robot repeating the same task on an assembly line — watch how loops automate repetitive work
List Slicing
You have a playlist of 50 songs — grab just tracks 10 through 20 with a single slice expression
Sorting Algorithms
You're organizing a library of 10,000 books — which sorting method is fastest?
Ask questions, share insights
Consider this tree:
Tree Terminology Example
| Term | Definition | In the tree above |
|---|---|---|
| Root | Top node with no parent | A |
| Leaf | Node with no children | D, E, F |
| Internal node | Node with at least one child | A, B, C |
| Parent | Direct predecessor | A is parent of B and C |
| Children | Direct successors | B and C are children of A |
| Sibling | Nodes sharing the same parent | B and C are siblings |
| Subtree | A node plus all its descendants | B's subtree is |
def height(root) -> int:
"""Recursive height computation. O(n) — must visit every node."""
if root is None:
return -1 # height of empty tree is -1 by convention
return 1 + max(height(root.left), height(root.right))
def depth(root, target, current_depth: int = 0) -> int:
"""Find depth of the node containing target value."""
if root is None:
return -1
if root.val == target:
return current_depth
left = depth(root.left, target, current_depth + 1)
if left != -1:
return left
return depth(root.right, target, current_depth + 1)Insert values and watch BST ordering enforce itself. Step through all four traversals (inorder, preorder, postorder, BFS) with each node lit up as it is visited.
In a perfectly balanced binary tree with 15 nodes (4 levels: 1 + 2 + 4 + 8 nodes), what is the height?
from __future__ import annotations
from typing import Any, Optional, List
from collections import deque
class TreeNode:
"""A node in a binary tree."""
def __init__(self, val: Any) -> None:
self.val = val
self.left: Optional[TreeNode] = None
self.right: Optional[TreeNode] = None
def __repr__(self) -> str:
return f"TreeNode({self.val!r})"
def is_leaf(self) -> bool:
"""A leaf node has no children."""
return self.left is None and self.right is None
class BinaryTree:
"""
General binary tree with breadth-first insertion.
(BST, covered in Section 4, uses a different ordered insert.)
"""
def __init__(self) -> None:
self.root: Optional[TreeNode] = None
def insert(self, val: Any) -> None:
"""
Insert a value using BFS (level-order) insertion.
Fills left child first, then right, level by level.
O(n) in the worst case.
"""
new_node = TreeNode(val)
if self.root is None:
self.root = new_node
return
# BFS to find the first node with a free child slot
queue = deque([self.root])
while queue:
node = queue.popleft()
if node.left is None:
node.left = new_node
return
else:
queue.append(node.left)
if node.right is None:
node.right = new_node
return
else:
queue.append(node.right)
def height(self) -> int:
"""Height of the tree. O(n) — visits every node."""
def _height(node: Optional[TreeNode]) -> int:
if node is None:
return -1
return 1 + max(_height(node.left), _height(node.right))
return _height(self.root)
def count_nodes(self) -> int:
"""Count all nodes recursively. O(n)."""
def _count(node: Optional[TreeNode]) -> int:
if node is None:
return 0
return 1 + _count(node.left) + _count(node.right)
return _count(self.root)
def count_leaves(self) -> int:
"""Count leaf nodes. O(n)."""
def _leaves(node: Optional[TreeNode]) -> int:
if node is None:
return 0
if node.is_leaf():
return 1
return _leaves(node.left) + _leaves(node.right)
return _leaves(self.root)def inorder(root: Optional[TreeNode]) -> List[Any]:
"""
Left -> Root -> Right.
Produces SORTED output for a BST — extremely useful.
Think: 'visit the left subtree completely, then self, then right.'
"""
if root is None:
return []
return inorder(root.left) + [root.val] + inorder(root.right)
# For the example tree: [1, 2, 3, 4, 5, 6, 7]
def preorder(root: Optional[TreeNode]) -> List[Any]:
"""
Root -> Left -> Right.
Root is processed BEFORE its subtrees.
Use case: copy/serialize a tree (preorder gives you insertion order
to reconstruct the same BST shape from scratch).
"""
if root is None:
return []
return [root.val] + preorder(root.left) + preorder(root.right)
# For the example tree: [4, 2, 1, 3, 6, 5, 7]
def postorder(root: Optional[TreeNode]) -> List[Any]:
"""
Left -> Right -> Root.
Root is processed AFTER both subtrees.
Use case: (1) delete a tree (free children before parent),
(2) evaluate expression trees (compute sub-expressions first).
"""
if root is None:
return []
return postorder(root.left) + postorder(root.right) + [root.val]
# For the example tree: [1, 3, 2, 5, 7, 6, 4]def level_order(root: Optional[TreeNode]) -> List[List[Any]]:
"""
Visit nodes level by level, left to right.
Returns a list of levels — each level is a list of values.
Use case: shortest path in unweighted trees, printing tree structure,
finding minimum depth.
Uses a queue (deque) — never use recursion for BFS.
"""
if root is None:
return []
result = []
queue = deque([root])
while queue:
level_size = len(queue) # snapshot: how many nodes at this level
level_values = []
for _ in range(level_size):
node = queue.popleft()
level_values.append(node.val)
if node.left:
queue.append(node.left)
if node.right:
queue.append(node.right)
result.append(level_values)
return result
# For the example tree: [[4], [2, 6], [1, 3, 5, 7]]Recursive DFS is elegant but uses O(h) call stack space (dangerous for deep trees). Here are the iterative versions:
from collections import deque
def inorder_iterative(root: Optional[TreeNode]) -> List[Any]:
"""
Iterative inorder (Left -> Root -> Right).
Uses an explicit stack. Critical for very deep trees (no recursion limit).
"""
result = []
stack = []
current = root
while current is not None or stack:
# Go as far left as possible, pushing nodes onto the stack
while current is not None:
stack.append(current)
current = current.left
# Process the node (it has no more left children to push)
current = stack.pop()
result.append(current.val)
# Move to the right subtree
current = current.right
return result
def preorder_iterative(root: Optional[TreeNode]) -> List[Any]:
"""
Iterative preorder (Root -> Left -> Right).
Push right child first so left is processed first (stack is LIFO).
"""
if root is None:
return []
result = []
stack = [root]
while stack:
node = stack.pop()
result.append(node.val) # process root
if node.right:
stack.append(node.right) # right pushed first (processed second)
if node.left:
stack.append(node.left) # left pushed second (processed first)
return result
def postorder_iterative(root: Optional[TreeNode]) -> List[Any]:
"""
Iterative postorder (Left -> Right -> Root).
Trick: do a modified preorder (Root -> Right -> Left), then reverse the result.
"""
if root is None:
return []
result = []
stack = [root]
while stack:
node = stack.pop()
result.append(node.val)
if node.left:
stack.append(node.left) # left pushed first (reversed later)
if node.right:
stack.append(node.right) # right pushed second
return result[::-1] # reverse gives Left -> Right -> RootTraversal Outputs
| Traversal | Output | Key Property |
|---|---|---|
| Inorder | [1, 2, 3, 4, 5, 6, 7] | SORTED (BST property) |
| Preorder | [4, 2, 1, 3, 6, 5, 7] | Root first (insertion order) |
| Postorder | [1, 3, 2, 5, 7, 6, 4] | Root last (children first) |
| BFS | [[4], [2,6], [1,3,5,7]] | Level by level |
| Task | Best Traversal |
|---|---|
| Get sorted values from BST | Inorder |
| Copy/reconstruct the tree | Preorder |
| Delete all nodes (free memory) | Postorder |
| Find minimum path between nodes | BFS (level-order) |
| Evaluate an expression tree | Postorder |
| Find all leaf nodes | Any DFS |
| Serialize/deserialize | BFS or preorder |
def inorder(node):
if node is None:
return
inorder(node.left)
visit(node.val)
inorder(node.right)n, all values in n.left subtree are strictly less than n.val, and all values in n.right subtree are strictly greater.BST property check at node 5: All values in left subtree (3, 1, 4) < 5. All values in right subtree (7, 6, 8) > 5.
from __future__ import annotations
from typing import Any, List, Optional, Tuple
class BSTNode:
"""Node for a Binary Search Tree."""
def __init__(self, val: Any) -> None:
self.val = val
self.left: Optional[BSTNode] = None
self.right: Optional[BSTNode] = None
class BST:
"""
Binary Search Tree.
Average-case O(log n) insert/search/delete for balanced trees.
Worst-case O(n) when tree degenerates (see Section 5).
Inorder traversal always returns a sorted sequence.
"""
def __init__(self) -> None:
self.root: Optional[BSTNode] = None
self._size: int = 0
# ------------------------------------------------------------------
# Insert
# ------------------------------------------------------------------
def insert(self, val: Any) -> None:
"""
Insert a value into the BST.
Navigates left if val < current, right if val > current.
Ignores duplicate values (set semantics).
O(h) where h is the tree height.
"""
self.root = self._insert_recursive(self.root, val)
def _insert_recursive(self, node: Optional[BSTNode], val: Any) -> BSTNode:
"""
Returns the (possibly new) root of the subtree.
THE KEY PATTERN: node.left = _insert(node.left, val)
— always reassign because when node is None we return a new BSTNode.
"""
if node is None:
self._size += 1
return BSTNode(val) # base case: found the insertion spot
if val < node.val:
node.left = self._insert_recursive(node.left, val)
elif val > node.val:
node.right = self._insert_recursive(node.right, val)
# val == node.val: duplicate — do nothing (BSTs typically store unique values)
return node
def insert_iterative(self, val: Any) -> None:
"""Iterative insert — avoids recursion overhead for very deep trees."""
new_node = BSTNode(val)
if self.root is None:
self.root = new_node
self._size += 1
return
current = self.root
while True:
if val < current.val:
if current.left is None:
current.left = new_node
self._size += 1
return
current = current.left
elif val > current.val:
if current.right is None:
current.right = new_node
self._size += 1
return
current = current.right
else:
return # duplicate
# ------------------------------------------------------------------
# Search
# ------------------------------------------------------------------
def search(self, val: Any) -> bool:
"""Return True if val is in the BST. O(h)."""
return self._search_recursive(self.root, val)
def _search_recursive(self, node: Optional[BSTNode], val: Any) -> bool:
if node is None:
return False
if val == node.val:
return True
if val < node.val:
return self._search_recursive(node.left, val)
return self._search_recursive(node.right, val)
def search_iterative(self, val: Any) -> bool:
"""Iterative search — O(h), O(1) space."""
current = self.root
while current is not None:
if val == current.val:
return True
current = current.left if val < current.val else current.right
return False
Which property must every node in a Binary Search Tree satisfy?
# ------------------------------------------------------------------
# Min and Max
# ------------------------------------------------------------------
def min_value(self) -> Any:
"""
Minimum value is always at the leftmost node.
Keep going left until left is None.
O(h).
"""
if self.root is None:
raise ValueError("BST is empty")
node = self.root
while node.left is not None:
node = node.left
return node.val
def max_value(self) -> Any:
"""Maximum is at the rightmost node. O(h)."""
if self.root is None:
raise ValueError("BST is empty")
node = self.root
while node.right is not None:
node = node.right
return node.val
def _min_node(self, node: BSTNode) -> BSTNode:
"""Helper: return the node with minimum value in a subtree."""
while node.left is not None:
node = node.left
return node
# ------------------------------------------------------------------
# Traversals
# ------------------------------------------------------------------
def inorder(self) -> List[Any]:
"""Returns a SORTED list of all values. O(n)."""
result: List[Any] = []
def _inorder(node: Optional[BSTNode]) -> None:
if node is None:
return
_inorder(node.left)
result.append(node.val)
_inorder(node.right)
_inorder(self.root)
return result
# ------------------------------------------------------------------
# Utilities
# ------------------------------------------------------------------
def __len__(self) -> int:
return self._size
def __contains__(self, val: Any) -> bool:
return self.search(val)
def __repr__(self) -> str:
return f"BST(inorder={self.inorder()})"| Operation | Average (balanced) | Worst Case (degenerate) |
|---|---|---|
| Search | O(log n) | O(n) |
| Insert | O(log n) | O(n) |
| Min / Max | O(log n) | O(n) |
| Inorder (all) | O(n) | O(n) |
1, 2, 3, 4, 5:This is just a linked list. Height = n - 1 = 4. Searching for 5: traverse 5 nodes -- O(n), not O(log n).
[3, 1, 4, 2, 5] — same values, different order:Height = 2. Searching for 5: 3 comparisons -- O(log n).
# In production Python, use sortedcontainers for a balanced sorted structure:
from sortedcontainers import SortedList
sl = SortedList([3, 1, 4, 1, 5, 9, 2, 6])
sl.add(7) # O(log n) insert maintaining sorted order
print(sl) # SortedList([1, 1, 2, 3, 4, 5, 6, 7, 9])
print(sl.bisect_left(5)) # O(log n) binary search → index 5
sl.discard(4) # O(log n) delete
print(sl[2]) # O(log n) index accessBuild BSTs interactively, watch all four traversal orders animate, and explore Trie prefix matching:
Tests · assert bst.inorder() == [1, 3, 4, 5, 6, 7, 8]; assert bst.search(4) == True; assert bst.search(9) == False
node.left = self._insert(node.left, val) — always reassign because the base case returns a fresh node, and always return node at the end of the helpersortedcontainers.SortedList when you need dependable O(log n) lookups in production Python codeYou perform an inorder traversal of a BST and get [1, 2, 3, 5, 7, 8]. Which value was most recently deleted from the original BST if it contained [1, 2, 3, 4, 5, 7, 8]?
| Edge |
| Connection between parent and child |
| A–B, A–C, B–D, B–E, C–F |
| Degree | Number of children a node has | A has degree 2; D has degree 0 |
| Height of a node | Longest path from that node down to a leaf | Height of B = 1; Height of A = 2 |
| Height of tree | Height of the root node | 2 |
| Depth of a node | Distance from root to that node | Depth of A = 0; Depth of D = 2 |
| Level | Set of nodes at the same depth | Level 0: ; Level 1: ; Level 2: |