Validate a BST: carry every ancestor constraint
Constructed practice problem; no company attribution. Prerequisites: tree structure and identity and ordered boundaries.
Candidate brief
A storage index exports a binary tree and claims it is a strict binary search tree. Every value in a left subtree must be smaller than its ancestor, and every value in a right subtree larger. Validate that claim. Is checking each node against only its immediate children enough?
Write this:
@dataclass(eq=False)
class Node:
value: object
left: "Node | None" = None
right: "Node | None" = None
def validate_bst(root):
...
| Contract | Decision |
|---|---|
| Input | Binary Node tree with integer values, bool excluded; None is empty |
| Output | Boolean for strict BST ordering; equal values anywhere cannot satisfy strict ordering |
| Boundaries | Empty/singleton trees are valid; unbounded Python integers allowed; no mutation |
| Invalid input | Invalid values/links, cycles, or shared nodes raise ValueError; ordering violations return False |
| Excluded | Balancing guarantees and duplicate-placement policies |
Optional refresher · the underlying tool
For a binary search tree, a node must satisfy every ancestor's bounds, not merely its direct parent's comparison. Carry a permissible range down the recursion:
def allowed(value, lower, upper):
return lower < value < upper # strict: duplicates are invalid here
A right child 6 under root 5 looks locally fine, but if it lies inside left subtree rooted at 3 it violates the root's upper bound of 5.
A design choice worth saying aloud
lower and upper are exclusive bounds, so duplicate values fail the BST contract. A local child comparison is insufficient: a node 6 deep in the left subtree of root 5 must fail even if its immediate parent is 3. If duplicates become legal, state which side admits equality and adjust both checks.
What the interviewer expects
The interviewer gives you the scenario and the contract above. Explain what a successful call returns, walk through one example below, and name what your state means before choosing a data structure.
Done means: Boolean for strict BST ordering; equal values anywhere cannot satisfy strict ordering.
Now predict each output before looking at the reference; invalid input should leave any existing state unchanged unless the contract says otherwise.
Test-case scenarios to settle before coding
01 · Valid
- Input / starting state
- 2 with children 1 and 3
- Expected result
True
What it is testing: Both global bounds hold.
02 · Ancestor violation
- Input / starting state
- 10 → left 5 → right 12
- Expected result
False
What it is testing: Checking only each parent misses the violation.
03 · Duplicate
- Input / starting state
- 2 with child 2
- Expected result
False
What it is testing: The baseline ordering is strict.
04 · Empty/singleton
- Input / starting state
None/ one integer node- Expected result
True/True
What it is testing: Small valid structures establish boundaries.
05 · Invalid value
- Input / starting state
- a node contains
True - Expected result
ValueError
What it is testing: Boolean is excluded despite integer inheritance.
06 · Invalid topology
- Input / starting state
- cycle or shared child
- Expected result
ValueError
What it is testing: Ordering failure does not hide structural corruption.
For each case, show which branch or state change produces that result.
Node(2, Node(1), Node(3)) is valid.
Node(10, Node(5), Node(15, Node(6), Node(20))) is invalid: 6 is in 10's right subtree.
Node(2, Node(2)) returns False; Node(True) raises ValueError.
Before opening the explanation, restate the contract, trace the smallest useful example, implement a baseline, and identify the repeated work. Then implement your improvement independently and derive tests from the contract. Say what your state means before saying which data structure stores it.
Worked lesson, changed requirements, and reference
The failure is a forgotten ancestor
A local child comparison accepts the counterexample because 6 is less than 15, while forgetting that the entire right subtree must exceed 10. A correct baseline checks every node against all values in its left and right subtrees. Repeated subtree scans cost O(n²) on a skewed tree. The reusable improvement is to carry the ancestor requirements downward instead of rediscovering them below each node.
Represent a frame as (node, lower, upper) with exclusive bounds; None means an
absent bound, not a numeric sentinel. On entry, the bounds summarize every
ancestor constraint. Reject ordering when value <= lower or value >= upper.
The left child inherits lower and receives current value as upper; the right
child receives current value as lower and inherits upper. If all frames pass,
each node satisfies every relevant ancestor, which is exactly the strict BST
definition. Conversely, a violated ancestor relationship excludes a node from
its carried interval, so it is detected.
| Frame | Allowed interval | Value | Ordering result |
|---|---|---|---|
| root | unbounded | 10 | valid locally |
| root.right | (10, unbounded) |
15 | valid locally |
| root.right.left | (10, 15) |
6 | false |
The logical recursive return is a boolean: this node is in bounds and both child calls return true; an empty child returns true. The reference uses explicit stack frames to avoid Python recursion limits. It records an ordering-failure flag but continues validating shape and value types, so a malformed branch still raises ValueError even when another branch already violates ordering. This distinguishes a validly represented non-BST from an invalid object graph.
Each node is visited once: O(n) time. Trusted-tree DFS needs O(h) frame space, but the reference's identity set detects shared nodes/cycles and raises total auxiliary space to O(n). Integer bounds avoid false failures at extreme values. The function does not prove balance or efficient lookup merely by returning true.
Follow-up 1: allow duplicate keys only in right subtrees
Predict how bounds must carry inclusion as well as value. A left edge adds an exclusive upper bound; a right edge adds an inclusive lower bound.
| Structure | Strict policy | Duplicates-right policy |
|---|---|---|
| root 2, right child 2 | false | true |
| root 2, left child 2 | false | false |
| root 2, right 3 with left 2 | false | true: still in root's right subtree |
Globally replacing both comparisons with inclusive ones is wrong: it permits duplicates on the left. This policy also interacts with rotations; a balancing operation may require counts stored within one node instead of duplicate nodes.
Follow-up 2: validate an inorder stream
Inorder traversal emits left, node, right, so strict increase is equivalent to strict BST ordering for a valid tree. A stream of values alone cannot validate the original graph shape; that must be established elsewhere. A senior candidate explains recursive return meaning, bounds, and malformed-versus-false behavior. A lead candidate defines duplicate and structural-validation policy at the serialization boundary rather than silently changing it in one consumer.
Run and check
From the repository root:
cd curriculum/01-code/02-data-structures-algorithms/problems/18-validate-bst
python -m unittest -v test_solution.py
Reference implementation (download file, source below) · Contract and oracle tests (download file, source below). Read the tests after your attempt. A green reference suite verifies the supplied implementation; it does not demonstrate independent transfer. Reimplement one follow-up with the reference closed and explain which old invariant no longer holds.
from dataclasses import dataclass
@dataclass(eq=False)
class Node:
value: object
left: "Node | None" = None
right: "Node | None" = None
def validate_bst(root):
stack = [(root, None, None)] if root is not None else []
visited_nodes = set()
is_bst = True
while stack:
node, lower, upper = stack.pop()
if not isinstance(node, Node) or node in visited_nodes or type(node.value) is not int:
raise ValueError("expected an integer-valued tree without repeated nodes")
visited_nodes.add(node)
value = node.value
if (lower is not None and value <= lower) or (upper is not None and value >= upper):
is_bst = False
if node.right is not None:
stack.append((node.right, value, upper))
if node.left is not None:
stack.append((node.left, lower, value))
return is_bst
import random
import unittest
from solution import Node, validate_bst
class BSTTests(unittest.TestCase):
def test_ancestor_bounds_and_empty(self):
self.assertTrue(validate_bst(None))
self.assertTrue(validate_bst(Node(2, Node(1), Node(3))))
self.assertFalse(validate_bst(Node(10, Node(5), Node(15, Node(6), Node(20)))))
self.assertFalse(validate_bst(Node(2, Node(2))))
self.assertTrue(validate_bst(Node(0, Node(-(10**100)), Node(10**100))))
deep = None
for value in reversed(range(5000)):
deep = Node(value, right=deep)
self.assertTrue(validate_bst(deep))
def test_structural_errors_even_after_order_failure(self):
shared = Node(2)
cycle = Node(1)
cycle.left = cycle
for root in [Node(True), Node(2, Node(4), 42), Node(1, shared, shared), cycle, 42]:
with self.assertRaises(ValueError):
validate_bst(root)
def test_inorder_oracle(self):
rng = random.Random(118)
def build(depth):
if depth == 0 or rng.random() < .3:
return None
return Node(rng.randrange(-10, 11), build(depth - 1), build(depth - 1))
def inorder(node):
return [] if node is None else inorder(node.left) + [node.value] + inorder(node.right)
for _ in range(400):
root = build(5)
values = inorder(root)
expected = all(a < b for a, b in zip(values, values[1:]))
self.assertEqual(validate_bst(root), expected)
if __name__ == "__main__":
unittest.main()