Trees & BST
A binary search tree is an ordinary binary tree plus one promise: every value on the left is smaller, every value on the right is larger. That single promise turns lookup into binary search and makes an in-order walk hand you the values already sorted. This page is about tree structure itself — the invariant, height, balance, diameter, and how to rebuild a tree — not about traversal mechanics.
One promise, kept at every node
A binary tree is just nodes with a left and a right. A
binary search tree adds one rule, and the rule is stricter than most people
remember:
That word entire is where most bugs live, and section 02 shows exactly how. Everything else on this page follows from the rule or from the tree's shape.
class TreeNode:
def __init__(self, val, left=None, right=None):
self.val = val
self.left = left
self.right = right
T0The single most useful BST fact
Walk a BST left subtree, then the node, then right subtree — the in-order traversal — and the values come out sorted, ascending. No comparisons, no sorting step. The ordering was baked into the shape when the tree was built.
def in_order(node, output):
if node is None:
return
# Everything smaller than node lives to the left, so print it first.
in_order(node.left, output)
output.append(node.val)
# Everything larger lives to the right, so it comes last.
in_order(node.right, output)
def to_sorted_list(root):
output = []
in_order(root, output)
return output
Five things this pattern actually does
Getting around a tree is Depth-First Search (Pattern 03) and Breadth-First Search (Pattern 04) — those pages own the traversal machinery. This page owns what you do with the structure once you can walk it.
ASearch and insert: the invariant as a loop
At every node you compare once and discard an entire subtree. That is binary search with
pointers instead of indices — O(h), where h is the height. On a
balanced tree h ≈ log n; on a degenerate one-sided tree h = n, which
is why balance matters so much.
Insert is the same walk: follow the search path until you fall off the tree, then hang the new node in the empty slot you landed on. A newly inserted value is always a leaf.
BValidate: bounds, not parent-child
The natural-looking check — "is my left child smaller than me, is my right child larger" — is
wrong, because the invariant is about entire subtrees. The fix is to hand each
recursive call the open interval (low, high) its value must fall inside. Going left
tightens high to the current value; going right lifts low to it.
CLowest common ancestor: BST vs. general tree
In a BST you never search. Start at the root: if both targets are smaller than
the current node, the answer is somewhere to the left; if both are larger, it is to the right.
The moment they straddle the current node — one smaller, one larger, or one of them
is the current node — you are standing on the LCA. O(h), no recursion needed.
def lowest_common_ancestor_bst(root, p, q):
current = root
while current is not None:
if p.val < current.val and q.val < current.val:
current = current.left # both targets are in the smaller half
elif p.val > current.val and q.val > current.val:
current = current.right # both targets are in the larger half
else:
# They split here (or one of them IS here) — this is the LCA.
return current
return None
In a general binary tree there is no ordering to steer by, so you have to look
everywhere. Ask both subtrees "did you find either target?" A node that gets a hit from
both sides is the split point, so it is the answer. O(n).
def lowest_common_ancestor(root, p, q):
# Base case: an empty subtree finds nothing; finding p or q means
# "the deepest interesting node in here is me".
if root is None or root is p or root is q:
return root
left_result = lowest_common_ancestor(root.left, p, q)
right_result = lowest_common_ancestor(root.right, p, q)
if left_result is not None and right_result is not None:
# One target came from each side, so the paths split right here.
return root
# Otherwise pass up whichever side found something (possibly nothing).
if left_result is not None:
return left_result
return right_result
DHeight, balance, diameter: answers that travel upward
These three are the same shape. You recurse to the bottom, and each call
returns a small fact about its own subtree to its parent, which combines the
two children's facts into its own. That is a post-order computation: do the
children first, then yourself. One pass, O(n).
| Problem | What each call RETURNS | What it records on the way up |
|---|---|---|
| Height (LC 104) | 1 + max(left, right) | nothing |
| Balanced (LC 110) | height, or -1 meaning "already broken" | nothing — the sentinel does the work |
| Diameter (LC 543) | height | best = max(best, left + right) |
| Max path sum (LC 124) | best single downward arm | best = max(best, left + right + val) |
EStructural comparison and construction
Comparison (LC 100, 572, 617) walks two trees in lockstep: recurse on
(a.left, b.left) and (a.right, b.right) together, and handle the case
where one side is None and the other is not.
Construction comes in two flavours. From a sorted array (LC 108) you pick the middle element as the root so both halves are the same size — that is what makes the result balanced. From preorder + inorder (LC 105), preorder's first element names the root and inorder tells you how many nodes belong to its left subtree.
When to reach for it
- The words "binary search tree" appear at all — immediately ask yourself
whether sorted order or
O(h)descent solves it. - You need something order-related from a tree: kth smallest, a range of values, the closest value, the smallest difference.
- The question is about the tree's shape: height, depth, balance, diameter, "is this the same tree", "is this a subtree of that one".
- You are asked to build or rebuild a tree from a list, or from two traversals.
- An answer at each node needs facts from both children — that is the post-order "return info up the stack" shape.
The four templates to memorize
T1BST search and insert
def search_bst(root, target):
current = root
while current is not None:
if target == current.val:
return current
if target < current.val:
# Everything to the right is larger than current, so the target
# cannot possibly be over there. Discard that whole subtree.
current = current.left
else:
current = current.right
return None
def insert_into_bst(root, value):
if root is None:
return TreeNode(value)
current = root
while True:
if value < current.val:
if current.left is None:
# We fell off the tree — this empty slot is where it belongs.
current.left = TreeNode(value)
return root
current = current.left
else:
if current.right is None:
current.right = TreeNode(value)
return root
current = current.right
T2Validate a BST with bounds
def is_valid_bst(root):
def check(node, low, high):
if node is None:
return True # an empty subtree is trivially valid
# The node must sit strictly inside the window its ancestors left it.
if node.val <= low or node.val >= high:
return False
# Going left tightens the ceiling; going right lifts the floor.
left_ok = check(node.left, low, node.val)
right_ok = check(node.right, node.val, high)
return left_ok and right_ok
return check(root, float("-inf"), float("inf"))
The in-order alternative is worth knowing too, because it generalizes to LC 230 and LC 501: walk in order and check each value is strictly greater than the previous one.
def is_valid_bst_inorder(root):
stack = []
current = root
previous_value = None
while current is not None or stack:
# Dive as far left as possible, remembering the way back.
while current is not None:
stack.append(current)
current = current.left
current = stack.pop() # this is the next value in sorted order
if previous_value is not None and current.val <= previous_value:
return False
previous_value = current.val
current = current.right
return True
T3Post-order: return info up the call stack
def height(node):
"""Number of nodes on the longest root-to-leaf path. Empty tree = 0."""
if node is None:
return 0
left_height = height(node.left)
right_height = height(node.right)
return 1 + max(left_height, right_height)
def is_balanced(root):
# Returns the real height, or -1 as a flag meaning "already unbalanced".
# The flag is what keeps this O(n) instead of O(n^2).
def height_or_flag(node):
if node is None:
return 0
left_height = height_or_flag(node.left)
if left_height == -1:
return -1 # bad news travels straight up
right_height = height_or_flag(node.right)
if right_height == -1:
return -1
if abs(left_height - right_height) > 1:
return -1
return 1 + max(left_height, right_height)
return height_or_flag(root) != -1
def diameter_of_binary_tree(root):
longest_path = 0 # measured in EDGES, not nodes
def height(node):
nonlocal longest_path
if node is None:
return 0
left_height = height(node.left)
right_height = height(node.right)
# A path through this node uses both arms, but a parent could only
# ever use one — so record this here and return only the better arm.
longest_path = max(longest_path, left_height + right_height)
return 1 + max(left_height, right_height)
height(root)
return longest_path
T4Build a balanced BST from a sorted array
def sorted_array_to_bst(numbers):
def build(low, high):
if low > high:
return None
# The middle element splits the slice into two halves of nearly equal
# size — that even split is exactly what makes the tree balanced.
middle = (low + high) // 2
node = TreeNode(numbers[middle])
node.left = build(low, middle - 1)
node.right = build(middle + 1, high)
return node
return build(0, len(numbers) - 1)
O(n), and the recursion depth is
O(log n).
T5Delete from a BST (the fiddly one)
def delete_node(root, key):
if root is None:
return None
if key < root.val:
root.left = delete_node(root.left, key)
elif key > root.val:
root.right = delete_node(root.right, key)
else:
# Found it. Case 1 and 2: zero or one child — promote the child.
if root.left is None:
return root.right
if root.right is None:
return root.left
# Case 3: two children. The in-order successor is the smallest value
# in the right subtree, and it is the only value that can sit here
# without breaking the invariant. Copy it up, then delete the original.
successor = root.right
while successor.left is not None:
successor = successor.left
root.val = successor.val
root.right = delete_node(root.right, successor.val)
return root
Where tree code goes wrong
node.left.val < node.val < node.right.val at every node passes trees that are
not BSTs, because a node must beat all of its ancestors, not just its parent.
Pass (low, high) down, or check the in-order sequence is increasing.
<= instead of < silently accepts a tree with two equal values.
And initialising the bounds to -2**31 instead of float("-inf") breaks
on a tree whose root really is -2147483648 — a favourite hidden test case.
height() from inside is_balanced() re-walks every
subtree once per ancestor: O(n^2) on a skewed tree. Return the height and
the verdict from one recursion — the -1 sentinel in T3 does both with one integer.
nonlocal variable and return the arm.
Related: LC 543 counts edges, so left + right is already correct —
do not add 1.
root is p or root is q means you never
report a target that is an ancestor of the other one, and the function returns None
for a perfectly valid pair. Also compare by identity, not value, unless the
problem guarantees unique values.
n, and every later search at
O(n). Balance comes from choosing the midpoint as the root, which
is why LC 108 recurses on halves instead of looping.
Practice ladder
In this order — easiest first. Tick each one as you solve it, and re-solve anything that took more than 25 minutes two days later.