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:
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.
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:
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:
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:
#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:
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.
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:
#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
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:
- Implement
insert()and build a BST by inserting the values{8, 3, 10, 1, 6, 14, 4, 7, 13}one at a time. - Implement
isValidBST()using the range-bound approach and confirm it returnstrueon your tree. - Implement
lowestCommonAncestor()and find the LCA of the nodes holding4and7, then of1and13— print both results and explain why they differ. - Implement
diameterOfBinaryTree()using theO(n)postorder technique and print the result. - Implement
deleteNode(), remove the value3(a two-children case), and re-runisValidBST()to confirm the invariant still holds after deletion. - Print an inorder traversal before and after the deletion and confirm both are sorted, with
3missing from the second.
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?
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?
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?
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?
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.