Data Structures/Binary Search Trees

Lesson 4.33,961 words

Binary Search Trees

A binary search tree keeps keys ordered so that every operation follows a single root-to-leaf path. We state the BST property, trace search, insert, successor, and all three delete cases on concrete trees, prove the inorder walk sorts, and note the drawback — every operation costs O(h)O(h), and a carelessly built tree degrades to height h=Θ(n)h = \Theta(n), motivating balance.

╌╌╌╌

A hash table gives expected lookups but throws away order: it cannot tell you the smallest key, the next key after a given one, or every key in a range. A binary search tree (BST) keeps those queries fast by storing keys in a shape that records their order. Each node holds a key and pointers to a left child, a right child, and a parent; the keys are arranged so that the tree itself is a kind of decision diagram for searching. The result is a dynamic ordered dictionary supporting search, insert, delete, minimum, maximum, predecessor, successor, and in-order traversal, every one of them in time proportional to the tree's height.

The binary search tree property

The arrangement is governed by one local invariant, checked at every node :

Smaller keys live to the left, larger keys to the right, everywhere, not just between a node and its immediate children.1 This recursive constraint is what lets a search discard half the tree at each step.

A binary search tree with smaller keys left and larger keys right

Reading this tree: the root is ; everything in its left subtree () is and everything in its right subtree () is , and the same holds recursively at and .

Searching

To search for a key , start at the root and walk down. At each node, if equals the node's key we are done; if is smaller we go left, otherwise we go right. Each comparison drops us one level, so the search traces a single root-to-leaf path.

Algorithm:Tree-Search(x,k)\textsc{Tree-Search}(x, k) — find key kk in the subtree rooted at xx
  1. 1
    if x=nilx = \text{nil} or k=key(x)k = key(x) then
  2. 2
    return xx
  3. 3
    if k<key(x)k < key(x) then
  4. 4
    return call Tree-Search(left(x),k)\textsc{Tree-Search}(left(x), k)
    k is in the left subtree
  5. 5
    else
  6. 6
    return call Tree-Search(right(x),k)\textsc{Tree-Search}(right(x), k)
    k is in the right subtree

The procedure is correct by the BST property: when , the property guarantees cannot be in 's right subtree, so discarding it loses nothing. The search visits one node per level and runs in time, where is the height of the tree.2

Searching for . At we go left (); at we go right (); at we stop. The path (accent) visits one node per level, and each step discards an entire subtree (dashed), so the search examines just of the nodes.

An unsuccessful search behaves the same way. Searching for in this tree compares (go left), (go right), (go left), and finds 's left child is nil: the key is absent, and the search reports it after comparisons, never more. The nil that ends a failed search is not wasted information: it marks where the key would go, which is the observation insertion is built on.

and are the degenerate cases of search: follow left pointers until they run out to reach the smallest key, or right pointers for the largest.

Algorithm:Tree-Minimum(x)\textsc{Tree-Minimum}(x) and Tree-Maximum(x)\textsc{Tree-Maximum}(x)
  1. 1
    Tree-Minimum(x):
  2. 2
    while left(x)nilleft(x) \ne \text{nil} do
  3. 3
    xleft(x)x \gets left(x)
    min is the leftmost node
  4. 4
    return xx
  5. 5
    Tree-Maximum(x):
  6. 6
    while right(x)nilright(x) \ne \text{nil} do
  7. 7
    xright(x)x \gets right(x)
    max is the rightmost node
  8. 8
    return xx

Inserting

Insertion reuses the search path. To insert key , walk down as if searching for it; when the walk falls off the bottom of the tree (reaches a nil child), that empty spot marks where belongs, preserving the BST property. We attach a new leaf there, remembering the parent so we can hook it in.

Algorithm:Tree-Insert(T,z)\textsc{Tree-Insert}(T, z) — insert node zz (with key(z)key(z) set) into BST TT
  1. 1
    ynily \gets \text{nil}
    y trails x
  2. 2
    xroot(T)x \gets root(T)
  3. 3
    while xnilx \ne \text{nil} do
  4. 4
    yxy \gets x
  5. 5
    if key(z)<key(x)key(z) < key(x) then
  6. 6
    xleft(x)x \gets left(x)
  7. 7
    else
  8. 8
    xright(x)x \gets right(x)
  9. 9
    parent(z)yparent(z) \gets y
  10. 10
    if y=nily = \text{nil} then
  11. 11
    root(T)zroot(T) \gets z
    tree was empty
  12. 12
    else if key(z)<key(y)key(z) < key(y) then
  13. 13
    left(y)zleft(y) \gets z
  14. 14
    else
  15. 15
    right(y)zright(y) \gets z

Like search, insertion walks one root-to-leaf path and costs . New keys always enter as leaves, which keeps insertion simple but also lets the tree's shape degrade, as shown below.

Tracing with on our running tree: starts at the root with . Since , the trailing pointer moves to and descends left to . Since , moves to and descends right to . Since , moves to and descends left, to nil. The loop exits with ; because , the new node becomes 's left child. Three comparisons, one pointer assignment, done.

Inserting : the search path (accent) compares , , and falls off at 's empty left child — the unique spot where preserves the BST property. The new node attaches there as a leaf (dashed).

Finding a successor

The successor of a node is the node with the smallest key greater than , the next key in sorted order. There are two cases, and neither needs a comparison of keys, only structure:

  • If has a right subtree, the successor is the minimum of that subtree: the smallest key still larger than .
  • If has no right subtree, the successor is the lowest ancestor whose left child is also an ancestor of ; we climb up until we move up a left link.
Algorithm:Tree-Successor(x)\textsc{Tree-Successor}(x) — next node in sorted order
  1. 1
    if right(x)nilright(x) \ne \text{nil} then
  2. 2
    return call Tree-Minimum(right(x))\textsc{Tree-Minimum}(right(x))
    min of right subtree
  3. 3
    yparent(x)y \gets parent(x)
  4. 4
    while ynily \ne \text{nil} and x=right(y)x = right(y) do
  5. 5
    xyx \gets y
    climb while x is a right child
  6. 6
    yparent(y)y \gets parent(y)
  7. 7
    return yy

Both cases follow a single vertical path, down into the right subtree or up through ancestors, so also runs in . Predecessor is the mirror image (left subtree's maximum, or climb until a right link).

Trace both cases on the running tree. For (case 1): is non-nil, so we return of the subtree at : descend left from to , and has no left child, so the successor is . Correct: is the smallest key exceeding . For (case 2): has no right subtree, so we climb. First iteration: and , so , . Second test: , not a right child, so the loop stops and returns , the first ancestor reached by moving up-and-right, which is precisely the smallest key greater than everything in 's subtree. The climb can also run off the top: for the loop ascends (each a right child of its parent) and exits with ; is the maximum and has no successor. A predecessor trace mirrors this: for , which has no left subtree, we climb while is a left child (, so ), then stop because , returning .

The two successor cases. Left: has a right subtree, so its successor is that subtree's minimum, (descend left from ). Right: has no right subtree, so climb until moving up a left link — — giving successor .

Deleting a node

Deletion is the one operation that needs care, because removing an internal node leaves a hole that must be filled without disturbing the BST property. There are three cases, in increasing difficulty:

  1. has no children: just detach it from its parent.
  2. has one child: splice that child into 's position.
  3. has two children: 's successor is the minimum of its right subtree, so has no left child. Move into 's position; if was not 's direct child, first replace by its own right child.

All three reduce to a single primitive, , which replaces the subtree rooted at with the subtree rooted at :

Algorithm:Transplant(T,u,v)\textsc{Transplant}(T, u, v) — put subtree vv where subtree uu was
  1. 1
    if parent(u)=nilparent(u) = \text{nil} then
  2. 2
    root(T)vroot(T) \gets v
  3. 3
    else if u=left(parent(u))u = left(parent(u)) then
  4. 4
    left(parent(u))vleft(parent(u)) \gets v
  5. 5
    else
  6. 6
    right(parent(u))vright(parent(u)) \gets v
  7. 7
    if vnilv \ne \text{nil} then
  8. 8
    parent(v)parent(u)parent(v) \gets parent(u)
Algorithm:Tree-Delete(T,z)\textsc{Tree-Delete}(T, z) — remove node zz from the BST
  1. 1
    if left(z)=nilleft(z) = \text{nil} then
  2. 2
    call Transplant(T,z,right(z))\textsc{Transplant}(T, z, right(z))
    lift the right child
  3. 3
    else if right(z)=nilright(z) = \text{nil} then
  4. 4
    call Transplant(T,z,left(z))\textsc{Transplant}(T, z, left(z))
    lift the left child
  5. 5
    else
  6. 6
    yy \gets call Tree-Minimum(right(z))\textsc{Tree-Minimum}(right(z))
    successor, no left child
  7. 7
    if parent(y)zparent(y) \ne z then
  8. 8
    call Transplant(T,y,right(y))\textsc{Transplant}(T, y, right(y))
    detach y, lift its right child
  9. 9
    right(y)right(z)right(y) \gets right(z)
  10. 10
    parent(right(y))yparent(right(y)) \gets y
  11. 11
    call Transplant(T,z,y)\textsc{Transplant}(T, z, y)
    y into z's slot
  12. 12
    left(y)left(z)left(y) \gets left(z)
  13. 13
    parent(left(y))yparent(left(y)) \gets y

The first two cases are pure pointer splices, and both are handled by the same two branches of : when is nil we transplant into 's place (this covers the leaf case too, transplanting nil), and symmetrically when is nil. Concretely, in the tree below, deleting the leaf calls : since , the assignment detaches it and nothing else moves. Deleting , which has only the child , calls : since , we set and , and rises one level with its subtree intact; every key in it is still , so the BST property holds.

The two easy delete cases. Top: the leaf (shaded) is detached by pointing its parent's child link at nil. Bottom: has one child, so that child (, accent) is spliced into 's position, carrying its whole subtree with it.

The two-child case is the subtle one. Replacing by its successor keeps every key in 's left subtree below the new root and every key in the right subtree above it, so the ordering survives:

Deleting a node with two children: its successor (the minimum of the right subtree, here ) takes its place, and the successor's own right child () fills the slot it vacated.

Follow the pointer surgery step by step. We delete , the root. Both children exist, so the third branch runs: (from , one step left, then no further). Here , so the inner fix-up fires first: lifts 's right child into 's old slot (), then and hand 's entire right subtree to . Now makes the root, and , attach the untouched left subtree. The result is the tree on the right: sits where was, sits where was, and every ordering relation still holds because was the smallest key in the right subtree: everything remaining there is larger, and everything on the left was already smaller. When the successor is 's direct child (), the inner fix-up is skipped: 's right subtree is already in the correct position relative to , and the final transplant alone suffices. Why the successor and not some other key? Only 's successor or predecessor can replace without reordering: the replacement must be larger than all of 's left subtree and smaller than all of its right subtree except itself, and the successor (minimum of the right subtree) is one of exactly two keys with that property.

Each branch does a constant amount of pointer surgery plus at most one call, so runs in like the rest.

The order is already there: inorder walk

Because the BST property sorts keys left-to-right at every node, visiting the tree in order — left subtree, then the node, then right subtree — emits the keys in increasing order.3

Algorithm:Inorder-Walk(x)\textsc{Inorder-Walk}(x) — print the subtree at xx in sorted order
  1. 1
    if xnilx \ne \text{nil} then
  2. 2
    call Inorder-Walk(left(x))\textsc{Inorder-Walk}(left(x))
    smaller keys first
  3. 3
    print key(x)key(x)
  4. 4
    call Inorder-Walk(right(x))\textsc{Inorder-Walk}(right(x))
    then larger keys

The walk visits each of nodes once, so it runs in time. This gives a clean way to read out a sorted sequence, and shows that a BST is, in effect, a dynamic sorted list you can also splice into and search.

The catch: height is everything

Every operation above costs . So the BST is fast exactly when is small. The best case is a balanced tree, where the two subtrees of each node have nearly equal size; then and every operation is .

The worst case is a disaster. Suppose we insert keys in sorted order: . Each new key is larger than everything present, so it walks all the way right and attaches as the rightmost leaf. The tree degenerates into a single descending path, a glorified linked list:

Sorted insertions degenerate a BST into a path of height

Now , and search, insert, and successor all degrade to , no better than scanning an unsorted array.4 The very flexibility that made insertion easy (new keys land as leaves wherever the path takes them) lets an unlucky or adversarial insertion order ruin the shape. And sorted input is not a contrived adversary: it is one of the most common inputs in practice: keys read from a sorted file, timestamps arriving in order, sequential IDs from a database. Reverse-sorted input produces the mirror-image left path, and nearly-sorted input produces a tree that is nearly a path. Building a BST naively from data that happens to be ordered is a classic performance bug: the code is correct, the tests pass on small shuffled inputs, and production slows to a crawl.

How bad is a typical tree, as opposed to a worst-case one? There is a positive result here, with an important caveat about what typical means. Call a BST randomly built if it results from inserting distinct keys in uniformly random order into an empty tree.

So if insertion order were genuinely random, plain BSTs would be fine on average: the expected height is within a constant factor of the optimal (the constant in the known bounds is roughly ). The caveats: first, the theorem randomizes over insertion orders, not over tree shapes; it is a statement about a random process, and real inputs (sorted, nearly sorted, adversarial) need not look anything like a random permutation. Second, the guarantee is only in expectation and says nothing once deletions mix into the workload; the classical analysis covers insertion-only sequences. Randomized structures such as treaps enforce the random-order behavior regardless of the actual arrival order, which is one principled fix. The other is to enforce balance structurally.

This is the central tension of binary search trees:

We cannot control the order in which keys arrive. So the fix is to make the tree rebalance itself as keys come and go, forcing no matter what. Randomized BSTs (and treaps) achieve height in expectation; balanced search trees — red-black trees, AVL trees, B-trees — guarantee height in the worst case by maintaining extra structural invariants and repairing them after each update. That repair machinery is the subject of the next lesson.

binary_search_tree.pypython
from collections.abc import Iterator
from typing import Generic, Optional, TypeVar

from comparable import Comparable

Key = TypeVar("Key", bound=Comparable)

class BSTNode(Generic[Key]):
  """
    One tree node: its key plus links to its left child, right child,\n
    and parent. A link is None where the corresponding node is absent;\n
    the root is the unique node whose parent is None.\n
  """

  def __init__(self, key: Key) -> None:
    self.key: Key = key
    self.left: Optional[BSTNode[Key]] = None
    self.right: Optional[BSTNode[Key]] = None
    self.parent: Optional[BSTNode[Key]] = None

  def __repr__(self) -> str:
    return f"BSTNode({self.key!r})"

class BinarySearchTree(Generic[Key]):
  """
    An ordered dictionary of keys kept under the BST property.\n
    Supports search, insert, delete, minimum, maximum, predecessor,\n
    successor, and an inorder walk that yields the keys in sorted order.\n
    Every update keeps the structural invariant; performance is O(height).\n
  """

  def __init__(self) -> None:
    self.root: Optional[BSTNode[Key]] = None
    self._size: int = 0

  def search(self, key: Key) -> Optional[BSTNode[Key]]:
    """
      The node carrying `key`, or None if no such node exists.\n
      Walk down from the root: stop on a match, go left when `key` is\n
      smaller, right when larger. One node per level, so O(h).\n
    """
    # branch left or right by comparison until the key or a dead end.
    current: Optional[BSTNode[Key]] = self.root
    while current is not None and key != current.key:
      if key < current.key:
        current = current.left
      else:
        current = current.right

    return current

  def __contains__(self, key: Key) -> bool:
    return self.search(key) is not None

  @staticmethod
  def minimum(node: BSTNode[Key]) -> BSTNode[Key]:
    """
      The node with the smallest key in the subtree rooted at `node`:\n
      follow `left` links until they run out.\n
    """
    current: BSTNode[Key] = node
    while current.left is not None:
      current = current.left
    return current

  @staticmethod
  def maximum(node: BSTNode[Key]) -> BSTNode[Key]:
    """
      The node with the largest key in the subtree rooted at `node`:\n
      follow `right` links until they run out.\n
    """
    current: BSTNode[Key] = node
    while current.right is not None:
      current = current.right
    return current

  def min_key(self) -> Optional[Key]:
    """
      The smallest key in the whole tree, or None if it is empty.\n
    """
    return None if self.root is None else self.minimum(self.root).key

  def max_key(self) -> Optional[Key]:
    """
      The largest key in the whole tree, or None if it is empty.\n
    """
    return None if self.root is None else self.maximum(self.root).key

  def insert(self, key: Key) -> BSTNode[Key]:
    """
      Insert `key` as a new leaf, preserving the BST property.\n
      Trace the search path with a trailing pointer to the parent;\n
      when the walk falls off the tree, hook the leaf in there. O(h).\n
    """
    node: BSTNode[Key] = BSTNode(key)
    parent: Optional[BSTNode[Key]] = None
    current: Optional[BSTNode[Key]] = self.root

    # descend to the empty slot where `key` belongs, trailing the parent.
    while current is not None:
      parent = current
      if key < current.key:
        current = current.left
      else:
        current = current.right

    # hook the new leaf onto the side of `parent` it belongs on.
    node.parent = parent
    if parent is None:  # tree was empty
      self.root = node
    elif key < parent.key:
      parent.left = node
    else:
      parent.right = node

    self._size += 1
    return node

  def successor(self, node: BSTNode[Key]) -> Optional[BSTNode[Key]]:
    """
      The node with the smallest key greater than `node`'s, or None if\n
      `node` holds the maximum. With a right subtree, it is that\n
      subtree's minimum; otherwise climb until moving up a left link.\n
    """
    if node.right is not None:
      return self.minimum(node.right)

    # climb while `node` is a right child; the first left turn is the answer.
    current: BSTNode[Key] = node
    ancestor: Optional[BSTNode[Key]] = node.parent
    while ancestor is not None and current is ancestor.right:
      current = ancestor
      ancestor = ancestor.parent
    return ancestor

  def predecessor(self, node: BSTNode[Key]) -> Optional[BSTNode[Key]]:
    """
      The mirror of `successor`: the node with the largest key smaller\n
      than `node`'s, or None if `node` holds the minimum.\n
    """
    if node.left is not None:
      return self.maximum(node.left)

    current: BSTNode[Key] = node
    ancestor: Optional[BSTNode[Key]] = node.parent
    while ancestor is not None and current is ancestor.left:
      current = ancestor
      ancestor = ancestor.parent
    return ancestor

  def _transplant(
    self,
    target: BSTNode[Key],
    replacement: Optional[BSTNode[Key]],
  ) -> None:
    """
      Put the subtree `replacement` where the subtree `target` was,\n
      rewiring `target`'s parent. Does not touch `replacement`'s own\n
      children; the caller fixes those up.\n
    """
    # point `target`'s parent (or the root link) at `replacement`.
    parent: Optional[BSTNode[Key]] = target.parent
    if parent is None:  # target was the root
      self.root = replacement
    elif target is parent.left:
      parent.left = replacement
    else:
      parent.right = replacement

    # and give `replacement` its new parent back-pointer.
    if replacement is not None:
      replacement.parent = parent

  def delete(self, key: Key) -> bool:
    """
      Remove the node carrying `key`. Returns False if it is absent.\n
      No child: detach. One child: splice it up. Two children: the\n
      successor (right subtree's minimum, which has no left child) takes\n
      the slot. O(h).\n
    """
    node: Optional[BSTNode[Key]] = self.search(key)
    if node is None:
      return False
    self._delete_node(node)
    self._size -= 1
    return True

  def _delete_node(self, node: BSTNode[Key]) -> None:
    """
      Excise `node` from the tree via at most one Transplant plus a\n
      constant amount of pointer surgery.\n
    """
    # fewer than two children: splice the lone subtree (or None) up.
    if node.left is None:  # 0 or 1 (right) child
      self._transplant(node, node.right)
      return
    if node.right is None:  # exactly one (left) child
      self._transplant(node, node.left)
      return

    # two children: the successor (right subtree's min) takes the slot.
    successor: BSTNode[Key] = self.minimum(node.right)

    # if it sits deeper, detach it first, lifting its right child up.
    if successor.parent is not node:
      self._transplant(successor, successor.right)
      successor.right = node.right
      successor.right.parent = successor

    # move the successor into `node`'s place, inheriting its left subtree.
    self._transplant(node, successor)
    successor.left = node.left
    successor.left.parent = successor

  def inorder(self) -> Iterator[Key]:
    """
      Yield every key in nondecreasing order — left subtree, node, then\n
      right subtree. Visits each node once, so Theta(n).\n
    """

    def walk(node: Optional[BSTNode[Key]]) -> Iterator[Key]:
      if node is not None:
        yield from walk(node.left)
        yield node.key
        yield from walk(node.right)

    yield from walk(self.root)

  def height(self) -> int:
    """
      The number of edges on the longest root-to-leaf path; -1 for an\n
      empty tree, 0 for a single node. Operations cost O(height).\n
    """

    def depth(node: Optional[BSTNode[Key]]) -> int:
      if node is None:
        return -1
      return 1 + max(depth(node.left), depth(node.right))

    return depth(self.root)

  def __iter__(self) -> Iterator[Key]:
    return self.inorder()

  def __len__(self) -> int:
    return self._size
comparable.pypython
from typing import Any, Protocol, TypeVar


class Comparable(Protocol):
  """
    Anything orderable with `<` (int, float, str, tuple, date, …).\n
  """

  # `other` is position-only so built-ins (int, str, …), whose dunder
  # operands are position-only, structurally satisfy the protocol.
  def __lt__(self, other: Any, /) -> bool: ...
  def __gt__(self, other: Any, /) -> bool: ...
  def __le__(self, other: Any, /) -> bool: ...
  def __ge__(self, other: Any, /) -> bool: ...

Augmenting the tree

A BST is not only a sorted set; once you hang extra information on each node, the same walk answers much richer queries. The general method, from CLRS's chapter on augmenting data structures, is to store a small summary in each node that can be recomputed from a node and its two children in , so rotations still repair it cheaply.

Order-statistic trees. Store in each node the size of its subtree. Then two new queries run in : select, find the -th smallest key, and rank, count how many keys are . Select descends by comparing against the left subtree's size; rank accumulates left-subtree sizes along the search path. This augmentation is what the LeetCode problem Kth Smallest Element in a BST wants, and what a balanced order-statistic tree gives in , the LeetCode Count of Smaller Numbers After Self is the same augmentation applied online. As a concrete trace: in a tree holding with at the root (left subtree size ), sees , subtracts the keys at-or-left of the root, and recurses for the st smallest in the right subtree , landing on .

Interval and other summaries. Store the maximum endpoint in each subtree and the tree answers "does any stored interval overlap ?" in , the interval tree, taken up in Spatial Data Structures. Store subtree sums and you get the ordered analogue of a Fenwick tree. The lesson is that a self-balancing BST is a substrate: rank/select, dynamic order statistics, and stabbing queries are all one augmentation away, which is why balanced BSTs, not hash tables, back the ordered-map type (std::map, Java TreeMap) in standard libraries.6

Takeaways

  • A binary search tree stores keys under the BST property (left subtree node right subtree, recursively), so searching follows one root-to-leaf path.
  • Search, insert, minimum, maximum, successor, predecessor all walk a single vertical path and cost ; new keys enter as leaves.
  • Delete has three cases — leaf, one child, two children — all built on the splice; the two-child case moves the successor into the deleted node's place, which preserves ordering because the successor is the minimum of the right subtree.
  • An inorder walk emits the keys in sorted order in time; the order is baked into the shape.
  • Performance hinges entirely on height: when balanced, but for a degenerate (e.g. sorted-insertion) tree. A randomly built BST has expected height , but that assumes random insertion order and no deletions; real inputs offer no such promise.
  • Because we cannot control insertion order, we need trees that rebalance themselves to guarantee , the motivation for balanced search trees.

Footnotes

  1. CLRS, Ch. 12 — Binary Search Trees (§12.1): the BST property as a recursive ordering invariant.
  2. Skiena, §3.4 — Binary Search Trees: search along a single root-to-leaf path in time.
  3. Erickson, Ch. — Binary Search Trees: an inorder traversal emits the keys in sorted order.
  4. CLRS, Ch. 12 — Binary Search Trees (§12.4): operations cost , degrading to for an unbalanced tree.
  5. CLRS, Ch. 12 — Binary Search Trees (§12.4, Theorem 12.4): a randomly built BST on distinct keys has expected height ; the analysis assumes insertions only, in uniformly random order.
  6. CLRS, Ch. 14 — Augmenting Data Structures: order-statistic trees (subtree sizes for select/rank) and interval trees (subtree max endpoint), and the general rule for augmenting a red-black tree with recomputable summaries.
Practice

╌╌ END ╌╌