Data Structures/Balanced Search Trees

Lesson 4.54,327 words

Balanced Search Trees

An ordinary BST can degrade to height Θ(n)\Theta(n); balanced search trees guarantee h=O(logn)h = O(\log n) by maintaining invariants and repairing them after every update. We meet rotations, the local restructuring primitive, then red-black trees, whose color invariants force logarithmic height, and finally B-trees, which trade tall-and-thin for short-and-wide to win on disk.

╌╌╌╌

The previous lesson left us with a sharp problem. A binary search tree does everything in time, which is when the tree is balanced and when it is not, and we cannot control the order in which keys arrive. A balanced search tree removes the dependence on luck. It maintains a structural invariant strong enough to force , and after every insertion or deletion it does a small amount of rebalancing to restore that invariant.1 The guarantee becomes worst-case, not best-case: per operation, on every input.

Different balanced trees enforce different invariants. AVL trees bound the height difference of sibling subtrees, red-black trees color the nodes, and B-trees pack many keys per node, but they all share one mechanical primitive for reshaping the tree without disturbing its sorted order: the rotation.

Rotations: local surgery that preserves order

A rotation rearranges three pointers to change a tree's shape, and hence its height, while keeping the BST property intact. A right rotation at a node makes 's left child the new subtree root, with becoming 's right child; a left rotation is the exact inverse. The subtree that was in the middle (call it ) switches parents but stays between and in key order.

Left and right rotations reshape a BST while preserving sorted order

In both trees the sorted order is — a rotation never violates the BST property.2 What it does change is height: rotating can pull a deep subtree up a level and push a shallow one down — the mechanism a balanced tree uses to repair itself.

Algorithm:Left-Rotate(T,x)\textsc{Left-Rotate}(T, x) — pivot xx down-left, its right child yy up
  1. 1
    yright(x)y \gets right(x)
  2. 2
    right(x)left(y)right(x) \gets left(y)
    T2T_2 becomes x's right child
  3. 3
    if left(y)nilleft(y) \ne \text{nil} then
  4. 4
    parent(left(y))xparent(left(y)) \gets x
  5. 5
    parent(y)parent(x)parent(y) \gets parent(x)
    splice y where x was
  6. 6
    if parent(x)=nilparent(x) = \text{nil} then
  7. 7
    root(T)yroot(T) \gets y
  8. 8
    else if x=left(parent(x))x = left(parent(x)) then
  9. 9
    left(parent(x))yleft(parent(x)) \gets y
  10. 10
    else
  11. 11
    right(parent(x))yright(parent(x)) \gets y
  12. 12
    left(y)xleft(y) \gets x
    x hangs under y
  13. 13
    parent(x)yparent(x) \gets y

A rotation touches only a constant number of pointers, so it runs in time. Every balanced-tree rebalancing operation is built from a handful of rotations (plus, for red-black trees, recolorings) along a single root-to-leaf path, hence work to repair the tree after an update.

For a concrete instance, suppose has left child , and 's children are (keys ) and (keys in ), while 's right child is (keys ). A right rotation at promotes to the subtree root: becomes 's right child, (the middle subtree) detaches from and reattaches as 's left child, and , stay put. Reading the keys left to right before the rotation, ; after it, , unchanged. Only three pointers moved ('s right, 's left, and the parent link that now points at ), yet the node that was at depth () is now at depth and has dropped one level, the height change a balanced tree exploits.

Red-black trees: balance by color

A red-black tree is a BST in which every node carries one extra bit, its color — red or black.3 Five invariants on those colors squeeze the tree into logarithmic height:

Properties 4 and 5 are what force balance. Property 5 says all root-to-leaf paths have the same number of black nodes; property 4 says reds cannot cluster, so reds can at most double a path's length by interleaving. Together they bound how lopsided the tree can get.

The black-height of a node is the number of black nodes on any path from down to a leaf, not counting itself; property 5 asserts precisely that this number is well defined. Consider a concrete tree:

A red-black tree with black-heights annotated. Every root-to-nil path contains exactly two black nodes (counting the black nil leaves, not the starting node), so at the root even though the paths differ in length: reds pad some paths but never change the black count.

Check property 5 at the root: the path crosses two black nodes ( and nil), and so does the longer path ( and nil). The red nodes , , , stretch some paths without contributing to any black count, and property 4 guarantees they never appear twice in a row, which is what caps the stretching at a factor of two.

Why the height is

The argument is short and worth seeing, because it explains why the color rules take exactly this form. Let be the black-height of node (the number of black nodes on any path from down to a leaf, not counting ).

Now let be the tree's height. By property 4 at least half the nodes on any root-to-leaf path are black, so the root's black-height is at least . Applying the lemma at the root with internal nodes,

So every operation that walks the tree (search, insert, delete, successor) runs in worst-case time, provided updates preserve the invariants. That is the job of the fix-up procedures.

Insertion: recolor when you can, rotate when you must

starts as an ordinary BST insertion: walk down to a leaf position and attach the new node there. Then color red. The choice of color is deliberate. A red adds zero to every black count, so property 5 survives untouched; the only properties at risk are property 2 (if is the root) and property 4 (if 's parent happens to be red). Coloring black instead would add one black node to exactly the paths through and break property 5 — a global violation that is much harder to localize. So insertion trades a possible global breakage for a possible local one, and repairs the local one by walking up the tree.4

The fix-up loop runs while 's parent is red. Write for the parent, for the grandparent (which must be black, since is red and the tree was valid before), and for the uncle's other child. Assume is 's left child; the other orientation mirrors every case left-for-right. Exactly three cases arise:

Case 1 (red uncle): recolor and climb. If is red, paint and black and red, with no rotation at all. Locally, no two reds is restored; globally, every path through still crosses the same number of blacks, because the black that left reappears one level down on both sides. But is now red, so if its parent is also red the violation has moved two levels up. Set and repeat.

Insert-fixup Case 1 — a red uncle lets us recolor instead of rotate, pushing the violation up to grandparent

Case 2 (black uncle, zig-zag): straighten. If is black and is 's right child, the red pair forms a bent path (left, then right). A left rotation at straightens it: the old rises, the old drops to become its left child, and both are still red. Nothing is fixed yet — this move exists only to convert the configuration into Case 3, with renamed to the node that is now the lower red.

Case 3 (black uncle, zig-zig): rotate and finish. Now is 's left child and the two reds lie on a straight line. Right-rotate at , then swap the colors of and : the subtree's new root is black, its children and are red. Every path that used to pass through black now passes through black instead, so all black counts are preserved, and the new subtree root is black, so no red-red pair can exist at its top. The loop terminates.

Insert-fixup Cases 2 and 3, uncle black. A left rotation at straightens the zig-zag into a zig-zig; a right rotation at plus a color swap then ends the fix-up with a black subtree root. Labels track the code's renaming: after the first rotation, again names the lower red and its parent.

After the loop ends, one line remains: color the root black. If the loop pushed the red up to the root (or the very first insertion created a red root), this restores property 2 without disturbing property 5, since adding a black at the root adds one to every path's count equally.

The cost accounting: each Case 1 iteration is recoloring and lifts two levels, so there are at most of them; Cases 2 and 3 execute at most once each and end the loop. An insertion therefore performs recolorings but at most two rotations. Deletion has a symmetric (if fussier) fix-up with four cases per side and at most three rotations, also overall; CLRS works through it in full.5

A worked trace

Insert the keys into an empty tree, in that order. Every fix-up case appears exactly once.

  1. Insert . The tree was empty, so becomes the root; the final recolor-the-root step paints it black.
  2. Insert . It lands as the red right child of black . No red parent, no violation.
  3. Insert . It lands as the red right child of red : a violation. The uncle ('s left child) is nil, hence black, and lies on a straight line with under — the mirror of Case 3. Left-rotate at and swap colors: (black) is the new root with red children and .
  4. Insert . It lands as the red right child of red . The uncle is red, so Case 1 applies. Recolor: and turn black, turns red, and jumps to . The loop exits ( has no parent) and the final line repaints the root black. No rotation.
  5. Insert . It descends and lands as the red left child of red . The uncle (left child of ) is nil, hence black, and the reds form a bent path under , the mirror of Case 2. Right-rotate at : now is the lower parent with below it, a straight line. The mirror of Case 3 finishes: left-rotate at , swap colors, and the subtree root is black with red children and .
The trace at four checkpoints. Insert triggers a rotation (Case 3), insert a pure recolor (Case 1), insert the double rotation (Case 2 then Case 3). Black-heights stay equal on every path throughout.

Check the final tree against the invariants: the root is black; the reds and have only (nil) black children; and every root-to-nil path crosses exactly two black nodes. Height for keys, comfortably inside the bound.

The contrast is clearest on the worst possible input for a plain BST: keys arriving already sorted. A naive tree threads them into a single descending path of height ; the red-black invariants instead rebalance on the fly into a bushy tree of height , with every root-to-leaf path carrying the same black-height.

The same keys inserted in order: a plain BST degenerates to height , a red-black tree stays at height with equal black-heights

Red-black versus AVL

AVL trees reach the same guarantee with a different invariant: the heights of any node's two subtrees differ by at most , enforced by rotations. The two invariants trade against each other.

  • Height. The AVL condition is stricter, giving height at most , versus the red-black bound of . Searches in an AVL tree therefore inspect fewer nodes in the worst case.
  • Update cost. The looser red-black invariant is cheaper to maintain. A red-black insertion performs at most rotations and a deletion at most , with everything else recoloring; an AVL insertion also stops after one rebalancing step, but an AVL deletion can cascade rotations all the way up the path, of them in the worst case.

The practical split follows: read-heavy workloads with rare updates favor AVL's shorter trees, while mixed insert/delete workloads favor red-black's cheaper repairs, which is why red-black trees back many standard-library ordered maps.6 Both cost per operation either way; the difference is in the constants.

red_black_tree.pypython
from collections.abc import Iterator
from enum import Enum
from typing import Generic, Optional, TypeVar

from comparable import Comparable

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

class Color(Enum):
  """
    A node's color bit. The nil sentinel and the root are always black.\n
  """
  RED = 0
  BLACK = 1

class RedBlackNode(Generic[Key]):
  """
    One red-black tree node: a key, a color, and links to its parent and\n
    two children. Absent children point at the tree's shared black `nil`\n
    sentinel rather than at None, which removes the special cases when a\n
    fix-up reads a missing child's color.\n
  """

  def __init__(self, key: Key, color: Color) -> None:
    self.key: Key = key
    self.color: Color = color
    self.left: RedBlackNode[Key] = self
    self.right: RedBlackNode[Key] = self
    self.parent: RedBlackNode[Key] = self

  def __repr__(self) -> str:
    return f"RedBlackNode({self.key!r}, {self.color.name})"

class RedBlackTree(Generic[Key]):
  """
    An ordered set of comparable keys with O(log n) search and updates.\n
    A single shared `nil` node stands in for every leaf and for the parent\n
    of the root; it is black, so the invariants hold trivially at the edges.\n
  """

  def __init__(self) -> None:
    # the sentinel is black and points at itself; every leaf and the root's
    # parent reference it, so fix-ups never special-case a missing child.
    self.nil: RedBlackNode[Key] = RedBlackNode.__new__(RedBlackNode)
    self.nil.color = Color.BLACK
    self.nil.left = self.nil
    self.nil.right = self.nil
    self.nil.parent = self.nil

    # an empty tree is just the sentinel.
    self.root: RedBlackNode[Key] = self.nil
    self._size: int = 0

  # -- rotations -------------------------------------------------------------

  def left_rotate(self, pivot: RedBlackNode[Key]) -> None:
    """
      Pivot `pivot` down-left so its right child rises into its place.\n
      Rearranges a constant number of pointers and preserves the BST\n
      property; runs in O(1) time.\n
    """
    # the right child rises; its left subtree becomes `pivot`'s new right.
    riser: RedBlackNode[Key] = pivot.right
    pivot.right = riser.left
    if riser.left is not self.nil:
      riser.left.parent = pivot

    # splice `riser` where `pivot` used to hang.
    riser.parent = pivot.parent
    if pivot.parent is self.nil:
      self.root = riser
    elif pivot is pivot.parent.left:
      pivot.parent.left = riser
    else:
      pivot.parent.right = riser

    # drop `pivot` under `riser`.
    riser.left = pivot
    pivot.parent = riser

  def right_rotate(self, pivot: RedBlackNode[Key]) -> None:
    """
      The mirror of left_rotate: pivot `pivot` down-right so its left\n
      child rises. Also O(1) and order-preserving.\n
    """
    # the left child rises; its right subtree becomes `pivot`'s new left.
    riser: RedBlackNode[Key] = pivot.left
    pivot.left = riser.right
    if riser.right is not self.nil:
      riser.right.parent = pivot

    # splice `riser` where `pivot` used to hang.
    riser.parent = pivot.parent
    if pivot.parent is self.nil:
      self.root = riser
    elif pivot is pivot.parent.right:
      pivot.parent.right = riser
    else:
      pivot.parent.left = riser

    # drop `pivot` under `riser`.
    riser.right = pivot
    pivot.parent = riser

  # -- search ----------------------------------------------------------------

  def _find_node(self, key: Key) -> RedBlackNode[Key]:
    """
      The node carrying `key`, or the `nil` sentinel if it is absent.\n
    """
    # branch left or right by comparison until the key or `nil` is reached.
    current: RedBlackNode[Key] = self.root
    while current is not self.nil and current.key != key:
      current = current.left if key < current.key else current.right
    return current

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

  def minimum(self, subtree: Optional[RedBlackNode[Key]] = None) -> RedBlackNode[Key]:
    """
      The smallest-key node of a subtree (the whole tree by default).\n
      Returns the `nil` sentinel for an empty subtree.\n
    """
    current: RedBlackNode[Key] = self.root if subtree is None else subtree
    if current is self.nil:
      return self.nil

    # follow left links to the smallest key.
    while current.left is not self.nil:
      current = current.left
    return current

  def maximum(self, subtree: Optional[RedBlackNode[Key]] = None) -> RedBlackNode[Key]:
    """
      The largest-key node of a subtree (the whole tree by default).\n
    """
    current: RedBlackNode[Key] = self.root if subtree is None else subtree
    if current is self.nil:
      return self.nil

    # follow right links to the largest key.
    while current.right is not self.nil:
      current = current.right
    return current

  # -- insertion -------------------------------------------------------------

  def insert(self, key: Key) -> None:
    """
      Add `key`. Re-inserting a key already present is a no-op, so the\n
      tree behaves as a set. Restores the color invariants afterward.\n
    """
    # descend to the leaf where `key` belongs, bailing out if it already exists.
    parent: RedBlackNode[Key] = self.nil
    current: RedBlackNode[Key] = self.root
    while current is not self.nil:
      parent = current
      if key == current.key:
        return
      current = current.left if key < current.key else current.right

    # new nodes start red — that keeps every path's black-height unchanged,
    # so only the no-two-reds rule can break, which the fix-up repairs.
    fresh: RedBlackNode[Key] = RedBlackNode(key, Color.RED)
    fresh.left = self.nil
    fresh.right = self.nil
    fresh.parent = parent

    # hang the leaf off `parent` on the correct side (or make it the root).
    if parent is self.nil:
      self.root = fresh
    elif key < parent.key:
      parent.left = fresh
    else:
      parent.right = fresh

    self._size += 1
    self._insert_fixup(fresh)

  def _insert_fixup(self, node: RedBlackNode[Key]) -> None:
    """
      Restore the red-black invariants after inserting the red `node`.\n
      Walks toward the root recoloring on a red uncle and rotating on a\n
      black uncle, so it does O(1) work per level over an O(log n) path.\n
    """
    # climb while the red `node` sits under a red parent, fixing one level a step.
    while node.parent.color is Color.RED:
      grandparent: RedBlackNode[Key] = node.parent.parent
      if node.parent is grandparent.left:
        uncle: RedBlackNode[Key] = grandparent.right

        # case 1: red uncle — recolor and push the violation up two levels.
        if uncle.color is Color.RED:
          node.parent.color = Color.BLACK
          uncle.color = Color.BLACK
          grandparent.color = Color.RED
          node = grandparent
        else:

          # case 2: bend the zig-zag into a straight line, then fall through.
          if node is node.parent.right:
            node = node.parent
            self.left_rotate(node)

          # case 3: straighten with one rotation and recolor.
          node.parent.color = Color.BLACK
          grandparent.color = Color.RED
          self.right_rotate(grandparent)
      else:

        # mirror image of the three cases above.
        uncle = grandparent.left
        if uncle.color is Color.RED:
          node.parent.color = Color.BLACK
          uncle.color = Color.BLACK
          grandparent.color = Color.RED
          node = grandparent
        else:
          if node is node.parent.left:
            node = node.parent
            self.right_rotate(node)
          node.parent.color = Color.BLACK
          grandparent.color = Color.RED
          self.left_rotate(grandparent)

    # the root is always black.
    self.root.color = Color.BLACK

  # -- deletion --------------------------------------------------------------

  def _transplant(
    self,
    removed: RedBlackNode[Key],
    replacement: RedBlackNode[Key],
  ) -> None:
    """
      Replace the subtree rooted at `removed` with that of `replacement`,\n
      rewiring only the parent link (the caller fixes the children).\n
    """
    # point `removed`'s parent at `replacement` on the matching side.
    if removed.parent is self.nil:
      self.root = replacement
    elif removed is removed.parent.left:
      removed.parent.left = replacement
    else:
      removed.parent.right = replacement

    replacement.parent = removed.parent

  def delete(self, key: Key) -> bool:
    """
      Remove `key` if present, restoring the invariants. Returns False\n
      when the key was not in the tree.\n
    """
    target: RedBlackNode[Key] = self._find_node(key)
    if target is self.nil:
      return False

    # `moved` is the node leaving its position; track the color it gives up,
    # since losing a black node may break the equal-black-height rule.
    moved: RedBlackNode[Key] = target
    moved_original_color: Color = moved.color

    # at most one child: promote it straight into `target`'s slot.
    if target.left is self.nil:
      fixup_start: RedBlackNode[Key] = target.right
      self._transplant(target, target.right)
    elif target.right is self.nil:
      fixup_start = target.left
      self._transplant(target, target.left)
    else:

      # two children: the successor (right-subtree minimum) takes `target`'s place.
      successor: RedBlackNode[Key] = self.minimum(target.right)
      moved = successor
      moved_original_color = successor.color
      fixup_start = successor.right

      # detach the successor, carrying `target`'s right subtree with it.
      if successor.parent is target:
        fixup_start.parent = successor
      else:
        self._transplant(successor, successor.right)
        successor.right = target.right
        successor.right.parent = successor

      # move the successor over `target` and adopt its left subtree and color.
      self._transplant(target, successor)
      successor.left = target.left
      successor.left.parent = successor
      successor.color = target.color

    self._size -= 1

    # removing a black node leaves an extra black to resolve at `fixup_start`.
    if moved_original_color is Color.BLACK:
      self._delete_fixup(fixup_start)
    return True

  def _delete_fixup(self, node: RedBlackNode[Key]) -> None:
    """
      Restore the invariants after a black node was removed. `node` carries\n
      an extra unit of black that we push up or resolve with rotations and\n
      recolorings, O(1) per level along an O(log n) path.\n
    """
    # carry the extra black up until it lands on a red node or the root.
    while node is not self.root and node.color is Color.BLACK:
      if node is node.parent.left:
        sibling: RedBlackNode[Key] = node.parent.right

        # case 1: red sibling — recolor and rotate to get a black sibling.
        if sibling.color is Color.RED:
          sibling.color = Color.BLACK
          node.parent.color = Color.RED
          self.left_rotate(node.parent)
          sibling = node.parent.right

        # case 2: sibling's children both black — recolor, push black up.
        if (
          sibling.left.color is Color.BLACK
          and sibling.right.color is Color.BLACK
        ):
          sibling.color = Color.RED
          node = node.parent
        else:

          # case 3: bend so the sibling's far child is red.
          if sibling.right.color is Color.BLACK:
            sibling.left.color = Color.BLACK
            sibling.color = Color.RED
            self.right_rotate(sibling)
            sibling = node.parent.right

          # case 4: one rotation absorbs the extra black and ends the loop.
          sibling.color = node.parent.color
          node.parent.color = Color.BLACK
          sibling.right.color = Color.BLACK
          self.left_rotate(node.parent)
          node = self.root
      else:

        # mirror image of the four cases above.
        sibling = node.parent.left

        # case 1: red sibling.
        if sibling.color is Color.RED:
          sibling.color = Color.BLACK
          node.parent.color = Color.RED
          self.right_rotate(node.parent)
          sibling = node.parent.left

        # case 2: both of the sibling's children are black.
        if (
          sibling.right.color is Color.BLACK
          and sibling.left.color is Color.BLACK
        ):
          sibling.color = Color.RED
          node = node.parent
        else:

          # case 3: bend so the sibling's far child is red.
          if sibling.left.color is Color.BLACK:
            sibling.right.color = Color.BLACK
            sibling.color = Color.RED
            self.left_rotate(sibling)
            sibling = node.parent.left

          # case 4: one rotation absorbs the extra black and ends the loop.
          sibling.color = node.parent.color
          node.parent.color = Color.BLACK
          sibling.left.color = Color.BLACK
          self.right_rotate(node.parent)
          node = self.root

    # whatever the extra black landed on becomes black.
    node.color = Color.BLACK

  # -- traversal -------------------------------------------------------------

  def __iter__(self) -> Iterator[Key]:
    """
      Yield every key in ascending order via an in-order walk.\n
    """

    def walk(node: RedBlackNode[Key]) -> Iterator[Key]:
      if node is self.nil:
        return
      yield from walk(node.left)
      yield node.key
      yield from walk(node.right)

    yield from walk(self.root)

  def __len__(self) -> int:
    return self._size

  def height(self) -> int:
    """
      The number of edges on the longest root-to-leaf path (-1 if empty).\n
    """

    def measure(node: RedBlackNode[Key]) -> int:
      if node is self.nil:
        return -1
      return 1 + max(measure(node.left), measure(node.right))

    return measure(self.root)
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: ...

B-trees: balance for the disk

Red-black and AVL trees minimize the number of nodes on a root-to-leaf path, which is the right cost in memory. But when the data lives on a disk or SSD, the cost that dominates is not comparisons but block transfers: reading one page is millions of times slower than a memory comparison, and a binary tree of keys has height — up to disk reads per search. The fix is to make each node short and wide, sizing it to fill one disk block.

A of minimum degree packs between and keys per node (with one more child than keys) and keeps all leaves at the same depth.7 The high fan-out pushes the height down to

so with on the order of a thousand keys per block, a billion-key tree is just or levels deep — two or three disk reads instead of thirty. Updates keep the leaves level by splitting full nodes and merging underfull ones, all local work. B-trees (and the leaf-linked B+-tree) are the index structure databases and filesystems rely on for exactly this reason. The full treatment — node invariants, search, split/merge, and the height proof — is in the dedicated B-Trees lesson.

Other ways to stay balanced

Red-black and AVL trees enforce balance deterministically by structural rules, but they are two points in a much larger design space, and the alternatives trade worst-case guarantees for simpler code or new capabilities.

Randomized balance. A treap (Seidel and Aragon, 1996) gives each key a random priority and keeps the tree a BST on keys and a heap on priorities; rotations restore the heap order after an insert. Because the priorities are random, the expected height is with no explicit balance bookkeeping, the entire insert is attach as a leaf, then rotate up while your priority beats your parent's. A skip list reaches the same expected bound with a different mechanism, covered in Skip Lists & Probabilistic Structures.

Self-adjusting balance. A splay tree (Sleator and Tarjan, 1985) keeps no balance information at all. After every access it splays the touched node to the root by a sequence of rotations; individually an operation can be , but any sequence of operations costs amortized, and frequently accessed keys drift near the root, giving the tree a built-in caching effect that static-balance trees lack.

Simpler code, same bound. Sedgewick's left-leaning red-black tree (2008) restricts red links to lean left, collapsing the many insert/delete cases down to a handful and making red-black trees far easier to implement correctly. Weight-balanced and scapegoat trees balance by subtree sizes rather than heights, rebuilding an entire subtree from scratch when it grows too lopsided, which keeps amortized with no per-node balance field.

Persistence. Because a rotation touches only nodes on one path, balanced BSTs are naturally persistent: copying just the changed path (path copying) yields a new version of the tree while the old one survives intact, in extra space per update. This is why immutable/functional languages use balanced BSTs (typically red-black or weight-balanced) as their standard ordered-map implementation.8

Takeaways

  • A balanced search tree maintains a structural invariant that forces height , repairing it after each update so every operation is worst-case , not merely best-case.
  • Rotations are the primitive that reshapes a tree to change its height while preserving the BST property and sorted order.
  • Red-black trees color nodes and enforce no-two-reds plus equal black-height; a counting argument shows .
  • Insert-fixup has three cases: red uncle means recolor and climb two levels (Case 1); black uncle means straighten a zig-zag (Case 2) and finish with one rotation plus a color swap (Case 3). Total: recolorings, at most rotations per insert, at most per delete.
  • AVL trees reach the same bound with a stricter height-difference invariant: shorter trees, more rotations.
  • trade tall-and-thin for short-and-wide, packing keys per node so height is , minimizing disk block transfers, the dominant cost for on-disk data.

Footnotes

  1. Erickson, Ch. — Balanced Binary Search Trees: invariant-plus-rebalancing forces worst-case height.
  2. CLRS, Ch. 13 — Red-Black Trees (§13.2): rotations as the restructuring primitive that preserves the BST property.
  3. CLRS, Ch. 13 — Red-Black Trees (§13.1): the color invariants that bound height to .
  4. CLRS, Ch. 13 — Red-Black Trees (§13.3): RB-Insert-Fixup and its three-case analysis; at most two rotations per insertion.
  5. CLRS, Ch. 13 — Red-Black Trees (§13.4): RB-Delete-Fixup, four cases per side, at most three rotations.
  6. Skiena, §3.4 — Balanced Search Trees: red-black trees as the practical balanced BST behind library ordered maps.
  7. CLRS, Ch. 18 — B-Trees (§18.1): the minimum-degree structure with to keys per node, minimizing disk transfers.
  8. Seidel & Aragon, Randomized search trees (treaps, 1996); Sleator & Tarjan, Self-adjusting binary search trees (splay trees, 1985); Sedgewick, Left-leaning red-black trees (2008); Driscoll, Sarnak, Sleator & Tarjan, Making data structures persistent (1989).
Practice

╌╌ END ╌╌