Trees and Binary Search Trees Questions
Tree data structures as tested in coding interviews: binary trees, binary search trees, self-balancing trees (AVL insertion and deletion with rotations, a comparison with red-black trees, and alternatives such as treaps, splay trees and scapegoat trees), tries (prefix trees and radix trees), and N-ary, size-augmented and sum-augmented trees such as order-statistic trees. Covers traversal orders (in/pre/post-order, level-order, zigzag, iterative, generator-based and Morris variants), BST search, insertion and deletion invariants including duplicate-key policies, validation, successor and iterator design, range and kth-smallest queries, lowest common ancestor (including parent pointers, binary lifting and repeated queries on a static tree), tree reconstruction from traversals, serialization formats and parsing, converting between trees and sorted lists or linked lists, height, balance, diameter and path-sum recursion, subtree equality and hashing, persistent and snapshot-consistent trees, and how tree shape drives logarithmic versus degenerate performance. Excludes plain linked-list and stack/queue exercises with no tree in them, tree dynamic programming recurrences, general graph traversal, heap mechanics, and on-disk database index design, which are covered elsewhere.
When would you choose a BST over a hash map or a sorted array? Compare lookup speed, insertion cost, ordered traversal and range queries, and memory.
Sample Answer
Direct answer
Choose a binary search tree (BST) when you need both changing data and order: you keep inserting and deleting keys, and you also need sorted iteration, minimum and maximum, the next larger key, or "everything between A and B". A hash map is better when you only ever ask "is this exact key here?". A sorted array is better when the data rarely changes, because it is compact and binary search on it is very fast.
A BST keeps smaller keys in the left subtree and larger keys in the right subtree of every node. Inserting 50, 30, 70, 20, 40 gives this tree (height 2; heights count edges, so the empty tree is -1 and a single node is 0):
50
/ \
30 70
/ \
20 40
Reading it left subtree, node, right subtree (in-order) gives 20, 30, 40, 50, 70: sorted, with no sorting step.
Comparison
| Hash map | Sorted array | Balanced BST | |
|---|---|---|---|
| Lookup | O(1) expected | O(log n) binary search | O(log n) |
| Insert | O(1) expected | O(n): elements after the position shift | O(log n) |
| Delete | O(1) expected | O(n) | O(log n) |
| Ordered traversal | needs a sort, O(n log n) | O(n), already ordered | O(n) |
| Range query (k results) | scan all n entries, O(n) | O(log n + k) | O(log n + k) |
| Min, max, next larger | O(n) | O(1) / O(log n) | O(log n) |
| Memory | table plus spare capacity | smallest: just the values, contiguous | two child pointers per key, one allocation per node |
"Balanced" matters: a plain BST built from sorted input degenerates into a chain, every node having a single child:
chain (keys 1 to 5 inserted in order) balanced (same keys)
1 3
\ / \
2 2 4
\ / \
3 1 5
\
4
\
5
Finding 5 compares against 1, 2, 3, 4, 5 in the chain but only 3, 4, 5 in the balanced tree, and the gap grows with n. In the chain every operation becomes O(n), so in production use a self-balancing tree (AVL or red-black, two designs that rotate nodes after updates to keep the height small) or a library ordered map. Python's dict keeps insertion order, not key order, so it does not give you sorted iteration.
Measured on 1,000 random keys
The program builds all three structures from the same keys (seed 5) and counts comparisons, shifted elements and visited nodes.
import random
class Node:
def __init__(self, key):
self.key, self.left, self.right = key, None, None
def bst_insert(root, key):
"""Returns (root, comparisons)."""
if root is None:
return Node(key), 0
cur, steps = root, 0
while True:
steps += 1
side = "left" if key < cur.key else "right"
nxt = getattr(cur, side)
if nxt is None:
setattr(cur, side, Node(key))
return root, steps
cur = nxt
def bst_find(root, key):
cur, steps = root, 0
while cur:
steps += 1
if key == cur.key:
return True, steps
cur = cur.left if key < cur.key else cur.right
return False, steps
def bst_range(node, lo, hi, out, visited):
"""Keys in [lo, hi] in sorted order; skips subtrees that cannot contain any."""
if node is None:
return
visited[0] += 1
if lo < node.key:
bst_range(node.left, lo, hi, out, visited)
if lo <= node.key <= hi:
out.append(node.key)
if node.key < hi:
bst_range(node.right, lo, hi, out, visited)
def bst_height(root):
h, level = -1, [root] if root else []
while level:
h += 1
level = [c for n in level for c in (n.left, n.right) if c]
return h
def lower_bound(a, x):
"""First index with a[i] >= x, and the comparisons used (binary search)."""
lo, hi, steps = 0, len(a), 0
while lo < hi:
steps += 1
mid = (lo + hi) // 2
if a[mid] < x:
lo = mid + 1
else:
hi = mid
return lo, steps
if __name__ == "__main__":
random.seed(5)
keys = random.sample(range(100_000), 1000) # distinct keys, random arrival order
root = None
for k in keys:
root, _ = bst_insert(root, k)
table = {k: True for k in keys} # hash map
arr = sorted(keys) # sorted array
# Lookup: average comparisons over every stored key
bst_avg = sum(bst_find(root, k)[1] for k in keys) / len(keys)
arr_avg = sum(lower_bound(arr, k)[1] for k in keys) / len(keys)
print(f"lookup, average comparisons: BST {bst_avg:.1f} (height {bst_height(root)}), sorted array {arr_avg:.1f}, hash map: 1 hash + about 1 probe")
# Insert one new key that lands in the middle of the order
new = 50_001
_, bst_steps = bst_insert(root, new)
idx, find_steps = lower_bound(arr, new)
print(f"insert {new}: BST {bst_steps} comparisons, sorted array {find_steps} comparisons + {len(arr) - idx} elements shifted")
# Range query: keys between 40000 and 41999
lo, hi = 40_000, 41_999
out, visited = [], [0]
bst_range(root, lo, hi, out, visited)
start, c1 = lower_bound(arr, lo)
end, c2 = lower_bound(arr, hi + 1)
in_hash = sorted(k for k in table if lo <= k <= hi)
assert out == arr[start:end] == in_hash
print(f"range [{lo}, {hi}] returns {len(out)} keys:")
print(f" BST visits {visited[0]} nodes, sorted array uses {c1 + c2} comparisons + {end - start} copies, hash map examines all {len(table)} entries, then sorts the {len(in_hash)} hits")
print("smallest 3 keys in order:", arr[:3], "| BST in-order gives the same without a sort step")
Output:
lookup, average comparisons: BST 11.8 (height 20), sorted array 10.0, hash map: 1 hash + about 1 probe
insert 50001: BST 13 comparisons, sorted array 10 comparisons + 473 elements shifted
range [40000, 41999] returns 25 keys:
BST visits 40 nodes, sorted array uses 19 comparisons + 25 copies, hash map examines all 1000 entries, then sorts the 25 hits
smallest 3 keys in order: [9, 57, 86] | BST in-order gives the same without a sort step
Reading the output
- Lookup: the BST used about 11.8 comparisons on average (its height is 20 because it is not balanced); the sorted array used about 10.0. A hash map does one hash computation and about one probe. The hash function turns the key into a slot number, a probe is one look at one slot, and a collision is two keys that want the same slot, which the table resolves by looking at another slot. With a table that is not too full, most lookups need one or two probes whatever the number of keys, which is why the cost is called expected O(1); the small program below shows what a probe and a collision look like on 8 slots.
- Binary search (
lower_bound): it keeps a window[lo, hi)of positions that could still hold the answer, looks at the middle, and discards the half that cannot contain it. The window halves every step, so 1,000 elements need about 10 steps, as the output shows. - Insert: the array found the position in 10 comparisons but then had to shift 473 elements to make room, the cost that grows with n. The BST changed one pointer after 13 comparisons.
- Range query returning 25 keys: the BST visited 40 nodes, the array used 19 comparisons plus 25 copies, and the hash map had to look at all 1,000 entries and then sort the 25 hits.
- Why 40 nodes for 25 results:
bst_rangeenters a node, goes left only iflo < node.key, records the key if it is inside[lo, hi], and goes right only ifnode.key < hi. The 25 in-range nodes are visited once each. The other visited nodes are the ones passed on the way down to the first and last key of the range, the two lookup paths. That is the O(log n + k) shape: find the start in O(log n), then read the k results in order.
Where the 40 visits and the probes come from
The program below reuses the same 1,000 keys (seed 5) and range, splits the 40 visited nodes into the 25 inside the range and the rest, checks that the rest lie on the two lookup paths, and runs a hash table with 8 slots and linear probing (on a collision, try the next slot) on five integer keys. Real hash maps use more elaborate probing, but a probe still means one look at one slot.
import random
class Node:
def __init__(self, key):
self.key, self.left, self.right = key, None, None
def bst_insert(root, key):
if root is None:
return Node(key)
cur = root
while True:
side = "left" if key < cur.key else "right"
nxt = getattr(cur, side)
if nxt is None:
setattr(cur, side, Node(key))
return root
cur = nxt
def bst_range_visits(node, lo, hi, visited):
"""Keys of every node the range walk enters, in visiting order."""
if node is None:
return
visited.append(node.key)
if lo < node.key:
bst_range_visits(node.left, lo, hi, visited)
if node.key < hi:
bst_range_visits(node.right, lo, hi, visited)
def lookup_path(root, key):
path, cur = [], root
while cur:
path.append(cur.key)
if key == cur.key:
break
cur = cur.left if key < cur.key else cur.right
return path
random.seed(5)
keys = random.sample(range(100_000), 1000)
root = None
for k in keys:
root = bst_insert(root, k)
lo, hi = 40_000, 41_999
visited = []
bst_range_visits(root, lo, hi, visited)
inside = [k for k in visited if lo <= k <= hi]
outside = [k for k in visited if not lo <= k <= hi]
path_lo, path_hi = lookup_path(root, lo), lookup_path(root, hi)
print(f"range walk visited {len(visited)} nodes: {len(inside)} inside the range, {len(outside)} outside")
print(f"lookup path for {lo} has {len(path_lo)} nodes, lookup path for {hi} has {len(path_hi)} nodes")
print("every outside node lies on one of the two lookup paths:", all(k in path_lo or k in path_hi for k in outside))
# A hash table with 8 slots and linear probing: a probe is one look at one slot.
slots = [None] * 8
def put(key):
i = key % 8 # the hash function: key modulo the table size
probes = 1
while slots[i] is not None: # collision: the slot is taken, try the next one
i = (i + 1) % 8
probes += 1
slots[i] = key
return probes
def get(key):
i, probes = key % 8, 1
while slots[i] != key:
i = (i + 1) % 8
probes += 1
return probes
for key in (10, 18, 3, 26, 7):
print(f"put {key}: hash slot {key % 8}, probes {put(key)}")
print("table:", slots)
print("probes to find each key:", {key: get(key) for key in (10, 18, 3, 26, 7)})
# A chain versus a balanced tree on the same five keys.
chain = None
for k in (1, 2, 3, 4, 5):
chain = bst_insert(chain, k)
balanced = None
for k in (3, 2, 4, 1, 5):
balanced = bst_insert(balanced, k)
print("lookup path for 5 in the chain:", lookup_path(chain, 5), "| in the balanced tree:", lookup_path(balanced, 5))
Output:
range walk visited 40 nodes: 25 inside the range, 15 outside
lookup path for 40000 has 13 nodes, lookup path for 41999 has 16 nodes
every outside node lies on one of the two lookup paths: True
put 10: hash slot 2, probes 1
put 18: hash slot 2, probes 2
put 3: hash slot 3, probes 2
put 26: hash slot 2, probes 4
put 7: hash slot 7, probes 1
table: [None, None, 10, 18, 3, 26, None, 7]
probes to find each key: {10: 1, 18: 2, 3: 2, 26: 4, 7: 1}
lookup path for 5 in the chain: [1, 2, 3, 4, 5] | in the balanced tree: [3, 4, 5]
The range walk visits 15 nodes outside the range, and each one lies on the lookup path of the lowest or the highest key, so the extra visits are bounded by the length of those two paths (at most about twice the height), not by n. In the hash table, key 10 lands in its own slot with 1 probe; 18 hashes to the same slot 2, finds it taken and needs 2 probes; 26 also hashes to slot 2 and needs 4. Lookups repeat the same probe sequence, so a crowded table costs more probes, which is why a hash map keeps spare capacity. The last line is the chain drawing above as code: 5 steps in the chain, 3 in the balanced tree.
Decision rule
- Static or bulk-loaded data with lookups and ranges: sorted array.
- Exact-key lookups, no order needed, heavy writes: hash map.
- Heavy writes and order or range queries (leaderboards, time windows, scheduling, "nearest key"): balanced BST.
Pitfalls
- Quoting O(log n) for a BST without saying it is balanced.
- Forgetting that the hash map's O(1) is expected, not guaranteed, and that it needs a good hash function.
- Counting only comparisons and ignoring that an array insert moves O(n) elements.
- Writing the range query recursively on a degenerate tree: depth equals the height, and CPython's default limit is 1,000 frames.
Running the code
Save the listing as bst_vs_hash.py and run python3 bst_vs_hash.py (Python 3.12, standard library only). The program in the section above is self-contained: save it as range_and_probes.py and run it the same way.
Design a persistent (versioned) BST so you can query any past version. Explain path copying, the memory cost per update, how queries start from a version root, and how it compares with copying the whole tree.
Sample Answer
The idea
A persistent data structure keeps every old version readable after an update, instead of overwriting it. For a BST (binary search tree: smaller keys in the left subtree, larger in the right) the trick is path copying: never change a node that is already part of some version. To update a key, make new copies of just the nodes on the path from the root down to the change, point those copies at the new child, and reuse every other subtree untouched. The result is a new root. The old root still describes the old tree, because none of its nodes changed.
So a version is nothing but a root pointer. Keep a list roots, where roots[v] is the root of version v. Heights and depths count edges (empty tree -1, single node 0), so a node at depth d has d ancestors and an update that changes it copies d + 1 nodes.
Version 5 of the demo below looks like this:
50
/ \
30 70
/ \
20 40
Inserting 60 into version 5 copies 50 and 70 (the path, since 60 is greater than 50 and then less than 70) and creates the new leaf 60. The nodes 30, 20 and 40 are shared by version 5 and the new version.
Brute force and why it is not enough
Copy the whole tree before every update: O(n) time and O(n) new memory per update, so m updates on a tree of n nodes cost O(n m) in total. Path copying costs O(h) per update, where h is the height in edges, and for a balanced tree that is O(log n).
Approach
- Node holds
key,val,left,rightand is never assigned to after construction. - put(root, key, val) walks down from the root, recording each parent and the direction taken. At the bottom it creates the new leaf, or a copy of the existing node with the new value (overwriting a key). Then it walks the recorded path upward, creating a copy of each parent that points at the new child below it. The last copy is the new root. Everything else is shared.
- remove(root, key) records the path the same way. A node with at most one child is replaced by that child. A node with two children is replaced by its in-order successor (the next larger key: the leftmost node of its right subtree), and the nodes on the way down to that successor, called the successor spine, are copied too. In the code the loop
while succ.left is not Nonestarts atnode.rightand keeps stepping left, saving each node it leaves inspine; the node where it stops issucc, which has no left child. Rebuilding goes bottom-up: start fromsucc.right, wrap it in a copy of each spine node from the deepest one up (each copy keeps its own right subtree and takes the rebuilt chain as its left child, which removessuccfrom the chain), then make one new node carryingsucc's key and value withnode.lefton the left and the rebuilt chain on the right. If the successor is the right child itself, the spine is empty. The path above is then copied as input. If the key is absent, the old root is returned unchanged and nothing is created. - get(version, key) starts at
roots[version]and does an ordinary BST search. Old versions are queried exactly like the newest. - Branching: an update takes any version as its base and appends a new root, so versions form a tree of histories, not just a line. (Being able to update an old version, not only read it, is called full persistence; if only the newest version could be updated it would be partial persistence.) The demo's history looks like this, with version 5 updated twice:
0 (empty) -> 1 -> 2 -> 3 -> 4 -> 5 -+-> 6 (put 60) -> 8 (remove 50 from 6)
+-> 7 (remove 30)
The code is iterative, so a degenerate tree of depth 999 works without recursion limits (Python's default limit is 1000 frames).
Code
import random
class Node:
__slots__ = ("key", "val", "left", "right")
def __init__(self, key, val, left=None, right=None):
self.key, self.val, self.left, self.right = key, val, left, right
def put(root, key, val):
"""Return (new_root, nodes_created). Copies only the path from the root to the key."""
path, node = [], root
while node is not None and node.key != key:
went_left = key < node.key
path.append((node, went_left))
node = node.left if went_left else node.right
if node is None:
child = Node(key, val)
else:
child = Node(key, val, node.left, node.right) # overwrite: copy the node, share its subtrees
created = 1
for parent, went_left in reversed(path):
child = (Node(parent.key, parent.val, child, parent.right) if went_left
else Node(parent.key, parent.val, parent.left, child))
created += 1
return child, created
def remove(root, key):
path, node = [], root
while node is not None and node.key != key:
went_left = key < node.key
path.append((node, went_left))
node = node.left if went_left else node.right
if node is None:
return root, 0 # absent: the old version is reused as is
created = 0
if node.left is None:
child = node.right
elif node.right is None:
child = node.left
else: # two children: promote the in-order successor
spine, succ = [], node.right
while succ.left is not None:
spine.append(succ)
succ = succ.left
right = succ.right
for p in reversed(spine):
right = Node(p.key, p.val, right, p.right)
created += 1
child = Node(succ.key, succ.val, node.left, right)
created += 1
for parent, went_left in reversed(path):
child = (Node(parent.key, parent.val, child, parent.right) if went_left
else Node(parent.key, parent.val, parent.left, child))
created += 1
return child, created
def get(root, key):
while root is not None:
if key == root.key:
return root.val
root = root.left if key < root.key else root.right
return None
def items(root):
out, stack = [], []
while stack or root is not None:
while root is not None:
stack.append(root)
root = root.left
root = stack.pop()
out.append((root.key, root.val))
root = root.right
return out
def node_ids(*roots):
seen, stack = set(), [r for r in roots if r is not None]
while stack:
n = stack.pop()
if id(n) not in seen:
seen.add(id(n))
stack += [c for c in (n.left, n.right) if c is not None]
return seen
class VersionedMap:
def __init__(self):
self.roots = [None] # roots[v] is the tree of version v; version 0 is empty
def put(self, version, key, val):
root, created = put(self.roots[version], key, val)
self.roots.append(root)
return len(self.roots) - 1, created
def remove(self, version, key):
root, created = remove(self.roots[version], key)
self.roots.append(root)
return len(self.roots) - 1, created
def get(self, version, key):
return get(self.roots[version], key)
# 1. a short history
vm = VersionedMap()
v = 0
for k in (50, 30, 70, 20, 40):
v, made = vm.put(v, k, f"v{len(vm.roots)}")
print(f"put {k} -> version {v}, new nodes {made}, nodes in that version {len(items(vm.roots[v]))}")
v6, made = vm.put(5, 60, "late") # branch A: update version 5
print(f"put 60 on version 5 -> version {v6}, new nodes {made}")
v7, made = vm.remove(5, 30) # branch B: also update version 5
print(f"remove 30 from version 5 -> version {v7}, new nodes {made}")
for ver in (5, 6, 7):
print(f"version {ver} items:", items(vm.roots[ver]))
print("get(3, 30) =", vm.get(3, 30), "| get(5, 30) =", vm.get(5, 30), "| get(7, 30) =", vm.get(7, 30))
# a two-child delete whose successor is NOT the right child: the nodes between them are the spine
v8, made = vm.remove(6, 50) # version 6: 50 has right child 70, and 70 has left child 60
print(f"remove 50 from version 6 -> version {v8}, new nodes {made}")
print("version 8 items:", items(vm.roots[8]))
print(f"versions 6 and 8 hold {len(node_ids(vm.roots[6]))} and {len(node_ids(vm.roots[8]))} nodes, "
f"{len(node_ids(vm.roots[6]) & node_ids(vm.roots[8]))} are the same objects")
for a_, b_ in ((5, 6), (5, 7)):
ids_a, ids_b = node_ids(vm.roots[a_]), node_ids(vm.roots[b_])
print(f"versions {a_} and {b_} hold {len(ids_a)} and {len(ids_b)} nodes, {len(ids_a & ids_b)} are the same objects")
# a rotation is local too: left rotation at the root of a small tree, written in the same copy-don't-edit style
def rotate_left(node):
pivot = node.right
new_left = Node(node.key, node.val, node.left, pivot.left)
return Node(pivot.key, pivot.val, new_left, pivot.right)
def preorder(root):
out, stack = [], [root]
while stack:
n = stack.pop()
if n is not None:
out.append(n.key)
stack += [n.right, n.left]
return out
def height(root): # edges: empty tree -1, single node 0
best, stack = -1, [(root, 0)]
while stack:
n, d = stack.pop()
if n is not None:
best = max(best, d)
stack += [(n.left, d + 1), (n.right, d + 1)]
return best
tree = None
for k in (10, 20, 15, 30, 40):
tree, _ = put(tree, k, k)
turned = rotate_left(tree)
print(f"\nbefore rotation: preorder {preorder(tree)}, height {height(tree)}")
print(f"after rotation : preorder {preorder(turned)}, height {height(turned)}")
print(f"old tree still : preorder {preorder(tree)}")
new_nodes = len(node_ids(turned) - node_ids(tree))
print(f"nodes created by the rotation: {new_nodes}; nodes shared with the old tree: "
f"{len(node_ids(turned) & node_ids(tree))}")
# 2. every old version stays correct: random operations on random versions against full copies
rng = random.Random(9)
histories = set()
for _ in range(200):
vm, copies = VersionedMap(), [{}]
for step in range(30):
base = rng.randrange(len(copies))
key = rng.randrange(12)
new = dict(copies[base])
if rng.random() < 0.6:
vm.put(base, key, step)
new[key] = step
else:
vm.remove(base, key)
new.pop(key, None)
copies.append(new)
for ver, expected in enumerate(copies):
assert dict(items(vm.roots[ver])) == expected
histories.add(tuple(map(lambda d: tuple(sorted(d.items())), copies)))
print("\nrandom branching histories checked:", len(histories), "distinct")
# the same check on an in-place update, which is wrong on purpose
def put_in_place(root, key, val):
if root is None:
return Node(key, val)
node = root
while True:
if key == node.key:
node.val = val
return root
side = "left" if key < node.key else "right"
nxt = getattr(node, side)
if nxt is None:
setattr(node, side, Node(key, val))
return root
node = nxt
first = put_in_place(None, 50, "a")
second = put_in_place(first, 30, "b")
print("in-place update: version 1 should hold [(50, 'a')] but holds", items(first))
# 3. memory per update: random order, sorted order, and copying the whole tree each time
n = 1000
keys = random.Random(2).sample(range(100_000), n)
for label, order in (("random order", keys), ("ascending order", sorted(keys))):
vm, v, created_total, full_copy_total = VersionedMap(), 0, 0, 0
for i, k in enumerate(order):
v, made = vm.put(v, k, i)
created_total += made
full_copy_total += i + 1
distinct = len(node_ids(*vm.roots))
print(f"{label:>15}: {n} versions, path-copy nodes {created_total:,} (distinct alive {distinct:,}), "
f"whole-tree copies {full_copy_total:,}")
Output
put 50 -> version 1, new nodes 1, nodes in that version 1
put 30 -> version 2, new nodes 2, nodes in that version 2
put 70 -> version 3, new nodes 2, nodes in that version 3
put 20 -> version 4, new nodes 3, nodes in that version 4
put 40 -> version 5, new nodes 3, nodes in that version 5
put 60 on version 5 -> version 6, new nodes 3
remove 30 from version 5 -> version 7, new nodes 2
version 5 items: [(20, 'v4'), (30, 'v2'), (40, 'v5'), (50, 'v1'), (70, 'v3')]
version 6 items: [(20, 'v4'), (30, 'v2'), (40, 'v5'), (50, 'v1'), (60, 'late'), (70, 'v3')]
version 7 items: [(20, 'v4'), (40, 'v5'), (50, 'v1'), (70, 'v3')]
get(3, 30) = v2 | get(5, 30) = v2 | get(7, 30) = None
remove 50 from version 6 -> version 8, new nodes 2
version 8 items: [(20, 'v4'), (30, 'v2'), (40, 'v5'), (60, 'late'), (70, 'v3')]
versions 6 and 8 hold 6 and 5 nodes, 3 are the same objects
versions 5 and 6 hold 5 and 6 nodes, 3 are the same objects
versions 5 and 7 hold 5 and 4 nodes, 2 are the same objects
before rotation: preorder [10, 20, 15, 30, 40], height 3
after rotation : preorder [20, 10, 15, 30, 40], height 2
old tree still : preorder [10, 20, 15, 30, 40]
nodes created by the rotation: 2; nodes shared with the old tree: 3
random branching histories checked: 200 distinct
in-place update: version 1 should hold [(50, 'a')] but holds [(30, 'b'), (50, 'a')]
random order: 1000 versions, path-copy nodes 12,792 (distinct alive 12,792), whole-tree copies 500,500
ascending order: 1000 versions, path-copy nodes 500,500 (distinct alive 500,500), whole-tree copies 500,500
Reading the output
- Each
putreports the nodes it created, equal to the depth of the changed position plus 1: key 20 went under 30 under 50, so 3 nodes. Putting 60 on version 5 created 3 nodes (60, a copy of 70, a copy of 50) and shares the remaining 3 nodes (30, 20, 40) with version 5. remove 30from version 5 replaced node 30 (two children, successor 40 with no left child) by a new node 40, then copied the root: 2 nodes created, with 20 and 70 shared.remove 50from version 6 is the two-child case with a spine: 50 has right child 70, and 70 has left child 60, so the spine is just[70]and the successor is 60. The code creates a copy of 70 (now without its left child) and a new node 60 that takes 50's place with the old left subtree (30, 20, 40) shared untouched: 2 new nodes, and versions 6 and 8 share 3 objects.- The rotation lines show a left rotation at the root of the tree 10, 20 (children 15 and 30), 30 (child 40). Node 20 moves up, a new copy of 10 hangs under it, and 15 and the subtree 30-40 are re-hung without being copied: 2 nodes created, 3 shared, height 3 before and 2 after, and the old tree still reads exactly as before.
- Versions 5, 6 and 7 are three different histories hanging off version 5, and
get(5, 30)still returnsv2whileget(7, 30)returnsNone. Old versions are never affected. - The random-history check applies 30 random operations to randomly chosen base versions, 200 times, and after every operation compares every version with a plain dictionary copy made at that moment. The line
in-place updateshows the symptom that comparison guards against: an update that edits nodes in place changes the older version too (version 1 should hold only key 50 but also holds 30). - For 1,000 keys inserted in random order, the 1,000 versions together contain 12,792 nodes (about 12.8 new nodes per update) where 500,500 would be needed if each version were a separate copy: roughly 39 times fewer.
- For 1,000 keys inserted in ascending order the tree is a chain, every update copies the whole path, and path copying costs exactly what whole-tree copying costs: 500,500 nodes either way.
Memory cost per update and how it compares
| Path copying | Copy the whole tree | |
|---|---|---|
| New nodes per update | depth of the change + 1 (plus a successor spine for a two-child delete) | n |
| Time per update | O(h) | O(n) |
| Balanced tree, n = 1,000 (height 9 edges) | at most 10 nodes to change or delete an existing key, at most 11 to insert a new key (its leaf can sit one level below the current deepest) | 1,000 |
| Read an old version | Search from its root, O(h) | Search its own copy, O(h) |
| Space after m updates | O(n + sum of path lengths) | O(n m) |
Path copying only pays off when h is small, so use a self-balancing tree as the base: one that keeps its height O(log n) by rewriting a few nodes after each update (AVL and red-black trees do this with rotations, a treap does it with random priorities). A typical interview needs the plain BST version above and the point that balance keeps h small; naming one of those trees as the base is enough, and the details of rotations are deeper reading than a typical interview asks for. A rotation is a local rewrite of a parent and one child that moves the child up and re-hangs the subtrees around them; the run above performs one. It rewrites a constant number of nodes at or next to the update path (a rotation after a delete can involve a sibling just off the path), and a path-copying update copies those nodes too; the subtrees it re-hangs are shared, not copied, so the copy count stays O(log n) per update. A plain BST can be a chain, as the ascending run shows.
Garbage: a node is freed once no version reaches it. In Python, dropping roots[v] is enough: reference counting (the runtime keeps a count of how many references point at each object and frees it at zero) releases the nodes no other root reaches. Other runtimes use a garbage collector (a background process that finds and frees objects nothing can reach) or an explicit version-expiry policy. Keeping every version forever costs the totals in the table.
Structural sharing in lists
A persistent singly linked list is path copying with a path of length 1: prepending an element creates one new node whose next points at the entire old list, so every older version is a suffix shared by the newer ones. This reuse of unchanged parts between versions is called structural sharing. The tree case generalizes this: a list has one place to change (the front), while a tree has an arbitrary position and so copies the path to it.
Complexity
get: O(h) time, O(1) space.put: O(h) time and O(h) new nodes, plus O(h) for the recorded path.remove: O(h) time and nodes in the worst case.- Space across m updates to a tree that starts with n nodes: O(n + sum over updates of the path length), which is O(n + m log n) with balancing and O(n + m n) for a chain.
- Listing a version in order: O(size) with an explicit stack.
Edge cases and common mistakes
- Mutating a node "in place" for speed: every older version that reaches that node changes too. The cheap fix is to treat the
Nodeclass as frozen. - Forgetting that the new root must be stored: the updated tree is only reachable through the new root.
- Updating a missing key on delete: return the old root and create nothing.
- Updating the same key again: the overwritten node is copied, not edited, so the older value stays with the older version.
- A plain BST fed sorted keys: no better than copying the whole tree.
- Using recursion for the copy on a deep tree, which fails past the recursion limit.
Running the code
python3.12 persistent_bst.py
Save the code block as persistent_bst.py. Standard library only (random).
Serialize and deserialize a binary tree with up to ten million nodes in Python, with preorder and null markers. How do you stream the output to disk and read it back without holding every token in memory?
Sample Answer
The idea
Serializing a tree means turning it into a flat sequence you can store; deserializing is the reverse. For a binary tree, write a preorder walk (node, then left subtree, then right subtree) and emit a marker, here #, wherever a child is missing. That sequence is self-delimiting (it shows by itself where it ends): the reader knows a subtree is finished exactly when its null markers close it, so no lengths or brackets are needed, and a tree with n nodes always produces exactly 2n + 1 tokens (n values and n + 1 null markers).
Memory is the real question at ten million nodes, and two things are easy to get wrong:
- Writing: never build the full token list. Walk the tree with an explicit stack and flush small batches to the file.
- Reading: never call
read().split(). Read the file in fixed-size blocks, hand out tokens lazily (one at a time as the caller asks, using a Python generator, a function thatyields a value and pauses until the next one is requested), and rebuild the tree from that token stream while keeping only a stack of nodes whose right child is still open.
Heights count edges here (empty tree -1, one node 0), so a tree with h edges on its longest path needs a stack of at most about h entries.
Brute force and why it is not enough
The textbook version is two recursive functions, one building a Python list of strings and one reading from that list. Two problems at 10 million nodes: the token list for 20,000,001 tokens is large: every value token is its own Python string object, and the list alone needs 8 bytes per slot, 160,000,008 bytes before counting any string (both figures are printed by the last lines of the program), and recursion fails on a skewed tree because CPython allows about 1000 nested calls by default.
Approach
-
Writer: push the root on a stack. Pop a node, append its value (or
#forNone), push the right child and then the left child so the left is processed first. When the buffer reaches 4096 tokens, write it out and clear it. -
Token reader: read 64 KiB (65,536 characters; 1 KiB is 1024) at a time, split on spaces, and keep the last piece back (
carry) because it may be half of a token that continues in the next block. Yield tokens one by one. -
Rebuilder: keep the slot that the next token fills (a parent and left-or-right) and a stack
right_openof nodes whose left subtree is being filled and whose right subtree is still to come. A real node becomes the parent of the next slot. A#finishes the current slot, so jump to the most recent node on the stack and fill its right slot. When the stack is empty after a#, the tree is complete; any further token is an error, and running out of tokens early is an error too.Traced on the file
1 2 # # 3 4 # # #(the program prints these lines, one per token). Token 1 is the root, so the next slot is the left of 1. Token 2 fills that slot,1goes onright_open(its right is still to come), and the next slot is the left of 2. The first#fills the left of 2 with nothing, so the next slot is the right of 2. The second#fills that, and 2 is finished, so1is popped and the next slot is the right of 1. Token 3 fills it, token 4 fills the left of 3 (pushing 3), two more#close 4 and pop 3, and the last#fills the right of 3, leaving the stack empty: the tree is complete. -
If the caller only needs a statistic (a count, a sum, a search), the rebuilder can be replaced by a consumer that processes each node as it appears, and then even the output tree is never held.
Code
import io
import os
import random
import sys
import tempfile
NULL = "#"
class Node:
__slots__ = ("val", "left", "right")
def __init__(self, val, left=None, right=None):
self.val, self.left, self.right = val, left, right
def write_tree(root, out, batch=4096, stats=None):
"""Iterative preorder; tokens leave memory in batches of `batch`."""
stack, buf = [root], []
while stack:
if stats is not None:
stats["max_stack"] = max(stats.get("max_stack", 0), len(stack))
node = stack.pop()
if node is None:
buf.append(NULL)
else:
buf.append(str(node.val))
stack.append(node.right) # pushed first so the left child is popped first
stack.append(node.left)
if stats is not None:
stats["max_buffer"] = max(stats.get("max_buffer", 0), len(buf))
if len(buf) >= batch:
out.write(" ".join(buf) + " ")
buf.clear()
out.write(" ".join(buf) + " ")
def iter_tokens(fp, block=64 * 1024, stats=None):
"""Yield space-separated tokens (lazily: a generator, so each `yield` hands one token to the caller
and pauses), reading `block` characters at a time."""
carry = ""
while True:
chunk = fp.read(block)
if not chunk:
break
parts = (carry + chunk).split(" ")
carry = parts.pop() # the last piece may be a token cut in half
if stats is not None:
stats["max_tokens_alive"] = max(stats.get("max_tokens_alive", 0), len(parts))
for p in parts:
if p:
yield p
if carry:
yield carry
def naive_tokens(fp, block):
"""Broken on purpose: splits every block on its own and drops the carry."""
while chunk := fp.read(block):
yield from chunk.split()
def read_tree(tokens, trace=None, stats=None):
"""Rebuild from a token iterator using a stack of nodes whose right slot is still open.
State: `parent` and `filling_left` name the slot the next token fills."""
it = iter(tokens)
first = next(it, None)
if first is None:
raise ValueError("tokens ended before the tree was complete")
if first == NULL:
if next(it, None) is not None:
raise ValueError("extra tokens after the tree is complete")
return None
root = Node(int(first))
parent, filling_left = root, True
right_open = []
if trace is not None:
trace.append(f"token {first:>2}: next slot = left of {parent.val}, right_open = []")
for tok in it:
if parent is None:
raise ValueError("extra tokens after the tree is complete")
child = None if tok == NULL else Node(int(tok))
if filling_left:
parent.left = child
right_open.append(parent)
else:
parent.right = child
if stats is not None: # measured before the pop, when the stack is at its fullest
stats["max_open"] = max(stats.get("max_open", 0), len(right_open))
if child is not None:
parent, filling_left = child, True
elif right_open:
parent, filling_left = right_open.pop(), False
else:
parent = None
if trace is not None:
slot = "none, tree complete" if parent is None else f"{'left' if filling_left else 'right'} of {parent.val}"
trace.append(f"token {tok:>2}: next slot = {slot}, right_open = {[n.val for n in right_open]}")
if parent is not None:
raise ValueError("tokens ended before the tree was complete")
return root
def same_tree(a, b):
stack = [(a, b)]
while stack:
x, y = stack.pop()
if x is None or y is None:
if x is not y:
return False
continue
if x.val != y.val:
return False
stack.append((x.left, y.left))
stack.append((x.right, y.right))
return True
def left_chain(n):
root = cur = Node(1)
for v in range(2, n + 1):
cur.left = Node(v)
cur = cur.left
return root
def random_tree(n, seed):
rng = random.Random(seed)
root = Node(1)
slots = [(root, 0), (root, 1)]
for v in range(2, n + 1):
i = rng.randrange(len(slots))
slots[i], slots[-1] = slots[-1], slots[i]
parent, side = slots.pop()
node = Node(v)
setattr(parent, "left" if side == 0 else "right", node)
slots += [(node, 0), (node, 1)]
return root
def chain(n):
root = cur = Node(1)
for v in range(2, n + 1):
cur.right = Node(v)
cur = cur.right
return root
# 1. a small tree, what the file holds
small = Node(1, Node(2), Node(3, Node(4), None))
buf = io.StringIO()
write_tree(small, buf)
print("small tree file:", repr(buf.getvalue()))
steps = []
buf.seek(0)
read_tree(iter_tokens(buf), trace=steps)
print("\n".join(steps))
# 2. round trips, including block sizes that cut tokens in half
cases = 0
for seed in range(200):
n = random.Random(seed).randint(0, 40)
t = random_tree(n, seed) if n else None
s = io.StringIO()
write_tree(t, s, batch=3)
for block in (1, 2, 5, 64 * 1024):
s.seek(0)
assert same_tree(read_tree(iter_tokens(s, block)), t)
cases += 1
print("round trips checked:", cases)
big_tokens = io.StringIO()
write_tree(Node(1234567, Node(7654321)), big_tokens)
big_tokens.seek(0)
good = list(iter_tokens(big_tokens, 4))
big_tokens.seek(0)
bad = list(naive_tokens(big_tokens, 4))
print("carry reader :", good)
print("no-carry read:", bad)
# 3. memory, counted in tokens instead of bytes: 300,000 nodes
N = 300_000
tree = random_tree(N, 1)
with tempfile.TemporaryDirectory() as d:
path = os.path.join(d, "tree.txt")
write_stats = {}
with open(path, "w") as f:
write_tree(tree, f, stats=write_stats)
size = os.path.getsize(path)
with open(path) as f:
n_tokens = len(f.read().split())
stream_stats = {}
with open(path) as f:
count = sum(1 for _ in iter_tokens(f, stats=stream_stats))
rebuild_stats = {}
with open(path) as f:
rebuilt = read_tree(iter_tokens(f), stats=rebuild_stats)
assert same_tree(rebuilt, tree) and count == n_tokens
print(f"nodes {N:,} tokens {n_tokens:,} (= 2n+1: {n_tokens == 2 * N + 1}) file bytes {size:,}")
print(f"writer: at most {write_stats['max_buffer']:,} tokens buffered, at most {write_stats['max_stack']:,} entries on its stack")
print(f"read().split(): {n_tokens:,} tokens alive at once")
print(f"streamed reader: at most {stream_stats['max_tokens_alive']:,} tokens alive at once (one 64 KiB block)")
print(f"read_tree: right_open never held more than {rebuild_stats['max_open']:,} nodes")
for name, shape in (("right-leaning chain of 1000", chain(1000)), ("left-leaning chain of 1000", left_chain(1000))):
s, st = io.StringIO(), {}
write_tree(shape, s)
s.seek(0)
read_tree(iter_tokens(s), stats=st)
print(f" {name}: right_open peaks at {st.get('max_open', 0):,}")
# 4. a chain 1,000,000 deep: no recursion anywhere
with tempfile.TemporaryDirectory() as d:
path = os.path.join(d, "chain.txt")
chain_tree = chain(1_000_000)
with open(path, "w") as f:
write_tree(chain_tree, f)
with open(path) as f:
again = read_tree(iter_tokens(f))
print("1,000,000-deep chain round trip:", same_tree(again, chain_tree))
# 4b. malformed input, and a recursive writer on a deep chain
for text in ("", "# 5", "1 #", "1 # # 5"):
try:
read_tree(iter_tokens(io.StringIO(text)))
outcome = "accepted"
except ValueError as err:
outcome = f"ValueError: {err}"
print(f"{text!r:10} -> {outcome}")
def recursive_write(node, out):
if node is None:
out.append(NULL)
return
out.append(str(node.val))
recursive_write(node.left, out)
recursive_write(node.right, out)
try:
recursive_write(chain(2000), [])
except RecursionError:
print("recursive writer on a 2000-deep chain: RecursionError")
# 5. file size for ten million nodes labelled 1..10,000,000
n = 10_000_000
digits = sum(len(str(v)) for v in range(1, n + 1))
print(f"10,000,000 nodes: tokens {2 * n + 1:,}, bytes {digits + n + 2 * (n + 1):,}")
print(f"a list of those tokens needs {8 * (2 * n + 1):,} bytes for its 8-byte slots alone; "
f"one 7-character token string is {sys.getsizeof('1234567')} bytes on this build")
Output
small tree file: '1 2 # # 3 4 # # # '
token 1: next slot = left of 1, right_open = []
token 2: next slot = left of 2, right_open = [1]
token #: next slot = right of 2, right_open = [1]
token #: next slot = right of 1, right_open = []
token 3: next slot = left of 3, right_open = []
token 4: next slot = left of 4, right_open = [3]
token #: next slot = right of 4, right_open = [3]
token #: next slot = right of 3, right_open = []
token #: next slot = none, tree complete, right_open = []
round trips checked: 800
carry reader : ['1234567', '7654321', '#', '#', '#']
no-carry read: ['1234', '567', '7654', '321', '#', '#', '#']
nodes 300,000 tokens 600,001 (= 2n+1: True) file bytes 2,588,897
writer: at most 4,096 tokens buffered, at most 29 entries on its stack
read().split(): 600,001 tokens alive at once
streamed reader: at most 15,217 tokens alive at once (one 64 KiB block)
read_tree: right_open never held more than 28 nodes
right-leaning chain of 1000: right_open peaks at 1
left-leaning chain of 1000: right_open peaks at 1,000
1,000,000-deep chain round trip: True
'' -> ValueError: tokens ended before the tree was complete
'# 5' -> ValueError: extra tokens after the tree is complete
'1 #' -> ValueError: tokens ended before the tree was complete
'1 # # 5' -> ValueError: extra tokens after the tree is complete
recursive writer on a 2000-deep chain: RecursionError
10,000,000 nodes: tokens 20,000,001, bytes 98,888,899
a list of those tokens needs 160,000,008 bytes for its 8-byte slots alone; one 7-character token string is 48 bytes on this build
Reading the output
small tree fileshows the format for node 1 with children 2 and 3, where 3 has a left child 4:1 2 # # 3 4 # # #. Count: four values and five markers, 2n + 1 = 9 tokens.- The 800 round trips include blocks of 1 and 2 characters that cut tokens in half. The pair of lines after it feeds the value
1234567through a block size of 4: the reader with a carry returns it whole, while a reader that drops the carry splits it into1234and567. That is the bug the carry exists to prevent. - Memory is reported as counts of live tokens and nodes, which are the same on every run. For 300,000 nodes (600,001 tokens)
read().split()holds all 600,001 tokens at once, while the streamed reader holds at most 15,217 (the tokens in one 64 KiB block, set by the block size and not by n). The writer buffers at most 4,096 tokens, and its stack (which also holds the empty children still to be written) never exceeds 29 entries on this random tree. read_treekept at most 28 nodes onright_openfor this tree; everything else it allocates is the rebuilt tree itself. That cost is unavoidable if the caller needs the whole tree back; item 4 above is the way around it.- The two chains show the stack extremes. On a right-leaning chain of 1000 nodes every node's left slot is empty, so a node is pushed for an instant and popped as soon as the
#is read (peak 1). On a left-leaning chain of 1000 nodes every ancestor stays on the stack until the bottom is reached, and the deepest node is pushed too when its empty left child arrives (peak 1,000). The peak is read before the pop, when the stack is fullest. - A right-leaning chain of 1,000,000 nodes (depth one million) round-trips with no recursion.
- With node values 1 to 10,000,000, the file holds 20,000,001 tokens and 98,888,899 bytes (about 94 MiB). A Python list of those tokens would need 160,000,008 bytes just for its 8-byte slots, plus a separate string object (48 bytes for a 7-character token on this build) for each value token.
Complexity
- Time O(n) for writing and for reading: each token is produced or consumed once. Python-level work per token is constant, with
str(val)andint(tok)linear in the digits of the value. - Writer space: O(h + batch) for the stack and the 4096-token buffer, besides the tree it was handed.
- Reader space: O(h + block) for the open-right stack and the block, plus O(n) only for the tree being returned. The open-right stack holds ancestors whose right child is pending, so it is largest on a left-leaning chain (up to n entries; 1,000 for the 1000-node chain printed above) and holds at most one entry on a right-leaning one (peak 1 in the same output).
- Disk traffic is purely sequential, one pass each way.
Edge cases and common mistakes
- Empty tree: writes
#, reads backNone. One node:v # #. - An empty file, a truncated file or extra trailing tokens raise
ValueErrorinstead of returning a half-built tree or silently ignoring the rest; the four malformed inputs printed after the deep-chain line show each one, including a stray token after a lone#. - Splitting every block independently, which cuts a token in half at a block boundary (shown above).
- Values containing spaces or the marker character: use a length-prefixed encoding (each value preceded by its length, so the reader never has to guess where it ends), a binary encoding, or JSON lines (one JSON value per line), instead of a space-separated text format.
- Writing the tokens with
printper token orfile.writeper token is slow; batch them. - Recursion: a recursive serializer or builder fails on a deep tree (the recursive writer in the program raises
RecursionErroron a chain 2000 deep), which a corrupt or adversarial file (one crafted to break the reader) can supply. The loops above never recurse. - Compact storage: a binary format (for example 4-byte integers plus a bit stream for the null markers) or a compressed stream wrapped around the same token loop changes only how tokens are written and split; the rebuild loop stays as it is.
Running the code
python3.12 stream_tree_to_disk.py
Save the code block as stream_tree_to_disk.py. Standard library only. Every printed figure is a count (tokens, nodes, bytes of file), so the output is the same on each run; the 48-byte string size is specific to a 64-bit CPython 3.12 build.
How do you validate that a binary tree is a BST? Why is comparing each node only to its immediate children not enough? Compare the min/max range method with the inorder method.
Sample Answer
The idea
A binary search tree (BST) must keep order across whole subtrees: every key in a node's left subtree is smaller than the node, and every key in its right subtree is larger. Checking only the immediate children tests a much weaker rule, "left child < node < right child", and a tree can obey that everywhere while still being broken, because a deeper node can be on the wrong side of a distant ancestor.
Why comparing with the immediate children is not enough
50
/ \
30 70
/ \ \
20 60 80
Check each parent against its children: 30 has children 20 and 60 (20 < 30 < 60, passes); 50 has children 30 and 70 (passes); 70 has right child 80 (passes). Every local test passes. Yet 60 is in the left subtree of 50 and is larger than 50, so a search for 60 that starts at the root goes right at 50 (60 > 50) and never finds it. The rule that was broken connects 60 to 50, which are grandparent and grandchild, and no check on single edges ever compares them.
The two correct methods
- Min/max range method. Carry an allowed open interval (both endpoints excluded, so
(30, 50)allows 31 to 49 but not 30 or 50) down the recursion. Going left sets the upper bound to the current key; going right sets the lower bound to the current key. Each node is tested against everything above it at once, because the interval already summarizes all ancestors. In the example, 60 arrives with the interval (30, 50) and fails the upper bound. - Inorder method. An inorder traversal (left subtree, node, right subtree) of a valid BST lists keys in strictly increasing order, and a tree whose inorder list is strictly increasing is a valid BST. Walk the tree and compare each key only with the one before it. In the example the list is 20, 30, 60, 50, 70, 80, which falls at 50. Step by step the comparisons are: 30 after 20 (increasing, fine), 60 after 30 (fine), 50 after 60 (not increasing, so the tree is invalid); the program prints exactly these three comparisons.
Code
class Node:
def __init__(self, key, left=None, right=None):
self.key = key
self.left = left
self.right = right
def local_only(node):
"""Wrong: compares each node with its immediate children only."""
if node is None:
return True
if node.left and not node.left.key < node.key:
return False
if node.right and not node.right.key > node.key:
return False
return local_only(node.left) and local_only(node.right)
def range_check(node, low=float("-inf"), high=float("inf"), seen=None, steps=None):
if node is None:
return True
if seen is not None:
seen.append(node.key)
if steps is not None:
steps.append(f"{node.key} must be in ({low}, {high}): {low < node.key < high}")
if not low < node.key < high:
return False
return (range_check(node.left, low, node.key, seen, steps)
and range_check(node.right, node.key, high, seen, steps))
def inorder_check(root, seen=None):
stack, cur, previous = [], root, None
while stack or cur:
while cur:
stack.append(cur)
cur = cur.left
cur = stack.pop()
if seen is not None:
seen.append(cur.key)
if previous is not None and cur.key <= previous:
return False
previous = cur.key
cur = cur.right
return True
# 50
# / \
# 30 70
# / \ \
# 20 60 80 60 is a right child of 30, yet it sits left of 50
trap = Node(50, Node(30, Node(20), Node(60)), Node(70, None, Node(80)))
print("trap tree: local_only =", local_only(trap),
"| range =", range_check(trap), "| inorder =", inorder_check(trap))
# 50
# /
# 60 every key on the left of 50 must be below 50
# /
# 55
# /
# 52
leaning = Node(50, Node(60, Node(55, Node(52))))
steps, order = [], []
range_check(trap, steps=steps)
inorder_check(trap, order)
print("range method steps on the trap tree:")
print("\n".join(" " + s for s in steps))
print("inorder method on the trap tree:",
"; ".join(f"{p} then {c}: {'ok' if c > p else 'NOT increasing'}" for p, c in zip(order, order[1:])))
# 50
# \
# 70 40 is in the right subtree of 50 but is smaller than 50
# /
# 60
# /
# 40
deep_right = Node(50, None, Node(70, Node(60, Node(40))))
by_range, by_inorder = [], []
print("deep-right tree: range =", range_check(deep_right, seen=by_range),
"| inorder =", inorder_check(deep_right, seen=by_inorder))
print(" range examined keys :", by_range)
print(" inorder examined keys:", by_inorder)
by_range, by_inorder = [], []
print("leaning tree: range =", range_check(leaning, seen=by_range),
"| inorder =", inorder_check(leaning, seen=by_inorder))
print(" range examined keys :", by_range)
print(" inorder examined keys:", by_inorder)
Run in a python:3.12 container, it prints:
trap tree: local_only = True | range = False | inorder = False
range method steps on the trap tree:
50 must be in (-inf, inf): True
30 must be in (-inf, 50): True
20 must be in (-inf, 30): True
60 must be in (30, 50): False
inorder method on the trap tree: 20 then 30: ok; 30 then 60: ok; 60 then 50: NOT increasing
deep-right tree: range = False | inorder = False
range examined keys : [50, 70, 60, 40]
inorder examined keys: [50, 40]
leaning tree: range = False | inorder = False
range examined keys : [50, 60]
inorder examined keys: [52, 55, 60, 50]
Comparing the two methods
| Range method | Inorder method | |
|---|---|---|
| State carried (extra values remembered while walking) | two bounds per call | one previous key |
| Natural form | recursion (a node is checked before its children, which is the preorder order) | iterative stack or recursion |
| Order of examination | top-down, root first | smallest key first |
| Time | O(n), stops at first violation | O(n), stops at first violation |
| Extra space | O(h) call stack | O(h) stack, no recursion-limit risk |
| Reports where the break is | the node and the bound it broke (60 broke the bound 50 above) | the pair of adjacent keys (60 then 50) |
Here n is the number of nodes and h the height. Neither is faster in general: on the leaning tree in the code, the range method examines 50 and 60 and stops, while the inorder method must first walk down to 52 and examines 52, 55, 60, 50 (the printed lists), and other trees favour the inorder method instead. On the deep-right tree in the code (50, then a right chain 70, 60, 40 where 40 is smaller than 50), the range method examines 50, 70, 60, 40 while the inorder method examines only 50 and 40, because the inorder walk reaches the smallest key of the right subtree first and compares it with 50 at once. The printed lists show both counts. I would write the range method first in an interview because it explains the idea (each node must satisfy all its ancestors), and switch to the inorder version when the tree can be very deep, since a recursive version in Python fails past about 1000 levels.
Common mistakes
- Thinking "left child smaller, right child larger" is the whole definition.
- Letting equal keys pass when the BST has unique keys: a range test written
low <= key <= high, or an inorder test that fails only oncur.key < previous(the shipped code fails oncur.key <= previous). Both accept the tree 2 with children 2 and 3. - Throwing away the inherited bound instead of keeping it: passing the left child
(-inf, key)and the right child(key, +inf)at every level. Each node is then checked against its parent only, and the trap tree above is accepted.
Running the code
Save as bst_validation_compare.py and run python bst_validation_compare.py with Python 3.12. Standard library only.
What is a degenerate (skewed) BST? How does it arise, what does it do to search, insert and delete, and how do you prevent it?
Sample Answer
The idea in plain words
A binary search tree (BST) keeps one rule at every node: everything in the left subtree is smaller, everything in the right subtree is larger. Search, insert and delete each walk one path from the root downward, so their cost is the length of that path, which is bounded by the tree's height. A degenerate (skewed) BST is one where nearly every node has only one child, so the tree is really a linked list that happens to have left and right pointers. Its height is n instead of about log2(n), and every operation falls from O(log n) to O(n).
Height convention used throughout: height is the number of nodes on the longest root-to-leaf path, and an empty tree has height 0. A single node has height 1. (Counting edges instead, the other common convention, gives every height here one less: 999, 24 and 9 below, and 0 for a single node.)
How it arises
A plain BST never rearranges itself, so its shape is decided entirely by insertion order. Each new key goes to a leaf position. If keys arrive already sorted (ascending or descending), every new key is larger (or smaller) than everything before it, so it always goes to the same side of the last node. Nearly sorted input, such as timestamps, auto-increment ids or a file read in key order, does the same. Deleting from one side repeatedly can also leave a lopsided tree.
Here is the shape for the same five keys in two insertion orders (R: and L: mark the side, children are indented under their parent):
Insert 1,2,3,4,5 Insert 3,1,4,2,5
1 3
R:2 L:1
R:3 R:2
R:4 R:4
R:5 R:5
The first tree has height 5 for 5 keys. The second has height 3. Same keys, same rule, different shape.
What it does to search, insert and delete
- Search: on the right-leaning tree built from ascending keys, finding the largest key visits every node (on a left-leaning tree built from descending keys the smallest key is the one that costs every node), so O(n) instead of O(log n).
- Insert: a new key must walk down to a leaf, so O(h) = O(n). Inserting n sorted keys one by one costs 1 + 2 + ... + (n-1) = n(n-1)/2 comparisons, which is O(n^2) in total, versus about n log n for a balanced build.
- Delete: first find the node (O(h)). A node with two children is replaced by its in-order successor (the smallest key in its right subtree), which is another walk down O(h). Both parts are O(n) on a skewed tree.
- Recursive traversals: a recursive inorder, preorder or postorder uses one call frame per level (a call frame is the block of memory Python sets aside for one active function call; active calls pile up on top of each other, which is the call stack), so a skewed tree needs n frames. CPython's default limit is 1000 frames, and a deeper nesting raises
RecursionError(the exception Python raises when calls nest past that limit), so a skewed tree of about a thousand nodes already triggers it (the program below uses 2000). An iterative inorder traversal avoids that limit because its explicit stack lives on the heap, not on the call stack. That stack holds the left spine, so it holds every node on a left-leaning chain (O(n) memory) but only one node at a time on a right-leaning chain such as the sorted-insert tree above, and about the height (10 for 1000 nodes) on a balanced tree.
Measuring it
The program inserts the same 1000 keys sorted and shuffled (fixed seed), counts comparisons, and then rebuilds a perfectly balanced tree from the sorted keys.
import random
class Node:
def __init__(self, key):
self.key = key
self.left = None
self.right = None
def insert(root, key):
"""Iterative insert. Returns (root, comparisons made)."""
comparisons = 0
if root is None:
return Node(key), 0
cur = root
while True:
comparisons += 1
if key < cur.key:
if cur.left is None:
cur.left = Node(key)
return root, comparisons
cur = cur.left
else:
if cur.right is None:
cur.right = Node(key)
return root, comparisons
cur = cur.right
def search_cost(root, key):
"""Number of nodes visited while searching for key."""
visited = 0
cur = root
while cur:
visited += 1
if key == cur.key:
return visited
cur = cur.left if key < cur.key else cur.right
return visited
def height(root):
"""Height = number of nodes on the longest root-to-leaf path (empty tree = 0). Iterative level count."""
if root is None:
return 0
level, h = [root], 0
while level:
h += 1
level = [c for n in level for c in (n.left, n.right) if c]
return h
def draw(node, indent="", tag=""):
"""Print the tree sideways: children are indented under their parent, L: / R: mark the side."""
print(indent + tag + str(node.key))
if node.left:
draw(node.left, indent + " ", "L:")
if node.right:
draw(node.right, indent + " ", "R:")
def build(keys):
root, total = None, 0
for k in keys:
root, c = insert(root, k)
total += c
return root, total
def from_sorted(keys, lo=0, hi=None):
"""Build a perfectly balanced BST from sorted keys (recursion depth ~log n)."""
if hi is None:
hi = len(keys)
if lo >= hi:
return None
mid = (lo + hi) // 2
n = Node(keys[mid])
n.left = from_sorted(keys, lo, mid)
n.right = from_sorted(keys, mid + 1, hi)
return n
small = [1, 2, 3, 4, 5]
print("Insert 1,2,3,4,5 in that order:")
draw(build(small)[0])
print("Insert 3,1,4,2,5:")
draw(build([3, 1, 4, 2, 5])[0])
N = 1000
sorted_keys = list(range(1, N + 1))
shuffled = sorted_keys[:]
random.Random(7).shuffle(shuffled)
for name, keys in [("sorted", sorted_keys), ("shuffled", shuffled)]:
root, total = build(keys)
print(f"{name:9} n={N} height={height(root)} insert comparisons={total} search(1000) visits={search_cost(root, N)}")
bal = from_sorted(sorted_keys)
print(f"{'rebuilt':9} n={N} height={height(bal)} search(1000) visits={search_cost(bal, N)}")
# recursion limit on a skewed tree
import sys
def inorder_recursive(n, out):
if n:
inorder_recursive(n.left, out)
out.append(n.key)
inorder_recursive(n.right, out)
print("recursion limit:", sys.getrecursionlimit())
skew, _ = build(list(range(1, 2001)))
try:
inorder_recursive(skew, [])
print("recursive inorder on 2000-node skewed tree: ok")
except RecursionError:
print("recursive inorder on 2000-node skewed tree: RecursionError")
Output:
Insert 1,2,3,4,5 in that order:
1
R:2
R:3
R:4
R:5
Insert 3,1,4,2,5:
3
L:1
R:2
R:4
R:5
sorted n=1000 height=1000 insert comparisons=499500 search(1000) visits=1000
shuffled n=1000 height=25 insert comparisons=11972 search(1000) visits=7
rebuilt n=1000 height=10 search(1000) visits=9
recursion limit: 1000
recursive inorder on 2000-node skewed tree: RecursionError
For 1000 sorted keys the insert comparisons are 499500, which equals 1000 x 999 / 2, and searching for the largest key visits all 1000 nodes. The shuffled order gives height 25, about 2.5 times the minimum of 10 that the rebuilt tree reaches, and still a tiny fraction of 1000. The average insert in the shuffled run makes 11972 / 1000, about 12 comparisons, only a couple more than log2(1000), which is about 10, even though its single deepest path (height 25) is much longer than the ideal. Balanced height for n nodes is floor(log2(n)) + 1, which is 10 for 1000.
How to prevent it
- Use a self-balancing tree. An AVL tree keeps the heights of the two subtrees of every node within 1 of each other and restores that with rotations (local pointer rewirings that keep the BST order while lifting one child above its parent; the code below shows one). A red-black tree colours every node red or black and enforces colour rules (a red node never has a red child, and every root-to-leaf path has the same number of black nodes) that bound the longest path to at most twice the shortest, so the height stays O(log n). Both guarantee O(log n) search, insert and delete in the worst case. AVL is stricter (faster lookups, more rotations on writes); red-black does fewer rotations on writes. Java's
TreeMapis documented as red-black tree based and Rust'sBTreeMapis a B-tree (a B-tree has many keys per node and is the usual choice for on-disk data); Python's standard library has no balanced tree. - Randomize. A treap gives each key a random priority and keeps the tree ordered by key and heap-ordered by priority (every node's priority is at least as large as its children's), which gives expected O(log n) height whatever the arrival order. If you control a bulk load, shuffling the keys first gives the same effect, as the shuffled row above shows.
- Build balanced from sorted data. When the data is static and already sorted, pick the middle key as the root and recurse on each half, as
from_sorteddoes. Height is floor(log2(n)) + 1. - Make traversals safe regardless of shape. Use an explicit stack so depth is limited by memory, not by the interpreter's frame limit.
For an interview, the part to have ready is the diagnosis (sorted input gives a chain, costs become O(n)) and the cure in one sentence (a self-balancing tree restores the shape with rotations, or sorted data is loaded by recursing on the middle key). Writing one rotation on a whiteboard is a reasonable ask; the full AVL bookkeeping, red-black colour cases, treaps and B-trees are deeper reading than most interviews ask for.
A rotation, worked on three keys
Inserting 1, 2, 3 in order gives a right-leaning chain of height 3. One left rotation lifts 2 above 1: 2's old left subtree (empty here) becomes 1's right subtree, and 1 becomes 2's left child. The in-order sequence does not change, only the shape. The AVL insert in the same program rotates whenever a node's two subtrees differ in height by 2. This run feeds it sorted ascending keys, which only make the right side too tall, so it exercises only the left-rotation case.
class Node:
def __init__(self, key):
self.key = key
self.left = None
self.right = None
self.h = 1 # height in nodes of the subtree rooted here
def h(n):
return n.h if n else 0
def update(n):
n.h = 1 + max(h(n.left), h(n.right))
def rotate_left(x):
"""x's right child y moves up; x becomes y's left child. In-order stays the same."""
y = x.right
x.right, y.left = y.left, x
update(x)
update(y)
return y
def rotate_right(y):
x = y.left
y.left, x.right = x.right, y
update(y)
update(x)
return x
def insert(n, key):
"""AVL insert: ordinary BST insert, then rotate when a node's two sides differ in height by 2."""
if n is None:
return Node(key)
if key < n.key:
n.left = insert(n.left, key)
else:
n.right = insert(n.right, key)
update(n)
balance = h(n.left) - h(n.right)
if balance > 1:
if h(n.left.left) < h(n.left.right):
n.left = rotate_left(n.left)
return rotate_right(n)
if balance < -1:
if h(n.right.right) < h(n.right.left):
n.right = rotate_right(n.right)
return rotate_left(n)
return n
def draw(node, indent="", tag=""):
print(indent + tag + str(node.key))
if node.left:
draw(node.left, indent + " ", "L:")
if node.right:
draw(node.right, indent + " ", "R:")
def inorder(n):
return inorder(n.left) + [n.key] + inorder(n.right) if n else []
chain = Node(1)
chain.right = Node(2)
chain.right.right = Node(3)
update(chain.right)
update(chain)
print("chain 1,2,3, height", h(chain))
draw(chain)
chain = rotate_left(chain)
print("after one left rotation at 1, height", h(chain))
draw(chain)
print("in-order still", inorder(chain))
root = None
for k in range(1, 1001):
root = insert(root, k)
print("AVL, keys 1..1000 inserted in sorted order: height", h(root), "in nodes")
assert inorder(root) == list(range(1, 1001))
Run in a python:3.12 container, it prints:
chain 1,2,3, height 3
1
R:2
R:3
after one left rotation at 1, height 2
2
L:1
R:3
in-order still [1, 2, 3]
AVL, keys 1..1000 inserted in sorted order: height 10 in nodes
The 1000 sorted keys that built a height-1000 chain above now give a height of 10 (in nodes), the same as the perfectly balanced rebuild, because the rotations fire as the chain starts to form.
Common mistakes
- Saying a BST is "O(log n)" without the word "balanced": the guarantee depends on shape, and a plain BST has O(n) worst-case operations.
- Assuming real-world keys arrive in random order: random insertion order gives expected O(log n) height, but ordered real-world keys (ids, dates) are exactly the input that breaks it.
- Forgetting that the skewed tree also breaks recursion in Python, not just speed.
Running the code
Save the first listing (the measurements) as skewed_bst_demo.py and the second listing (the rotation and the AVL insert) as avl_rotation_demo.py. Each is a complete program:
python3 skewed_bst_demo.py
python3 avl_rotation_demo.py
Standard library only (Python 3.12). The shuffle is seeded (random.Random(7)), so the numbers above reproduce.
Unlock Full Question Bank
Get access to all Trees and Binary Search Trees interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.