Data Structures/AVL Trees

Lesson 4.43,749 words

AVL Trees

An AVL tree is the first balanced BST: at every node the two subtrees' heights differ by at most 11. A Fibonacci-style minimal-node argument forces height $h \le 1.

╌╌╌╌

The previous lesson left us with a binary search tree that does everything in time: fast when the tree is bushy, when a run of sorted insertions stretches it into a height- path. The fix is to prevent the tree from getting tall: pick a structural invariant that pins height to , and restore it after every update. The AVL tree, named for Adelson-Velsky and Landis (1962), is the oldest such scheme and the easiest to reason about, balancing by a direct height constraint at every node.1

To talk about rebalancing we need one primitive, which the next lesson on balanced search trees treats in full: the rotation. A rotation rearranges three pointers to change a subtree's shape, and so its height, while preserving the BST ordering and running in time. A right rotation at lifts its left child to the root and makes the right child of ; a left rotation is the inverse. That is all we need here: a cheap, order-preserving operation that trades height between siblings.

The AVL invariant

Define the height of a node as the number of edges on the longest downward path to a leaf, with . The balance factor is

and the invariant says exactly for every node. We store (or, equivalently, ) in each node and keep it current as the tree changes; both a search and an in-order walk ignore the field entirely, so a read-only operation on an AVL tree is just a BST operation.

A node with is left-heavy past tolerance, right-heavy; these are the two illegal states an update can create, and the two we must repair.

Height is logarithmic

The invariant forces a logarithmic height bound. The cleanest argument is extremal: ask how few nodes an AVL tree of a given height can possibly contain. A sparse tree is the dangerous case, so if even the sparsest legal tree is bushy, all of them are.

The recurrence is easiest to believe by drawing it. Each minimal tree is a root whose children are the two previous minimal trees — the same doubling-back structure as the Fibonacci numbers themselves:

Minimal AVL trees of heights through . Each is a root (accent) over the two preceding minimal trees, giving : the height- tree is a root over the height- tree (left) and the height- tree (right), .

The minimal-node trees are the Fibonacci trees: the sparsest height- AVL tree is a root over a Fibonacci tree of height and one of height . Because , an AVL tree is provably shorter than the worst-case red-black tree of the same size, so its lookups touch fewer nodes. With guaranteed, search, , , , and successor all run in worst-case time.3

The sparsest AVL tree of height — a Fibonacci tree with the minimum nodes. Each node's two subtrees differ in height by exactly (the extreme the invariant allows): the root sits over a minimal height- tree and a minimal height- tree, recursively. Subtree heights are labeled.
avl_tree.pypython
def minimal_avl_nodes(target_height: int) -> int:
  """
    The minimum number of nodes N(h) in an AVL tree of the given height,\n
    from the Fibonacci-style recurrence N(h) = N(h-1) + N(h-2) + 1 with\n
    N(0) = 1 and N(1) = 2. These minima are realized by the Fibonacci trees\n
    and underpin the 1.44*log2(n) height bound. A height of -1 (the empty\n
    tree) has 0 nodes.\n
  """
  if target_height < 0:
    return 0
  if target_height == 0:
    return 1

  previous: int = 1  # N(0)
  current: int = 2    # N(1)
  for _ in range(2, target_height + 1):
    previous, current = current, current + previous + 1
  return current

Insertion: rebalancing on the way up

We insert a key exactly as in a plain BST: descend to a leaf position and attach the new node. That can only push heights up along the single root-to-leaf path we just walked, so we retrace it upward, recomputing at each node. Let be the lowest node whose balance factor has become . Its imbalance was caused by the new node landing in one of four positions relative to , named by the two steps down the heavy path from toward the insertion:

  • LL: left child left-heavy, a single right rotation at .
  • RR: right child right-heavy, a single left rotation at .
  • LR: left child right-heavy, a left rotation at the child, then a right rotation at .
  • RL: right child left-heavy, a right rotation at the child, then a left rotation at .

Consider the LL case. Node has balance , its left child leans left, and the new node sits under 's left child . A single right rotation at lifts into 's place, hangs as 's right child, and rehomes 's old right subtree as 's new left subtree.

The LL case — a left-left-heavy subtree fixed by one right rotation at

The height bookkeeping makes the repair exact. Measure heights at the moment of violation: say and have height , and 's subtree has height because it contains the new node. Then , and 's two subtrees have heights and : balance , the violation. After the right rotation, roots and (both height ), so has height and balance ; roots (height ) and (height ), so has height and balance . Before the insertion, 's height was — exactly the height has now. The subtree presents the same height to its parent as before the insert, so no ancestor's balance factor changes, and the repair is complete.

The RR case is the exact mirror: has balance , its right child leans right, and the new node sits under 's right child . A single left rotation at lifts , hangs as 's left child, and rehomes 's old left subtree as 's new right subtree. The same bookkeeping applies with left and right exchanged.

The RR case — a right-right-heavy subtree fixed by one left rotation at (the mirror of LL).

The LR case cannot be fixed by a single rotation: the heavy path zig-zags, so we first rotate the child to straighten it into an LL shape, then finish with the right rotation at . Here has balance , its left child leans right, and the offending grandchild is 's right child. A left rotation at lifts above ; the subtree is now left-left-heavy, and a right rotation at completes the repair, leaving as the new root.

The LR case — left rotation at , then right rotation at (a double rotation)

The bookkeeping for LR: at the violation, and have height , and 's subtree has height — its children and have heights and in some order, whichever one received the new node. So has height and is at balance . After the double rotation, is the root; its left child roots (height ) and (height ), so ; its right child roots (height ) and (height ), so . Both have balance or , legal, and 's height is , again exactly 's pre-insert height, so the violation cannot propagate. One subtlety worth noticing: after an LR repair, exactly one of ends up with balance (whichever lost the shorter of ), and ends at balance .

The RL case is the mirror of LR — the heavy path zig-zags right-then-left, so we straighten with a right rotation at the child before the left rotation at :

The RL case — right rotation at , then left rotation at (the mirror of LR). Here has balance , its right child leans left, and the offending grandchild is 's left child; the double rotation lifts to the root.

In every case the rebalanced subtree ends up with the same height it had before the insertion, which is the decisive fact:

So insertion is to descend, to walk back up adjusting heights, and at most a double rotation ( rotations) to repair: overall. The dispatch is driven entirely by the stored heights:

Algorithm:Insert-Fixup\textsc{Insert-Fixup} — retrace the insertion path, repairing the lowest violation
  1. 1
    xparent of the new leafx \gets \text{parent of the new leaf}
  2. 2
    while xnilx \ne \text{nil} do
  3. 3
    h(x)1+max(h(left(x)),h(right(x)))h(x) \gets 1 + \max\parens{h(left(x)),\, h(right(x))}
  4. 4
    bh(right(x))h(left(x))b \gets h(right(x)) - h(left(x))
    balance factor of xx
  5. 5
    if b=2b = -2 then
    left-heavy: LL or LR
  6. 6
    if h(left(left(x)))h(right(left(x)))h(left(left(x))) \ge h(right(left(x))) then
  7. 7
    Right-Rotate(x)\textsc{Right-Rotate}(x)
    LL
  8. 8
    else
  9. 9
    Left-Rotate(left(x))\textsc{Left-Rotate}(left(x)) ; Right-Rotate(x)\textsc{Right-Rotate}(x)
    LR
  10. 10
    break
    height restored, ancestors safe
  11. 11
    else if b=+2b = +2 then
    right-heavy: RR or RL
  12. 12
    if h(right(right(x)))h(left(right(x)))h(right(right(x))) \ge h(left(right(x))) then
  13. 13
    Left-Rotate(x)\textsc{Left-Rotate}(x)
    RR
  14. 14
    else
  15. 15
    Right-Rotate(right(x))\textsc{Right-Rotate}(right(x)) ; Left-Rotate(x)\textsc{Left-Rotate}(x)
    RL
  16. 16
    break
  17. 17
    xparent(x)x \gets parent(x)

A worked insertion sequence

Insert the keys into an empty AVL tree, in that order. This sequence is the sorted-order input that destroys a plain BST, plus one out-of-order key at the end to force a double rotation.

  • Insert 10, 20. becomes the root; its right child. Heights: , , balance factors and . Legal.
  • Insert 30. It lands as 's right child. Retracing: , fine; but . The lowest violation is , its right child leans right: RR. One left rotation at gives as root with children and , all balance factors , and stops.
  • Insert 40. Path , attach right. Retracing: , . No violation, no rotation.
  • Insert 50. Path , attach right. Retracing: , then — the lowest violation is , not the root. Its right child leans right: RR again. A left rotation at yields the subtree over and ; the tree is now over and , and is legal, so retracing stops.
  • Insert 25. Path , attach as 's left child. Retracing: , fine; with , fine; . Violation at , and this time its right child leans left: the zig-zag RL case, with and . First a right rotation at straightens the path ( lifts above ), then a left rotation at lifts to the root.
The final step of the walkthrough. Inserting pushes node to balance with its right child leaning left — the RL case with , , . A right rotation at straightens the zig-zag; a left rotation at lifts to the root. Every balance factor is legal again and the tree has height , the minimum possible for nodes.

Two properties show in this trace. Repairs fire rarely — six inserts triggered three, four rotations in all — and each fires at the lowest violated node only, never higher, because the repair restores the subtree's pre-insert height. And the sorted prefix , which would have built a height- path in a plain BST, ends up at height : the tree handles adversarial order at rotations per insert.

Deletion: the same cases, but up the whole path

Deletion starts as in a BST, splicing out the node, or its in-order successor if it has two children, then retraces the path to the root updating heights, applying exactly the same four rotation cases at any node that reaches . The one difference is consequential. A rotation that repairs an insertion preserves the subtree's height, but a rotation that repairs a deletion can shrink the subtree by one. That shorter subtree may unbalance the node's parent, which may unbalance its parent, and so on. Deletion can therefore trigger up to rotations cascading toward the root, though each is still , so the operation remains .3

Why deletion cascades. A rotation that repairs the subtree at leaves it one shorter (height ), so the height drop propagates to the parent : with its other subtree still height , 's balance becomes and must itself be rotated — and that rotation can shorten , unbalancing 's parent, all the way to the root. (Insertion never does this: its repair restores the original height.)

The asymmetry has a precise cause. An insertion grows the taller side, and the rotation cuts it back to exactly the old height, so no ancestor's balance changes. A deletion shrinks a side, and a rotation can only redistribute height, not create it: when the rotation at finishes, the repaired subtree often stands one shorter than before the delete, and the parent must re-check its own balance against a subtree that genuinely changed size. The retracing loop stops early in exactly two situations: at a node whose balance factor moves from to (one subtree shrank, but the node's height is set by the other and is unchanged), or after a rotation in which the lifted child had balance (that rotation preserves the subtree's height). Otherwise the height drop keeps propagating.

Concretely, take the sparsest height- AVL tree on the keys arranged as over and , and delete . Node becomes a leaf, legal by itself, but at the balance is now . The left child leans left (), so this is LL: one right rotation at . The result, over and , is a legal AVL tree of height — one shorter than the original. A single leaf deletion shrank the whole tree, and if this tree were itself a subtree, the shrink would arrive at its parent exactly as in the schematic above. Fibonacci trees realize the worst case: every node is already at balance , so a deletion on the short side of each ancestor can force a rotation at every level, of them.

A concrete cascade seed. Deleting from this minimal height- tree drops to a leaf and pushes to balance (LL); the right rotation at repairs it but leaves the tree at height , one shorter — a change the parent of this subtree (if any) would have to absorb in turn.

AVL versus red-black

AVL and red-black trees both guarantee height, but they sit at opposite ends of a tradeoff. The AVL height-difference invariant is strict: , noticeably shorter than red-black's , so AVL lookups are faster, the structure of choice for read-heavy workloads. The cost falls on updates: AVL trees track exact heights and may rotate to repair even small imbalances, whereas red-black trees tolerate looser balance and rely on cheap recolorings, doing fewer rotations per update. Roughly: AVL is more rigidly balanced and faster to search; red-black is cheaper to mutate, which is why it backs most standard-library ordered maps.4

avl_tree.pypython
from collections.abc import Iterator
from typing import Generic, Optional, TypeVar
from comparable import Comparable


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


class AVLNode(Generic[Key]):
  """
    One node of an AVL tree: its key, its left and right child links, and\n
    the cached height of the subtree rooted here. A leaf has height 0 and a\n
    missing (nil) child is treated as height -1.\n
  """

  def __init__(self, key: Key) -> None:
    self.key: Key = key
    self.left: Optional[AVLNode[Key]] = None
    self.right: Optional[AVLNode[Key]] = None
    self.height: int = 0

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


def _height(node: Optional[AVLNode[Key]]) -> int:
  """
    The cached height of a subtree, with the nil convention h(nil) = -1.\n
  """
  return node.height if node is not None else -1


def _balance_factor(node: AVLNode[Key]) -> int:
  """
    The balance factor bf(node) = h(right) - h(left): negative is\n
    left-heavy, positive is right-heavy, legal values are -1, 0, +1.\n
  """
  return _height(node.right) - _height(node.left)


def _update_height(node: AVLNode[Key]) -> None:
  """
    Recompute a node's height from its children's cached heights.\n
  """
  node.height = 1 + max(_height(node.left), _height(node.right))


class AVLTree(Generic[Key]):
  """
    An ordered set of comparable keys kept height-balanced.\n
    Supports membership, insertion, deletion, and an in-order traversal that\n
    yields keys in sorted order.\n
  """

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

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

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

  def __iter__(self) -> Iterator[Key]:
    yield from self._in_order(self.root)

  def _search(
    self, node: Optional[AVLNode[Key]], key: Key
  ) -> Optional[AVLNode[Key]]:
    """
      The node holding `key` in the subtree at `node`, or None if absent.\n
    """
    current: Optional[AVLNode[Key]] = node
    while current is not None:
      if key < current.key:
        current = current.left
      elif current.key < key:
        current = current.right
      else:
        return current
    return None

  def _rotate_right(self, root: AVLNode[Key]) -> AVLNode[Key]:
    """
      Right rotation at `root`: lift its left child into root's place,\n
      hang root as that child's right child, and rehome the child's old\n
      right subtree as root's new left subtree. Returns the new subtree root.\n
    """
    pivot = root.left
    assert pivot is not None  # a right rotation only runs with a left child.
    moved_subtree: Optional[AVLNode[Key]] = pivot.right
    pivot.right = root
    root.left = moved_subtree

    # the lower node (root) must be re-measured before the lifted pivot.
    _update_height(root)
    _update_height(pivot)
    return pivot

  def _rotate_left(self, root: AVLNode[Key]) -> AVLNode[Key]:
    """
      Left rotation at `root`, the mirror image of `_rotate_right`.\n
      Returns the new subtree root.\n
    """
    pivot = root.right
    assert pivot is not None  # a left rotation only runs with a right child.
    moved_subtree: Optional[AVLNode[Key]] = pivot.left
    pivot.left = root
    root.right = moved_subtree

    _update_height(root)
    _update_height(pivot)
    return pivot

  def _rebalance(self, node: AVLNode[Key]) -> AVLNode[Key]:
    """
      Restore the AVL invariant at `node` after its height was updated,\n
      dispatching on the balance factor into one of the four cases\n
      (LL, RR, LR, RL). Returns the subtree's (possibly new) root.\n
    """
    _update_height(node)
    balance: int = _balance_factor(node)

    # left-heavy: either LL (single right) or LR (left child, then right).
    if balance < -1:
      left_child = node.left
      assert left_child is not None  # left-heavy implies a left child exists.
      if _balance_factor(left_child) > 0:
        node.left = self._rotate_left(left_child)  # LR straightens to LL
      return self._rotate_right(node)

    # right-heavy: either RR (single left) or RL (right child, then left).
    if balance > 1:
      right_child = node.right
      assert right_child is not None  # right-heavy implies a right child exists.
      if _balance_factor(right_child) < 0:
        node.right = self._rotate_right(right_child)  # RL straightens to RR
      return self._rotate_left(node)

    return node

  def insert(self, key: Key) -> bool:
    """
      Add `key` to the tree, rebalancing on the way up.\n
      Returns False (and leaves the tree unchanged) if the key is present.\n
    """
    inserted: bool
    self.root, inserted = self._insert(self.root, key)
    if inserted:
      self.size += 1
    return inserted

  def _insert(
    self, node: Optional[AVLNode[Key]], key: Key
  ) -> tuple[AVLNode[Key], bool]:
    """
      Insert `key` into the subtree at `node`, returning the new subtree\n
      root and whether a node was actually added.\n
    """
    if node is None:
      return AVLNode(key), True

    if key < node.key:
      node.left, inserted = self._insert(node.left, key)
    elif node.key < key:
      node.right, inserted = self._insert(node.right, key)
    else:
      return node, False  # duplicate keys are ignored

    return self._rebalance(node), inserted

  def delete(self, key: Key) -> bool:
    """
      Remove `key` from the tree, rebalancing on the way up.\n
      Returns False if the key was not present.\n
    """
    deleted: bool
    self.root, deleted = self._delete(self.root, key)
    if deleted:
      self.size -= 1
    return deleted

  def _delete(
    self, node: Optional[AVLNode[Key]], key: Key
  ) -> tuple[Optional[AVLNode[Key]], bool]:
    """
      Delete `key` from the subtree at `node`, returning the new subtree\n
      root (or None) and whether a node was removed.\n
    """
    if node is None:
      return None, False

    if key < node.key:
      node.left, deleted = self._delete(node.left, key)
    elif node.key < key:
      node.right, deleted = self._delete(node.right, key)
    else:
      # found it: splice out, handling zero, one, or two children.
      if node.left is None:
        return node.right, True
      if node.right is None:
        return node.left, True

      # two children: replace key with the in-order successor, then
      # delete that successor from the right subtree.
      successor: AVLNode[Key] = self._min_node(node.right)
      node.key = successor.key
      node.right, _ = self._delete(node.right, successor.key)
      deleted = True

    return self._rebalance(node), deleted

  def _min_node(self, node: AVLNode[Key]) -> AVLNode[Key]:
    """
      The left-most (minimum-key) node of a non-empty subtree.\n
    """
    current: AVLNode[Key] = node
    while current.left is not None:
      current = current.left
    return current

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

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

    # walk right links to the largest key.
    current: AVLNode[Key] = self.root
    while current.right is not None:
      current = current.right
    return current.key

  def height(self) -> int:
    """
      The height of the tree in edges, with -1 for an empty tree.\n
    """
    return _height(self.root)

  def _in_order(self, node: Optional[AVLNode[Key]]) -> Iterator[Key]:
    """
      Yield the keys of the subtree at `node` in ascending order.\n
    """
    if node is not None:
      yield from self._in_order(node.left)
      yield node.key
      yield from self._in_order(node.right)
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: ...

The balance spectrum

The AVL tree (Adelson-Velsky and Landis, 1962) was the first self-balancing binary search tree, and its height-difference idea led to a family of variants that trade the strictness of differ by at most for cheaper maintenance.

Relaxing the balance factor. Allow subtree heights to differ by up to instead of and you get -balanced or HB() trees: taller, so lookups touch a few more nodes, but rebalanced less often. Pushing the same idea the other way, weak AVL (WAVL) trees (Haeupler, Sen, and Tarjan, 2015) use a rank rule that interpolates between AVL and red-black, an untouched WAVL tree is as short as an AVL tree, but after deletions it degrades gracefully toward red-black balance, doing at most one rotation per deletion where an AVL tree can cascade. WAVL is a clean answer to exactly the AVL-vs-red-black tension this lesson closes on: it aims for AVL's short trees with red-black's cheap deletes.

Why the four cases are really two. The LL/RR/LR/RL taxonomy is a special case of a general fact: any single imbalance is fixable by one or two rotations, and the double cases (LR, RL) are just rotate the child to reduce to a single case, then rotate the parent. The same double-rotation appears verbatim in red-black insert-fixup's Case 2-then-3, which is no coincidence: both are the standard way to straighten a zig-zag before a zig-zig.

Where AVL wins today. Because AVL trees are the shortest of the common balanced BSTs, they remain the pick for in-memory indexes and language runtimes whose workloads are lookup-dominated, some database in-memory indexes and the Linux kernel's earlier scheduler used AVL-style structures. When updates dominate, red-black or WAVL win; when the data lives on disk, neither, the fan-out of a B-tree beats any binary tree.5

Takeaways

  • An AVL tree is a BST with the invariant that every node's two subtrees differ in height by at most , i.e. its balance factor ; each node stores its height (or balance).
  • A minimal-node / Fibonacci-tree argument gives , so and height , hence all operations are worst-case .
  • Insertion descends as a BST, retraces the path updating heights, and repairs the lowest unbalanced node with one of four cases, LL (right), RR (left), LR (left-then-right), RL (right-then-left), using rotations, because the fix restores the subtree's prior height.
  • Deletion uses the same four cases but its rotations can shrink a subtree, so the rebalancing may cascade up to the root, taking rotations.
  • Versus red-black trees: AVL is more rigidly balanced (shorter, faster lookups) but does more rotations per update.

Footnotes

  1. Erickson, Ch. — Balanced Binary Search Trees: the height-balance invariant and its restoration by rotation after each update.
  2. CLRS, Problem 13-3 — AVL Trees: the Fibonacci minimal-node recurrence and the height bound; insertion via with rotations.
  3. Skiena, §3.4 — Balanced Search Trees: AVL operations are ; deletion may rebalance along the full root path. 2
  4. Skiena, §3.4 — Balanced Search Trees: the AVL-vs-red-black tradeoff — tighter balance and faster search versus cheaper update.
  5. Adelson-Velsky & Landis, An algorithm for the organization of information (1962), the original AVL tree; Haeupler, Sen & Tarjan, Rank-balanced trees (WAVL, 2015), interpolating between AVL and red-black balance.
Practice

╌╌ END ╌╌