Ko-fiSupport
Software Development2026-10-31•11 min read

Binary Search Trees, AVL, and B+ Trees: Why Your Database Index Isn’t What You Coded in Sophomore Year

B

BrainyTools Editor

Tech Contributor at BrainyTools

Binary Search Trees, AVL, and B+ Trees: Why Your Database Index Isn’t What You Coded in Sophomore Year

When you run CREATE INDEX on a slow PostgreSQL table and your query execution time drops from 4.2 seconds to 1.8 milliseconds, what actually changed on disk? And why didn't the database authors use the Binary Search Tree you coded in sophomore year?


In university Data Structures, the transition from linear structures (Arrays, Linked Lists, Stacks, Queues) to hierarchical Trees is a milestone moment.

Students learn the Binary Search Tree (BST) Invariant: for every node $X$, all keys in its left subtree are strictly less than $X.\text{key}$, and all keys in its right subtree are strictly greater than $X.\text{key}$. Because each comparison cuts the remaining search space in half, searching, inserting, and running ordered range queries all run in $O(\log_2 n)$ time—provided the tree stays balanced.

Then comes the reality check in two parts:

  1. The Degenerate Tree Trap: What happens if you insert sorted keys (1, 2, 3, 4, 5 or auto-incrementing database IDs / time-ordered v7 UUIDs from our UUID Generator) into a naive BST? It degenerates into a lopsided Singly Linked List with height $O(n)$, destroying your $O(\log n)$ guarantee!
  2. The Storage Hardware Trap: Even if you keep a binary tree perfectly balanced using AVL or Red-Black rotations, traversing a binary tree of $100,000,000$ rows requires $\log_2(10^8) \approx 27$ pointer hops across separate disk pages or memory cache lines.

Let us explore how Self-Balancing AVL Trees solve the first problem in RAM, and how B+ Trees solve the second problem in every relational database and filesystem on the planet.


1. The Academic Foundation: BST Invariants and Self-Balancing Rotations

Why Naive BSTs Fail on Sequential Data

In a Binary Search Tree of $N$ nodes, the height $H$ satisfies:

$$\lfloor \log_2 N \rfloor \le H \le N - 1$$

To guarantee $H = O(\log N)$ regardless of insertion order, Soviet mathematicians Georgy Adelson-Velsky and Evgenii Landis invented the AVL Tree in 1962—the very first self-balancing binary search tree.

The AVL Balance Factor & Tree Rotations

An AVL Tree enforces a strict structural invariant at every node $u$: the height difference (called the Balance Factor) between its left and right subtrees can never exceed $1$:

$$\text{BalanceFactor}(u) = \text{height}(u.\text{left}) - \text{height}(u.\text{right}) \in {-1, 0, +1}$$

Whenever an insertion or deletion causes $|\text{BalanceFactor}(u)| = 2$, the AVL tree restores balance locally in $O(1)$ pointer assignments using one of four rotations (Left-Left, Right-Right, Left-Right, or Right-Left) without ever violating the BST ordering invariant!

flowchart LR
    subgraph Unbalanced["Before Right Rotation (Left-Left Imbalance at 30, BF = +2)"]
        direction TB
        N30["30 (BF: +2)"] -->|"Left"| N20["20 (BF: +1)"]
        N20 -->|"Left"| N10["10 (BF: 0)"]
    end

    subgraph Balanced["After Right Rotation around 30 (Height Restored, BF = 0)"]
        direction TB
        B20["20 (BF: 0)"] -->|"Left"| B10["10 (BF: 0)"]
        B20 -->|"Right"| B30["30 (BF: 0)"]
    end

    Unbalanced ==>|"O(1) Right Rotate"| Balanced

2. From Binary Trees to B+ Trees: Designing for 4KB/16KB Disk Pages

If an AVL Tree or Red-Black Tree guarantees $O(\log_2 N)$ height, why doesn't PostgreSQL or MySQL InnoDB use an AVL Tree for database indexes?

Because SSDs, NVMe drives, and OS virtual memory managers read and write data in 4 KB to 16 KB blocks (Pages) (which you can calculate using our Data Size Converter).

  • A binary tree node stores only 1 key and 2 child pointers ($\sim 24\text{ bytes}$). Fetching a single 16 KB disk page just to read 24 bytes wastes $99.8%$ of the I/O bandwidth, and requires 27 separate page reads for 100 million rows!
  • A B+ Tree packs hundreds of sorted keys (say, branching factor $B = 500$) into a single 16 KB node that matches the exact hardware page size.

With a fanout of $B = 500$, how many tree levels are needed to index $125,000,000$ rows?

$$\log_{500}(125,000,000) = 3 \text{ levels!}$$

Because the top 2 levels of the B+ Tree easily stay pinned in RAM buffer cache, looking up any row out of 125 million requires at most 1 physical disk read.

flowchart TB
    Root["Root Internal Node (In RAM)<br/>[ Key < 500 | 500 <= Key < 1000 | Key >= 1000 ]"]
    L1["Leaf Page 1 (Disk Page 0x01)<br/>[101: RowPtr, 205: RowPtr, 480: RowPtr]"]
    L2["Leaf Page 2 (Disk Page 0x02)<br/>[500: RowPtr, 612: RowPtr, 890: RowPtr]"]
    L3["Leaf Page 3 (Disk Page 0x03)<br/>[1000: RowPtr, 1240: RowPtr, 1599: RowPtr]"]

    Root -->|"Key < 500"| L1
    Root -->|"500..999"| L2
    Root -->|">= 1000"| L3

    L1 <-->|"Doubly Linked Leaf Sibling Pointer (Fast SQL Range Scan!)"| L2
    L2 <-->|"Doubly Linked Leaf Sibling Pointer"| L3

Furthermore, in a B+ Tree (unlike the original B-Tree), all actual data records (or row pointers) live exclusively in the Leaf Pages, and the leaf pages are connected in a Doubly Linked List. When you run SELECT * FROM orders WHERE total BETWEEN 500 AND 1250, the database does one $O(\log_B N)$ tree descent to find 500 in Leaf Page 2, and then simply walks the leaf sibling pointer straight into Leaf Page 3!

Complexity & Systems Comparison Table

Structure Search Insert / Delete Range Query ($k$ matches) Optimal Hardware Target
Unbalanced BST $O(\log n)$ avg / $O(n)$ worst $O(\log n)$ avg / $O(n)$ worst $O(n)$ worst Academic teaching only
AVL Tree $O(\log_2 n)$ strict $O(\log_2 n)$ ($\le 2$ rotations) $O(\log_2 n + k)$ Read-heavy in-memory ordered indexes
Red-Black Tree $O(\log_2 n)$ $O(\log_2 n)$ (fewer write rotations) $O(\log_2 n + k)$ Write-heavy in-memory maps (std::map, Linux CFS)
B+ Tree $O(\log_B n)$ page reads $O(\log_B n)$ page splits $O(\log_B n + k/B)$ sequential Databases (PostgreSQL, MySQL) & Filesystems

3. Under the Hood: Testable Self-Balancing AVL Tree with Range Queries

Below is a complete, zero-dependency TypeScript implementation of a Self-Balancing AVL Search Tree supporting $O(\log n)$ insertion, all four balancing rotations (LL, RR, LR, RL), balance invariant verification, and $O(\log n + k)$ bounded range queries (rangeSearch(min, max)).

Copy and run this code directly in the JS/TS Playground:

class AVLNode<K, V> {
  public height: number = 1;
  public left: AVLNode<K, V> | null = null;
  public right: AVLNode<K, V> | null = null;

  constructor(
    public key: K,
    public value: V
  ) {}
}

/**
 * Self-Balancing AVL Binary Search Tree
 * Guarantees strict O(log2 N) height even when fed sequentially sorted inputs.
 */
class AVLTree<K, V> {
  private root: AVLNode<K, V> | null = null;
  private _size: number = 0;

  constructor(
    private readonly compare: (a: K, b: K) => number = (a, b) =>
      a < b ? -1 : a > b ? 1 : 0
  ) {}

  public get size(): number {
    return this._size;
  }

  public get height(): number {
    return this.nodeHeight(this.root);
  }

  /**
   * O(log n) Worst-Case Insert/Update with Automatic AVL Rebalancing
   */
  public insert(key: K, value: V): void {
    this.root = this.insertNode(this.root, key, value);
  }

  /**
   * O(log n) Worst-Case Exact Key Lookup
   */
  public get(key: K): V | undefined {
    let curr = this.root;
    while (curr !== null) {
      const cmp = this.compare(key, curr.key);
      if (cmp === 0) return curr.value;
      curr = cmp < 0 ? curr.left : curr.right;
    }
    return undefined;
  }

  /**
   * O(log n + k) Pruned Range Search: Equivalently executes SQL `WHERE key BETWEEN min AND max`
   * Only visits subtrees that can mathematically overlap [minKey, maxKey].
   */
  public rangeSearch(minKey: K, maxKey: K): Array<{ key: K; value: V }> {
    const results: Array<{ key: K; value: V }> = [];
    this.collectRange(this.root, minKey, maxKey, results);
    return results;
  }

  /**
   * Verifies that every node in the tree satisfies the AVL balance invariant |BF| <= 1
   */
  public isBalanced(): boolean {
    return this.checkBalance(this.root);
  }

  private insertNode(node: AVLNode<K, V> | null, key: K, value: V): AVLNode<K, V> {
    if (node === null) {
      this._size++;
      return new AVLNode(key, value);
    }

    const cmp = this.compare(key, node.key);
    if (cmp < 0) {
      node.left = this.insertNode(node.left, key, value);
    } else if (cmp > 0) {
      node.right = this.insertNode(node.right, key, value);
    } else {
      node.value = value; // Update existing key
      return node;
    }

    this.updateHeight(node);
    return this.rebalance(node);
  }

  private rebalance(node: AVLNode<K, V>): AVLNode<K, V> {
    const bf = this.balanceFactor(node);

    // Left-Heavy (BF > 1)
    if (bf > 1) {
      // Left-Right Case: convert to Left-Left first
      if (this.balanceFactor(node.left) < 0) {
        node.left = this.rotateLeft(node.left!);
      }
      return this.rotateRight(node);
    }

    // Right-Heavy (BF < -1)
    if (bf < -1) {
      // Right-Left Case: convert to Right-Right first
      if (this.balanceFactor(node.right) > 0) {
        node.right = this.rotateRight(node.right!);
      }
      return this.rotateLeft(node);
    }

    return node;
  }

  private rotateRight(y: AVLNode<K, V>): AVLNode<K, V> {
    const x = y.left!;
    const T2 = x.right;

    x.right = y;
    y.left = T2;

    this.updateHeight(y);
    this.updateHeight(x);
    return x;
  }

  private rotateLeft(x: AVLNode<K, V>): AVLNode<K, V> {
    const y = x.right!;
    const T2 = y.left;

    y.left = x;
    x.right = T2;

    this.updateHeight(x);
    this.updateHeight(y);
    return y;
  }

  private collectRange(
    node: AVLNode<K, V> | null,
    minKey: K,
    maxKey: K,
    out: Array<{ key: K; value: V }>
  ): void {
    if (node === null) return;

    // Only recurse left if node.key > minKey
    if (this.compare(node.key, minKey) > 0) {
      this.collectRange(node.left, minKey, maxKey, out);
    }

    // Include current node if within [minKey, maxKey]
    if (this.compare(node.key, minKey) >= 0 && this.compare(node.key, maxKey) <= 0) {
      out.push({ key: node.key, value: node.value });
    }

    // Only recurse right if node.key < maxKey
    if (this.compare(node.key, maxKey) < 0) {
      this.collectRange(node.right, minKey, maxKey, out);
    }
  }

  private nodeHeight(node: AVLNode<K, V> | null): number {
    return node === null ? 0 : node.height;
  }

  private updateHeight(node: AVLNode<K, V>): void {
    node.height = 1 + Math.max(this.nodeHeight(node.left), this.nodeHeight(node.right));
  }

  private balanceFactor(node: AVLNode<K, V> | null): number {
    return node === null ? 0 : this.nodeHeight(node.left) - this.nodeHeight(node.right);
  }

  private checkBalance(node: AVLNode<K, V> | null): boolean {
    if (node === null) return true;
    if (Math.abs(this.balanceFactor(node)) > 1) return false;
    return this.checkBalance(node.left) && this.checkBalance(node.right);
  }
}

// ============================================================================
// Verification & Execution: Adversarial Sorted Input Test
// ============================================================================
const indexTree = new AVLTree<number, string>();

// Insert 1,023 strictly sequential keys (which would make a naive BST height = 1023!)
for (let id = 1; id <= 1023; id++) {
  indexTree.insert(id, `Order-#${id}`);
}

console.log("Total Indexed Rows:", indexTree.size);      // 1023
console.log("AVL Tree Height:", indexTree.height);       // 10 (Optimal log2(1024) = 10!)
console.log("AVL Invariant Satisfied:", indexTree.isBalanced()); // true

// Execute O(log n + k) Range Query: WHERE id BETWEEN 500 AND 505
const rangeMatches = indexTree.rangeSearch(500, 505);
console.log("Range Query [500..505]:", JSON.stringify(rangeMatches));

4. Where Balanced Trees Live in Modern Production Systems

1. Relational Database Indexes (PostgreSQL, MySQL InnoDB, SQLite)

When you declare a PRIMARY KEY or CREATE INDEX idx_users_created_at ON users(created_at), the database constructs a B+ Tree.

Understanding B+ Tree leaf splits explains why database architects prefer UUID v7 (time-ordered) over UUID v4 (completely random) for primary keys (both of which you can generate and compare in our UUID Generator):

  • Inserting random UUID v4 values hits random leaf pages across the entire B+ Tree on disk, causing constant cache misses, mid-page splits, and $50%$ page fragmentation.
  • Inserting sequential UUID v7 values always appends to the rightmost leaf page of the B+ Tree, which stays hot in RAM buffer pool—increasing database write throughput by $3\times\text{–}10\times$!

2. The Linux Kernel Completely Fair Scheduler (CFS) & epoll

Inside the Linux kernel, how does the OS decide which thread gets the CPU next? The Completely Fair Scheduler (CFS) stores runnable tasks in a Red-Black Tree keyed by vruntime (virtual runtime consumed). The task that has received the least CPU time always sits at the leftmost node of the tree, cached for $O(1)$ retrieval, while newly woken or preempted tasks are re-inserted in $O(\log N)$ time. Similarly, Linux epoll (which powers Node.js and Nginx high-concurrency networking) uses a Red-Black Tree to track monitored file descriptors.

3. Modern Filesystems (Apple APFS, Linux Btrfs, ext4 htree, NTFS)

Your computer's filesystem is literally a giant B-Tree/B+ Tree on disk—mapping directory paths, inode extents, and copy-on-write snapshots in $O(\log_B N)$ block lookups.


5. The Professor's Architecture Cheat Sheet

When to Choose Balanced Search Trees (AVL / Red-Black / B+ Tree):

  • Ordered Range Queries (BETWEEN, <, >, ORDER BY): Whenever you need sorted traversal, floor/ceiling key lookups, or prefix/range scans.
  • Deterministic Worst-Case Latency: Unlike a Hash Table (which suffers $O(n)$ latency spikes during a full table resize or adversarial collision), an AVL or Red-Black Tree guarantees strict $O(\log n)$ worst-case time on every single operation.

When NOT to Choose a Full Search Tree:

  • When you only ever care about the Minimum or Maximum element: If your system only needs to repeatedly extract the highest-priority task or earliest timer deadline, maintaining total order across all elements in a balanced BST does unnecessary work. Instead, use a Binary Heap / Priority Queue—the subject of Article 7!

Related Videos

Enjoyed this article?

Check out our suite of free online developer tools to boost your productivity even further. 100% Privacy Focused.

Explore Tools

Latest Articles

Sponsored