Part 3 · Data Structures · Module 24

Trees & Binary Search Trees

Lists and hash tables are flat. Most interesting data is not: folders contain folders, HTML elements contain elements, and a sorted index must answer "everything between A and F" quickly. Trees are the structure behind all of these, and the binary search tree is the gateway to the balanced trees that power databases.

⏱ ~3 hoursPrerequisites: M19, M21, M22

What a tree is, and the words for it

A tree is a collection of nodes connected by edges such that there is exactly one path between any two nodes. One node is the root; every other node has exactly one parent. Nodes with the same parent are siblings, and a node with no children is a leaf (the others are internal nodes). Because each node has one parent, a tree can never contain a cycle; that single fact is what makes recursion on trees so natural. Every child is itself the root of a smaller tree, its subtree.

8310 16144 root (depth 0) internal, depth 1 leaf depth 3 → height of tree = 3 (edges on longest root→leaf path)
The depth of a node counts edges up to the root. The height of a node counts edges down to its deepest leaf; the tree's height is the root's height.
TermMeaning
Binary treeEvery node has at most two children, called left and right (order matters).
FullEvery node has 0 or 2 children, never exactly 1.
CompleteEvery level is full except possibly the last, which is filled left to right. Heaps (M25) are complete.
PerfectAll internal nodes have 2 children and all leaves share one depth: 2h+1 − 1 nodes.
Balanced (height-balanced)For every node, the heights of its two subtrees differ by at most 1. Height is then O(log n).
DegenerateEvery node has one child: the tree is a linked list in disguise, height n − 1.

Representing trees in Python

Python has no built-in tree type, so you choose a representation. The most flexible is a node class with references to children, exactly like the linked-list nodes from M21 but with two "next" pointers. A dataclass with slots=True keeps each node small.

Other representations trade flexibility for compactness:

Store a complete tree level by level in a list. The children of index i are at 2i + 1 and 2i + 2; the parent is at (i - 1) // 2. No pointers at all. Perfect for heaps, wasteful for sparse trees.

tree = [8, 3, 10, 1, 6, None, 14]
left_of_3 = tree[2 * 1 + 1]   # 1

Immutable and easy to print: (value, left, right). Good for small fixed trees and tests.

tree = (8, (3, (1, None, None), (6, None, None)), (10, None, (14, None, None)))

For n-ary trees with named nodes (org charts, file systems): map each node to a list of children, plus a parent map if you need to walk upward.

children = {"ceo": ["cto", "cfo"], "cto": ["dev1", "dev2"], "cfo": []}
parent = {c: p for p, kids in children.items() for c in kids}

What to remember: node objects for trees that change shape (BSTs, AVL trees); arrays for complete trees (heaps); dicts for named n-ary hierarchies.

Traversals: visiting every node

A traversal visits every node exactly once. Depth-first traversals differ only in when they process the current node relative to its subtrees:

Step through a small traversal and watch the call stack grow to the tree's height, then shrink. The frames panel is the recursion's implicit stack.

Iterative versions with an explicit stack

Recursion depth equals tree height. On a degenerate tree with 5,000 nodes, Python's default recursion limit (about 1,000) is exceeded and you get RecursionError. Replacing the implicit call stack with an explicit list removes that limit, and the technique (simulate the call stack yourself) works for any recursive algorithm.

Level order with a queue

Level-order (breadth-first) traversal visits the root, then all depth-1 nodes, then depth 2, and so on. Swap the stack for a FIFO queue: collections.deque from M22. Processing one level at a time (by remembering the queue's length) gives you the levels as separate lists.

What to remember: pre-order for copying, in-order for sorted output, post-order when children answer first, level order for "by depth" questions. Recursion uses O(h) stack; iteration avoids the recursion limit.

Binary search trees: search and insert

A binary search tree (BST) is a binary tree with an ordering invariant, the BST property: for every node, all keys in its left subtree are smaller and all keys in its right subtree are larger. Not just the children: the entire subtree. That invariant turns the tree into a decision procedure, the tree-shaped cousin of binary search: at each node, one comparison discards a whole subtree.

Each operation follows one root-to-leaf path, so it costs O(h). Insert values in the visualizer below, search for one, and run the traversals: in-order always comes out sorted.

Deleting from a BST: the three cases

Deletion is the operation people get wrong, because removing a node must leave the BST property intact. Find the node first, then:

1. Leaf: just remove it 2. One child: splice it out 3. Two children: use the successor 538 942 5386 delete 8: parent.right = None delete 4: 9.left = 2 delete 5: copy 6 up, delete old 6
Red: the node to delete. Green: the in-order successor (smallest key in the right subtree).
  1. Leaf: replace the parent's reference with None.
  2. One child: replace the node with its only child. The child's whole subtree was already on the correct side of the parent, so the property holds.
  3. Two children: find the in-order successor (go right once, then left as far as possible). It is the next larger key, so it can sit where the deleted node was: larger than everything on the left, smaller than everything else on the right. Copy its key up, then delete the successor from the right subtree; it has no left child, so that deletion is case 1 or 2. (The in-order predecessor works symmetrically.)

A recursive formulation is the cleanest: delete(node, key) returns the new root of the subtree, and each caller reattaches whatever comes back.

Degenerate trees and balancing

Every BST operation is O(h). The catch: h depends on insertion order. Insert 1, 2, 3, …, n into an empty BST and every new key goes to the right of the previous one. The "tree" is a linked list with height n − 1, and search is O(n). Sorted or nearly sorted input is common in practice (timestamps, auto-increment ids), so this is not a theoretical worry.

Random insertion gives expected height about 2.99 log₂ n, which is fine. Adversarial or sorted input does not. Self-balancing trees fix this by restructuring after each insert or delete so the height stays O(log n) no matter the order.

AVL trees and rotations

An AVL tree (Adelson-Velsky and Landis, 1962) stores each node's height and maintains the balance factor height(left) − height(right) in {−1, 0, 1}. After an insert, walk back up the path; at the first node whose balance factor becomes ±2, apply a rotation: a local pointer rearrangement that preserves the in-order sequence (so the BST property survives) while shortening the heavy side.

yxxy ABC ABC rotate right at y →← rotate left at x in-order: A x B y Cin-order: A x B y C
A rotation moves x up and y down; subtree B changes parent. The in-order sequence is unchanged, so it is still a valid BST.
Imbalance at node zShapeFix
Left-Leftz's left child is left-heavyrotate right at z
Right-Rightz's right child is right-heavyrotate left at z
Left-Rightz's left child is right-heavyrotate left at the child, then right at z
Right-Leftz's right child is left-heavyrotate right at the child, then left at z

Insert 10, 20, 30, 40, 50 in the AVL-mode visualizer below and watch it rotate where a plain BST would grow a chain. Then try the same keys in the plain BST above.

Red-black trees and B-trees

Red-black trees

Each node is red or black; no red node has a red child, and every root-to-leaf path has the same number of black nodes. That guarantees height ≤ 2 log₂(n + 1). Red-black trees are a little less strictly balanced than AVL trees but need fewer rotations on insert and delete, so they are the usual choice for library ordered maps: Java's TreeMap, C++'s std::map, and the Linux kernel's scheduler.

B-trees

A node holds many sorted keys (often hundreds) and has one more child than keys. The tree is very wide and very shallow: with 500 keys per node, three levels index 125 million rows. Databases (PostgreSQL, SQLite, MySQL InnoDB) and file systems use B-trees and B+ trees because each node is sized to one disk page, so a lookup costs about three or four page reads instead of the ~27 a binary tree would need.

Operation costs and Python's toolbox

OperationBST average (random keys)BST worst (degenerate)AVL / red-blackSorted list + bisect
searchO(log n)O(n)O(log n)O(log n)
insertO(log n)O(n)O(log n)O(n) (shifting)
deleteO(log n)O(n)O(log n)O(n)
min / maxO(log n)O(n)O(log n)O(1)
in-order iterationO(n)O(n)O(n)O(n)
range query (k results)O(log n + k)O(n)O(log n + k)O(log n + k)
spaceO(n) nodes, each with two pointers (plus height or color)O(n), compact

Python's standard library has no balanced tree. In practice you reach for:

Classic tree problems

Almost every tree problem is solved by one question: what does each node need from its children, and what does it return to its parent? Answer that and the recursion writes itself (usually post-order).

Height

Return −1 for None, else 1 + max(left, right).

Is balanced

Return the height, or a sentinel (−2) as soon as any subtree is unbalanced. One pass, O(n), instead of the O(n log n)–O(n²) "compute heights at every node" version.

Validate BST

Pass down an allowed (low, high) range. Checking only left.key < node.key < right.key is the famous wrong answer.

Lowest common ancestor

In a BST: walk from the root; the first node whose key lies between the two targets is the LCA. In a general tree: post-order, returning which targets were found.

Serialization

To save a tree to a file or send it over a network, flatten it to a string. Pre-order with explicit markers for missing children (#) is unambiguous: the parent comes first, so rebuilding is a single recursive pass that consumes tokens from an iterator.

N-ary trees: file systems and Python's own syntax tree

Trees with any number of children are everywhere. A directory is a node whose children are files and directories; the HTML of this page is a tree of elements (the DOM); JSON is a tree of dicts and lists. Store children in a list (or a dict keyed by name), and the traversals generalize directly: pre-order visits a node then each child; post-order visits each child then the node.

Python itself parses your source into an abstract syntax tree before compiling it. The ast module exposes that tree; linters such as ruff and type checkers such as mypy are, at heart, tree walkers.

Practice

Each exercise defines a small Node class for you. Tests build trees and check your function.

Write height(node) (edges on the longest root-to-leaf path; −1 for an empty tree, 0 for a single node) and size(node) (number of nodes).

Hint

Post-order: compute both children's answers, then combine. The base case for None carries the convention.

Show solution
class Node:
    def __init__(self, key, left=None, right=None):
        self.key, self.left, self.right = key, left, right

def height(node):
    if node is None:
        return -1
    return 1 + max(height(node.left), height(node.right))

def size(node):
    if node is None:
        return 0
    return 1 + size(node.left) + size(node.right)

Without recursion, write inorder(root) returning keys in in-order using an explicit stack, and level_order(root) returning a list of lists (one per depth) using collections.deque. Both return [] for an empty tree. A test uses a 3,000-node chain, so recursion will fail.

Hint

In-order: push nodes while going left; pop one, record it, then move to its right child. Level order: for _ in range(len(queue)) processes exactly one level.

Show solution
from collections import deque

class Node:
    def __init__(self, key, left=None, right=None):
        self.key, self.left, self.right = key, left, right

def inorder(root):
    out, stack, node = [], [], root
    while stack or node:
        while node:
            stack.append(node)
            node = node.left
        node = stack.pop()
        out.append(node.key)
        node = node.right
    return out

def level_order(root):
    if root is None:
        return []
    out, queue = [], deque([root])
    while queue:
        level = []
        for _ in range(len(queue)):
            node = queue.popleft()
            level.append(node.key)
            if node.left:
                queue.append(node.left)
            if node.right:
                queue.append(node.right)
        out.append(level)
    return out

Complete class BSTSet with add(key) (ignore duplicates), __contains__, remove(key) (raise KeyError if missing; handle all three cases), __len__, and __iter__ (sorted order).

Hint

Write a recursive helper _remove(node, key) that returns the new subtree root, and assign its result. Raise KeyError when you reach None; decrement the size only after a successful removal.

Show solution
class Node:
    __slots__ = ("key", "left", "right")
    def __init__(self, key):
        self.key, self.left, self.right = key, None, None

class BSTSet:
    def __init__(self):
        self.root = None
        self._size = 0

    def add(self, key):
        if self.root is None:
            self.root = Node(key)
            self._size += 1
            return
        node = self.root
        while True:
            if key == node.key:
                return
            if key < node.key:
                if node.left is None:
                    node.left = Node(key)
                    self._size += 1
                    return
                node = node.left
            else:
                if node.right is None:
                    node.right = Node(key)
                    self._size += 1
                    return
                node = node.right

    def __contains__(self, key):
        node = self.root
        while node:
            if key == node.key:
                return True
            node = node.left if key < node.key else node.right
        return False

    def remove(self, key):
        self.root = self._remove(self.root, key)
        self._size -= 1

    def _remove(self, node, key):
        if node is None:
            raise KeyError(key)
        if key < node.key:
            node.left = self._remove(node.left, key)
        elif key > node.key:
            node.right = self._remove(node.right, key)
        else:
            if node.left is None:
                return node.right
            if node.right is None:
                return node.left
            succ = node.right
            while succ.left:
                succ = succ.left
            node.key = succ.key
            node.right = self._remove(node.right, succ.key)
        return node

    def __len__(self):
        return self._size

    def __iter__(self):
        stack, node = [], self.root
        while stack or node:
            while node:
                stack.append(node)
                node = node.left
            node = stack.pop()
            yield node.key
            node = node.right

Write is_bst(root) (strictly increasing in-order; the whole-subtree property, not just parent/child) and is_balanced(root) (every node's subtree heights differ by at most 1) in a single O(n) pass each.

Hint

For is_bst, pass bounds down: left children get (lo, node.key), right children (node.key, hi). For balance, return the height or −2 as a "not balanced" sentinel, and stop as soon as you see it.

Show solution
class Node:
    def __init__(self, key, left=None, right=None):
        self.key, self.left, self.right = key, left, right

def is_bst(root, lo=float("-inf"), hi=float("inf")):
    if root is None:
        return True
    if not lo < root.key < hi:
        return False
    return is_bst(root.left, lo, root.key) and is_bst(root.right, root.key, hi)

def is_balanced(root):
    def check(node):
        if node is None:
            return -1
        left = check(node.left)
        if left == -2:
            return -2
        right = check(node.right)
        if right == -2 or abs(left - right) > 1:
            return -2
        return 1 + max(left, right)
    return check(root) != -2

Write lca_bst(root, a, b) returning the key of the lowest common ancestor of keys a and b in a BST (both are present; a node is its own ancestor). Then write serialize(root) and deserialize(text) so that serialize(deserialize(serialize(t))) == serialize(t) for any binary tree of ints.

Hint

LCA: if both keys are smaller than the node, go left; if both are larger, go right; otherwise you are at the split point. Serialization: pre-order with # for None, then rebuild by consuming an iterator of tokens.

Show solution
class Node:
    def __init__(self, key, left=None, right=None):
        self.key, self.left, self.right = key, left, right

def lca_bst(root, a, b):
    node = root
    while node:
        if a < node.key and b < node.key:
            node = node.left
        elif a > node.key and b > node.key:
            node = node.right
        else:
            return node.key
    return None

def serialize(root):
    out = []
    def walk(node):
        if node is None:
            out.append("#")
            return
        out.append(str(node.key))
        walk(node.left)
        walk(node.right)
    walk(root)
    return ",".join(out)

def deserialize(text):
    tokens = iter(text.split(","))
    def build():
        tok = next(tokens)
        if tok == "#":
            return None
        node = Node(int(tok))
        node.left = build()
        node.right = build()
        return node
    return build()

Mini-project: a BST-backed sorted map

Mini-projectTreeMap with range queries

Build TreeMap, an ordered mapping like Java's TreeMap or sortedcontainers.SortedDict, in treemap.py. Set it up with Pixi:

pixi init treemap && cd treemap
pixi add python=3.14 pytest
pixi run python -m pytest -q

Spec:

  • tm[key] = value, tm[key] (raises KeyError), del tm[key], key in tm, len(tm), and iteration over keys in sorted order. Subclass collections.abc.MutableMapping and you get get, items, pop, and update for free.
  • min_key(), max_key(), floor(key) (largest key ≤ key) and ceiling(key) (smallest key ≥ key), returning None when none exists.
  • range(lo, hi): yield (key, value) for lo <= key < hi in order, visiting only the O(log n + k) nodes that matter (prune subtrees that lie entirely outside the range).
  • TreeMap passes a property test: after 1,000 random set/delete operations, it agrees with a plain dict plus sorted()
  • Deleting a key with two children uses the in-order successor and moves the value along with the key
  • floor and ceiling are O(h) and handle keys below the minimum and above the maximum
  • range prunes: a counter shows it visits far fewer than n nodes for a narrow range
  • Type hints throughout (class TreeMap[K, V](MutableMapping[K, V])) and Google-style docstrings

Stretch goals: add AVL balancing and assert the height stays ≤ 1.44 log₂(n + 2) on sorted inserts; add rank(key) (number of keys smaller than key) by storing subtree sizes; benchmark against bisect on a sorted list with timeit.

Quiz

Recap

Going further