Algorithms - 13. Binary Search Tree (BST)

algorithmsdata-structuresbinary-search-treetree-traversalsearching
By sko10/8/202513 min read

When to use Binary Search Trees

Use Binary Search Trees when you need to maintain a dynamically changing sorted collection with efficient operations. While most commonly used for searching sorted data, BSTs can be adapted for:

  • Database indexing (classic use case) - B-trees and B+ trees are BST variants
  • Priority queues with frequent updates (when heap operations aren't sufficient)
  • Range queries - finding all values within a given range
  • Order statistics - finding the kth smallest/largest element
  • Autocomplete systems - prefix matching with lexicographic ordering
  • File systems - directory structures and file organization
  • Any problem requiring maintaining sorted order with frequent insertions/deletions

Example problem

Music Playlist Manager: You're building a music player that maintains a playlist sorted by song title. Given operations like adding songs ["Bohemian Rhapsody", "Imagine", "Hey Jude", "Let It Be", "Yesterday", "Come Together"], efficiently:

  1. Add new songs while maintaining alphabetical order
  2. Search for specific songs to play them
  3. Delete songs from the playlist
  4. Find all songs starting with a given letter or prefix

After processing all operations:

  • The BST maintains songs in alphabetical order
  • Search for "Hey Jude" takes O(log n) average time
  • We can get all songs in sorted order with an inorder traversal
  • Range queries like "all songs starting with 'H'" are efficient

Answer: BST automatically maintains sorted order, making all these operations efficient without manual sorting.

What Binary Search Tree Actually Does

A Binary Search Tree is a hierarchical data structure that maintains elements in sorted order through a simple rule. It provides three core operations:

  1. SEARCH: Find if an element exists in the tree
  2. INSERT: Add a new element while maintaining BST property
  3. DELETE: Remove an element and reorganize the tree

Think of it like organizing a library's card catalog:

  • SEARCH: "Do we have this book?"
  • INSERT: "Add this new book's card in the right alphabetical position"
  • DELETE: "Remove this book's card and reorganize if needed"

How the Operations Work

SEARCH Operation - "Is this value in the tree?"

When you call search(root, 42), it:

  1. Starts at the root node
  2. Compares 42 with current node's value
  3. Goes left if 42 is smaller, right if larger
  4. Returns true if found, false if reaches null
  5. Key efficiency: Eliminates half the remaining tree at each step

INSERT Operation - "Add this value maintaining order"

When you call insert(root, 35), it:

  1. Searches for where 35 should be (like search operation)
  2. Creates a new node when it reaches a null position
  3. Attaches the new node as a left or right child
  4. BST property maintained: All values in left subtree < node < all values in right subtree

DELETE Operation - "Remove this value and reorganize"

When you call delete(root, 50), it handles three cases:

  1. Leaf node: Simply remove it
  2. One child: Replace node with its child
  3. Two children: Find inorder successor (smallest in right subtree), replace value, delete successor

When These Operations Happen

These operations only happen when you explicitly call them:

const bst = new BinarySearchTree();

// INSERT: Explicitly add elements
bst.insert(50); // Creates root
bst.insert(30); // Goes left of 50
bst.insert(70); // Goes right of 50
bst.insert(40); // Goes right of 30

// SEARCH: Check if element exists
let found = bst.search(40); // Returns true
let notFound = bst.search(25); // Returns false

// DELETE: Remove element
bst.delete(30); // Removes 30, reorganizes tree

The beauty of BST is that it maintains sorted order automatically - you never need to manually sort or reorganize the data!

Single most important insight

The BST property (left < parent < right) at every node creates a decision tree for searching - and by consistently following "go left for smaller, right for larger," we eliminate half the search space at each step, achieving O(log n) search in balanced trees.

Mental note: The algorithm's genius lies in encoding sorted order into the tree structure itself. At each node, we only need to make one decision: go left or right based on comparison. This simple binary decision at each level automatically maintains sorted data and enables binary search without arrays.

Decision Framework

BST operations involve these key decisions:

During Search:

  • Is current node null? Return not found
  • Is target equal to current value? Return found
  • Is target less than current? Go left
  • Is target greater than current? Go right

During Insert:

  • Follow search path until reaching null
  • Create new node at the null position
  • Attach as left child if value is less than parent
  • Attach as right child if value is greater than parent

During Delete (most complex):

  • No children (leaf)? Simply remove
  • One child? Replace with that child
  • Two children? Find inorder successor, swap values, delete successor

Why BST Property Matters

The BST property ensures all operations can ignore entire subtrees:

Without BST Property (Unsorted Tree):

To find value 35:
       50
      /  \
     70   30
    / \   / \
   20 40 60 10
Must check ALL 7 nodes - O(n) time!

With BST Property:

To find value 35:
       50
      /  \
     30   70  ← Ignore entire right subtree!
    / \
   20  40    ← Only check left of 30, ignore 20!
Path: 50 → 30 → 40 → not found (only 3 checks)

The BST property guarantees:

  • All values in left subtree < node value
  • All values in right subtree > node value
  • This applies recursively to every subtree
  • Result: We can eliminate ~50% of remaining nodes at each step

This is why balanced BSTs achieve O(log n) operations - the height determines the maximum number of comparisons needed!

Code

// Node structure for the tree
class TreeNode {
  constructor(value) {
    this.value = value;
    this.left = null; // Left child (smaller values)
    this.right = null; // Right child (larger values)
  }
}

class BinarySearchTree {
  constructor() {
    this.root = null;
  }

  // SEARCH: Find if a value exists in the tree
  search(targetValue) {
    return this.searchFromNode(this.root, targetValue);
  }

  searchFromNode(currentNode, targetValue) {
    // Base case: reached end of path
    const reachedEndOfPath = currentNode === null;
    if (reachedEndOfPath) {
      return false; // Value not found
    }

    // Extract current node's value for comparison
    const currentValue = currentNode.value;

    // DECISION FRAMEWORK: Three-way comparison
    const foundTarget = targetValue === currentValue;
    const shouldSearchLeft = targetValue < currentValue;
    const shouldSearchRight = targetValue > currentValue;

    if (foundTarget) {
      return true; // Successfully found the target!
    }

    if (shouldSearchLeft) {
      // Target is smaller - search left subtree only
      const leftSubtree = currentNode.left;
      return this.searchFromNode(leftSubtree, targetValue);
    }

    if (shouldSearchRight) {
      // Target is larger - search right subtree only
      const rightSubtree = currentNode.right;
      return this.searchFromNode(rightSubtree, targetValue);
    }
  }

  // INSERT: Add a new value maintaining BST property
  insert(newValue) {
    // Start insertion from root, update root if needed
    this.root = this.insertIntoSubtree(this.root, newValue);
  }

  insertIntoSubtree(currentNode, newValue) {
    // Base case: found empty spot for new node
    const foundEmptySpot = currentNode === null;
    if (foundEmptySpot) {
      const newNode = new TreeNode(newValue);
      return newNode;
    }

    // Extract current value for comparison
    const currentValue = currentNode.value;

    // DECISION FRAMEWORK: Determine insertion direction
    const insertToLeft = newValue < currentValue;
    const insertToRight = newValue > currentValue;
    const isDuplicate = newValue === currentValue;

    if (insertToLeft) {
      // New value is smaller - insert in left subtree
      const updatedLeftSubtree = this.insertIntoSubtree(
        currentNode.left,
        newValue
      );
      currentNode.left = updatedLeftSubtree;
    } else if (insertToRight) {
      // New value is larger - insert in right subtree
      const updatedRightSubtree = this.insertIntoSubtree(
        currentNode.right,
        newValue
      );
      currentNode.right = updatedRightSubtree;
    } else if (isDuplicate) {
      // Value already exists - skip insertion (no duplicates allowed)
      // Do nothing, just return the unchanged node
    }

    return currentNode;
  }

  // DELETE: Remove a value and maintain BST property
  delete(valueToDelete) {
    // Start deletion from root, update root if needed
    this.root = this.deleteFromSubtree(this.root, valueToDelete);
  }

  deleteFromSubtree(currentNode, valueToDelete) {
    // Base case: value not found in tree
    const nodeNotFound = currentNode === null;
    if (nodeNotFound) {
      return null;
    }

    // Extract current value for comparison
    const currentValue = currentNode.value;

    // DECISION FRAMEWORK: Locate the node to delete
    const searchLeft = valueToDelete < currentValue;
    const searchRight = valueToDelete > currentValue;
    const foundNodeToDelete = valueToDelete === currentValue;

    if (searchLeft) {
      // Value to delete is in left subtree
      const updatedLeftSubtree = this.deleteFromSubtree(
        currentNode.left,
        valueToDelete
      );
      currentNode.left = updatedLeftSubtree;
      return currentNode;
    }

    if (searchRight) {
      // Value to delete is in right subtree
      const updatedRightSubtree = this.deleteFromSubtree(
        currentNode.right,
        valueToDelete
      );
      currentNode.right = updatedRightSubtree;
      return currentNode;
    }

    if (foundNodeToDelete) {
      // Found the node to delete - handle three cases

      // DECISION FRAMEWORK: Deletion cases based on children count
      const hasNoChildren =
        currentNode.left === null && currentNode.right === null;
      const hasOnlyRightChild =
        currentNode.left === null && currentNode.right !== null;
      const hasOnlyLeftChild =
        currentNode.right === null && currentNode.left !== null;
      const hasBothChildren =
        currentNode.left !== null && currentNode.right !== null;

      if (hasNoChildren) {
        // Case 1: Leaf node - simply remove it
        return null;
      }

      if (hasOnlyRightChild) {
        // Case 2a: Only right child exists - replace with it
        const rightChild = currentNode.right;
        return rightChild;
      }

      if (hasOnlyLeftChild) {
        // Case 2b: Only left child exists - replace with it
        const leftChild = currentNode.left;
        return leftChild;
      }

      if (hasBothChildren) {
        // Case 3: Both children exist - use successor strategy

        // Find the inorder successor (smallest value in right subtree)
        const rightSubtree = currentNode.right;
        const successorNode = this.findMinimumNode(rightSubtree);
        const successorValue = successorNode.value;

        // Replace current node's value with successor's value
        currentNode.value = successorValue;

        // Remove the successor from right subtree (it has at most one child)
        const updatedRightSubtree = this.deleteFromSubtree(
          rightSubtree,
          successorValue
        );
        currentNode.right = updatedRightSubtree;

        return currentNode;
      }
    }
  }

  // Helper: Find the node with minimum value in a subtree
  findMinimumNode(startNode) {
    // The minimum is always the leftmost node
    let currentNode = startNode;

    // Keep going left until we can't go further
    while (currentNode.left !== null) {
      currentNode = currentNode.left;
    }

    // This is the leftmost (minimum) node
    return currentNode;
  }

  // BONUS: Inorder traversal (returns sorted array)
  getSortedValues() {
    const sortedArray = [];
    this.collectInOrder(this.root, sortedArray);
    return sortedArray;
  }

  collectInOrder(currentNode, resultArray) {
    // Base case: null node contributes nothing
    const isNullNode = currentNode === null;
    if (isNullNode) {
      return;
    }

    // Inorder traversal: Left -> Current -> Right
    const leftSubtree = currentNode.left;
    const currentValue = currentNode.value;
    const rightSubtree = currentNode.right;

    // Step 1: Process all values in left subtree (smaller values)
    this.collectInOrder(leftSubtree, resultArray);

    // Step 2: Process current node's value
    resultArray.push(currentValue);

    // Step 3: Process all values in right subtree (larger values)
    this.collectInOrder(rightSubtree, resultArray);
  }
}

// === EXAMPLE USAGE: Music Playlist Manager ===
function demonstrateMusicPlaylist() {
  const playlist = new BinarySearchTree();

  // Songs to add to our playlist
  const songsToAdd = [
    "Bohemian Rhapsody",
    "Imagine",
    "Hey Jude",
    "Let It Be",
    "Yesterday",
    "Come Together",
  ];

  // Add each song to the playlist
  console.log("=== Adding Songs to Playlist ===");
  for (const songTitle of songsToAdd) {
    playlist.insert(songTitle);
    console.log(`✓ Added: "${songTitle}"`);
  }

  // Test search functionality
  console.log("\n=== Testing Song Search ===");

  const songToFind = "Hey Jude";
  const songExists = playlist.search(songToFind);
  console.log(`Does "${songToFind}" exist? ${songExists}`); // true

  const missingSong = "Stairway to Heaven";
  const notInPlaylist = playlist.search(missingSong);
  console.log(`Does "${missingSong}" exist? ${notInPlaylist}`); // false

  // Display all songs in sorted order
  console.log("\n=== All Songs (Alphabetically Sorted) ===");
  const allSongsInOrder = playlist.getSortedValues();
  console.log(allSongsInOrder);
  // Output: ["Bohemian Rhapsody", "Come Together", "Hey Jude", "Imagine", "Let It Be", "Yesterday"]

  // Remove a song from playlist
  console.log("\n=== Removing a Song ===");
  const songToRemove = "Hey Jude";
  console.log(`Removing "${songToRemove}" from playlist...`);
  playlist.delete(songToRemove);

  // Show updated playlist
  const updatedPlaylist = playlist.getSortedValues();
  console.log("Updated playlist:", updatedPlaylist);
  // Output: ["Bohemian Rhapsody", "Come Together", "Imagine", "Let It Be", "Yesterday"]

  // Visualize final tree structure
  console.log("\n=== Final Tree Structure ===");
  console.log(`
           "Imagine"
          /          \\
   "Come Together"   "Let It Be"
        /                \\
 "Bohemian Rhapsody"    "Yesterday"
  `);
}

// Run the demonstration
demonstrateMusicPlaylist();

Doubt buster

Common doubt: "When deleting a node with two children, why do we specifically choose the inorder successor (smallest in right subtree)? Why not the inorder predecessor (largest in left subtree) or any other node?"

This seems arbitrary - the algorithm appears to work with predecessor too, so why does every textbook insist on the successor? Let's explore why this choice matters.

Why this seems arbitrary

Consider deleting node 50 with two children:

       50 (delete this)
      /  \
     30   70
    / \   / \
   20 40 60 80

Options:
1. Replace with predecessor (40) - largest in left subtree
2. Replace with successor (60) - smallest in right subtree
3. Replace with... 70? 30? Why not?

Both predecessor and successor seem to work - they both maintain the BST property!

The key insight: Maintaining BST property

Here's the crucial requirement: The replacement value must be:

  1. Greater than ALL values in the left subtree
  2. Less than ALL values in the right subtree
  3. Easy to delete from its current position

Let's check each option:

Option 1: Inorder Predecessor (40)

Before:                After replacing 50 with 40:
     50                        40
    /  \                      /  \
   30   70                   30   70
  / \   / \                 / \   / \
20  40 60  80             20  - 60  80

✓ 40 > all in left subtree (20, 30)
✓ 40 < all in right subtree (60, 70, 80)
✓ Deleting 40 from left subtree is easy (it's a leaf or has one child)

Option 2: Inorder Successor (60)

Before:                After replacing 50 with 60:
     50                        60
    /  \                      /  \
   30   70                   30   70
  / \   / \                 / \   / \
20  40 60  80             20  40  -  80

✓ 60 > all in left subtree (20, 30, 40)
✓ 60 < all in right subtree (70, 80)
✓ Deleting 60 from right subtree is easy (it's a leaf or has one child)

Option 3: Random node like 70

After replacing 50 with 70:
     70
    /  \
   30   70 (duplicate?!)
  / \   / \
20  40 60  80

✗ 70 is NOT less than all in right subtree (it equals 70, greater than 60)
✗ Creates duplicate or violates BST property

Why specifically successor (or predecessor)?

Mathematical guarantee: The inorder successor and predecessor are the ONLY two values that satisfy all requirements:

  1. Inorder Successor = smallest value greater than deleted node

    • Guaranteed > all in left subtree
    • Guaranteed < all in right subtree (except itself)
    • Always has at most one child (no left child by definition)
  2. Inorder Predecessor = largest value less than deleted node

    • Guaranteed < all in right subtree
    • Guaranteed > all in left subtree (except itself)
    • Always has at most one child (no right child by definition)
  3. Any other value = violates BST property for at least one subtree

Why successor is slightly preferred

While both work correctly, successor is often chosen for consistency:

  1. Standard convention: Most textbooks use successor for uniformity
  2. Balancing consideration: In random deletions, alternating might balance better
  3. Implementation simplicity: Finding minimum (leftmost) is often cleaner than finding maximum
  4. Historical precedent: Early BST papers used successor

The beautiful property: Why successor/predecessor have at most one child

This is the elegant part that makes deletion efficient:

Inorder Successor (smallest in right subtree):

     Node
        \
     Right Subtree
        /
    Successor (no left child!)
        \
     Maybe right child
  • If successor had a left child, that child would be smaller
  • But then IT would be the successor, not this node
  • Contradiction! So successor can only have right child

Inorder Predecessor (largest in left subtree):

        Node
        /
    Left Subtree
        \
    Predecessor (no right child!)
        /
    Maybe left child
  • If predecessor had a right child, that child would be larger
  • But then IT would be the predecessor, not this node
  • Contradiction! So predecessor can only have left child

This property guarantees that when we delete the successor/predecessor from its position, we only face Case 1 (leaf) or Case 2 (one child) - never the complex Case 3 again!

Concrete example: Why the algorithm is optimal

Let's trace through a deletion to see why this approach is elegant:

// Delete 50 from this tree:
//        50
//       /  \
//      30   70
//     / \   / \
//    20 40 60 80
//           \
//           65

deleteNode(root, 50):
  // Found node 50 - has two children

  // Find successor: go right once, then left until null
  successor = findMin(70's subtree) = 60

  // Replace 50's value with 60
  node.value = 60

  // Now delete 60 from right subtree
  // 60 has one child (65), so this is Case 2 - simple!

  // Final tree:
  //        60
  //       /  \
  //      30   70
  //     / \   / \
  //    20 40 65 80

The algorithm elegantly reduces the complex two-children case to a simpler one-child case!

The bottom line: Using inorder successor (or predecessor) isn't arbitrary - they're the ONLY values that maintain the BST property when replacing a deleted node. The choice between them is a minor implementation detail, but the mathematical requirement for using one of them is absolute.