Week 10: Binary Search Trees

Week 9 gave you a plain binary tree and four ways to walk it; this week adds one ordering rule — every node's left subtree holds smaller values and its right subtree holds larger ones — and that single invariant turns a tree into a structure that supports O(log n) search, insertion, and deletion when it stays balanced. You'll implement search, insertion, and the three-case deletion algorithm, then apply the recursive patterns from Weeks 8 and 9 to validate a BST's invariant, find the lowest common ancestor of two nodes, and compute a tree's diameter using a single postorder pass. These node-value-and-height accumulation patterns reappear directly in the heap operations of Week 11 and the tree DP of Week 14, so the postorder-plus-global-state trick you'll use for diameter is worth internalizing now.

Module 7 of 17 Week 10 of 20 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Search, insert into, and delete from a BST while preserving the ordering invariant
  • Validate whether a binary tree satisfies the BST property using a range-bound check
  • Find the lowest common ancestor of two nodes and compute a tree's diameter in O(n)

1. BST Properties & Search

A binary search tree is a binary tree with one extra rule applied at every single node: every value in its left subtree is smaller than the node's own value, and every value in its right subtree is larger. This holds recursively — not just for a node's immediate children, but for every node in both subtrees — which is precisely what makes Week 9's inorder traversal produce values in sorted order on a BST.

That invariant is what makes search fast: at each node, comparing the target to the node's value tells you which entire subtree can be safely ignored, the same divide-and-conquer idea as binary search on a sorted array from Week 3:

bst_search.cpp
struct TreeNode {
    int val;
    TreeNode* left;
    TreeNode* right;
    TreeNode(int v) : val(v), left(nullptr), right(nullptr) {}
};

TreeNode* search(TreeNode* node, int target) {
    if (node == nullptr || node->val == target) return node;   // base cases
    return target < node->val ? search(node->left, target)
                               : search(node->right, target);
}

Each recursive call discards one entire subtree, so search costs O(h) where h is the tree's height — O(log n) when the tree is balanced, but degrading to O(n) on a skewed tree, exactly like the worst-case linked-list shape from Week 9.

"O(log n) BST" is a promise, not a guarantee

A plain BST does not automatically stay balanced — inserting already-sorted data one element at a time degenerates it into a linked list with O(n) operations. Self-balancing variants (AVL trees, red-black trees, the ones behind std::map/std::set) enforce balance automatically; for this course, know that the O(log n) bound assumes a roughly balanced tree, and be ready to state the worst case when asked.

2. Insertion & Deletion

Insertion follows the same left/right decision as search, except it stops at the first nullptr it finds and attaches a new node there instead of failing:

bst_insert.cpp
TreeNode* insert(TreeNode* node, int val) {
    if (node == nullptr) return new TreeNode(val);   // base case: found the insertion point
    if (val < node->val) node->left = insert(node->left, val);
    else if (val > node->val) node->right = insert(node->right, val);
    // val == node->val: duplicate -- ignored here; some problems store a count instead
    return node;
}

Deletion is the one BST operation genuinely worth memorizing carefully, because it has three distinct cases depending on how many children the node being removed has:

bst_delete.cpp
TreeNode* findMin(TreeNode* node) {
    while (node->left != nullptr) node = node->left;
    return node;
}

TreeNode* deleteNode(TreeNode* root, int key) {
    if (root == nullptr) return nullptr;              // key not found -- nothing to do

    if (key < root->val) {
        root->left = deleteNode(root->left, key);
    } else if (key > root->val) {
        root->right = deleteNode(root->right, key);
    } else {
        // Found the node to delete.
        if (root->left == nullptr) {                  // Case 1/2: zero or one (right) child
            TreeNode* right = root->right;
            delete root;
            return right;
        }
        if (root->right == nullptr) {                 // Case 2: exactly one (left) child
            TreeNode* left = root->left;
            delete root;
            return left;
        }
        // Case 3: two children -- replace with the inorder successor
        // (smallest value in the right subtree), then delete that successor.
        TreeNode* successor = findMin(root->right);
        root->val = successor->val;
        root->right = deleteNode(root->right, successor->val);
    }
    return root;
}

The two-children case is the subtle one: you can't just remove the node, because that would disconnect both of its subtrees. Instead you copy in a value that's guaranteed to preserve the ordering invariant — the smallest value in the right subtree (or equivalently, the largest in the left subtree) — and then recursively delete that value from its original position, which is now guaranteed to be a zero- or one-child case. Both insertion and deletion run in O(h) time.

3. Validating a BST

A common mistake is checking only that a node's value is greater than its left child and less than its right child. That's not sufficient — the BST property must hold against every node in each subtree, not just the immediate children. The correct approach threads a valid (lower, upper) range down through the recursion, tightening it at every step:

validate_bst.cpp
#include <climits>

bool isValidBST(TreeNode* node, long long lower, long long upper) {
    if (node == nullptr) return true;                 // base case: empty tree is valid
    if (node->val <= lower || node->val >= upper) return false;
    return isValidBST(node->left, lower, node->val) &&
           isValidBST(node->right, node->val, upper);
}

bool isValidBST(TreeNode* root) {
    return isValidBST(root, LLONG_MIN, LLONG_MAX);
}

As the recursion descends into the left subtree, the upper bound tightens to the parent's value; descending right tightens the lower bound instead — so a node deep in a left subtree is checked against every ancestor it needs to be smaller than, not just its immediate parent. long long bounds (rather than int) avoid overflow issues if node values sit near INT_MIN/INT_MAX.

An equally valid alternative reuses Week 9's inorder traversal directly: run inorder and check that the resulting sequence is strictly increasing — a direct consequence of inorder producing sorted output only when the BST property genuinely holds throughout.

4. Lowest Common Ancestor

The lowest common ancestor (LCA) of two nodes p and q is the deepest node that has both of them as descendants (a node counts as its own descendant). In a general binary tree this needs a full postorder-style search of both subtrees, but the BST invariant gives a huge shortcut: at any node, you can tell which direction to go just by comparing values, without exploring both sides:

bst_lca.cpp
TreeNode* lowestCommonAncestor(TreeNode* root, TreeNode* p, TreeNode* q) {
    if (root == nullptr) return nullptr;

    if (p->val < root->val && q->val < root->val)
        return lowestCommonAncestor(root->left, p, q);    // both smaller -- go left

    if (p->val > root->val && q->val > root->val)
        return lowestCommonAncestor(root->right, p, q);   // both larger -- go right

    return root;   // split point: p and q fall on different sides (or one equals root)
}

This runs in O(h) time and O(1) extra space if written iteratively as a while loop instead of recursively, since at most one recursive branch is ever taken. A general (non-BST) binary tree's LCA algorithm can't use value comparisons to prune a side away — it has to search both subtrees via postorder and combine the results, which costs O(n) instead.

Name-drop the general case in an interview

If asked for BST LCA, mention explicitly that you're exploiting the ordering invariant to avoid searching both subtrees, and that a plain binary tree would need the more expensive postorder approach instead. Interviewers frequently follow up by removing the BST guarantee specifically to see if you notice the shortcut disappears.

5. Diameter-of-a-Tree Problems

The diameter of a binary tree is the number of edges on the longest path between any two nodes — and critically, that path does not need to pass through the root. A naive approach recomputes height from scratch at every node, costing O(n²) in the worst case. The efficient version computes height and updates a running diameter in the same postorder pass, using the same "combine child results into a parent result" shape you used for maxDepth in Week 9:

diameter.cpp
#include <algorithm>
using namespace std;

int diameter = 0;   // tracked across the whole traversal

int height(TreeNode* node) {
    if (node == nullptr) return 0;                 // base case
    int leftHeight = height(node->left);
    int rightHeight = height(node->right);

    diameter = max(diameter, leftHeight + rightHeight);  // longest path THROUGH this node

    return 1 + max(leftHeight, rightHeight);        // height, for the caller (parent) to use
}

int diameterOfBinaryTree(TreeNode* root) {
    diameter = 0;
    height(root);
    return diameter;
}

Each node computes and returns its own height (needed by its parent), while separately updating a shared diameter variable with the best path that routes through it (leftHeight + rightHeight edges). Because every node is visited exactly once and does O(1) work beyond its recursive calls, this runs in O(n) time and O(h) space — a large improvement over the naive quadratic version, and the same "return one thing, but also update shared state along the way" pattern generalizes to many other "longest path" or "max sum path" tree problems.

6. Hands-on Exercise

Hands-on

Build, validate, and query a BST end to end

Chain together every operation from this week — build, validate, query, and modify — into one program that proves the invariant survives each step.

Requirements:

  1. Implement insert() and build a BST by inserting the values {8, 3, 10, 1, 6, 14, 4, 7, 13} one at a time.
  2. Implement isValidBST() using the range-bound approach and confirm it returns true on your tree.
  3. Implement lowestCommonAncestor() and find the LCA of the nodes holding 4 and 7, then of 1 and 13 — print both results and explain why they differ.
  4. Implement diameterOfBinaryTree() using the O(n) postorder technique and print the result.
  5. Implement deleteNode(), remove the value 3 (a two-children case), and re-run isValidBST() to confirm the invariant still holds after deletion.
  6. Print an inorder traversal before and after the deletion and confirm both are sorted, with 3 missing from the second.
Hint

Reuse the inorder traversal from Week 9 as your correctness check throughout this exercise — a BST's inorder output should stay sorted after every insert and delete, so any bug in your insertion or deletion logic will show up immediately as an out-of-order value.

7. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

Why is recursive BST search, insertion, and deletion O(h) rather than simply O(log n), and when do the two coincide?

Every one of these operations follows a single root-to-node path, discarding one subtree at each step, so the true cost is bounded by the tree's height h regardless of how many total nodes n exist. h only equals O(log n) when the tree is reasonably balanced; a BST built by inserting already-sorted values degenerates into a linked-list shape where h = n, making every operation O(n) instead.

Q2

Why is checking only node->val > node->left->val && node->val < node->right->val at every node NOT sufficient to validate a BST?

The BST property must hold against every node in a subtree, not just the immediate children — a counterexample is a root of 10 whose left child is 5, where 5's right child is 15. Every immediate parent/child comparison passes (5 < 10 and 5 < 15), yet 15 is in the root's left subtree despite being larger than the root, which violates the real invariant; only a bound that's threaded down and tightened through the whole recursion (as in the range-bound approach) catches this.

Q3

Why doesn't the BST lowest-common-ancestor algorithm need to explore both subtrees, the way a general binary tree's LCA algorithm does?

The BST ordering invariant means a simple value comparison at each node tells you exactly which subtree both p and q must be in if they're together on one side — no exploration is needed to know that. A plain binary tree has no such ordering guarantee, so its LCA algorithm has to actually search both subtrees (a postorder-style search) to discover where p and q live, which is what makes it O(n) instead of O(h).

Q4

In the diameter algorithm, why does the global maximum get updated inside height() using leftHeight + rightHeight, instead of just returning the diameter directly from the function?

height() has to return a node's height to its parent, since the parent needs that value to compute its own height correctly — the function's return channel is already committed to that job. The diameter, on the other hand, is a global property (the best path found anywhere in the whole tree so far), not something a single node's height computation can hand back up the call chain on its own, so it's tracked as shared state updated as a side effect at every node instead, letting one traversal compute both values in a single O(n) pass.