HARD Lab · Trees · 70

98. Validate Binary Search Tree

A binary tree is a BST when every node fits the strict ordering of all its ancestors, not just its parent. Carry an open (low, high) interval down each path and check every node against it.

MediumDFSValid BST BoundsTypeScript

PROBLEM What we're solving

Given a binary tree, return true iff it is a valid binary search tree: for every node, all values in its left subtree are strictly less and all values in its right subtree are strictly greater. [2,1,3]true. The tree [5,1,4,null,null,3,6]false: node 3 sits in the right subtree of 5, so it must exceed 5, but it doesn't.

KEY IDEA Each node owns an open (low, high) interval

Insight → a node is valid iff its value lies strictly inside the interval (low, high) inherited from its ancestors. The root starts with (−∞, +∞). Going left tightens the upper bound to the parent's value; going right raises the lower bound to it. Thread those bounds down with DFS and every node validates itself in O(1).

RECIPE Descend with bounds, tighten as you go

  • 0 · Base case. A null subtree is a valid BST → return true.
  • 1 · Range check. If node.val <= low or node.val >= high, it breaks an ancestor's ordering → return false. Use strict bounds so duplicates fail.
  • 2 · Recurse left. Everything left must stay below node.val: call dfs(left, low, node.val) — high tightens.
  • 3 · Recurse right. Everything right must exceed node.val: call dfs(right, node.val, high) — low tightens.
  • 4 · Combine. The tree is valid iff this node is in range AND both subtrees report valid.
Classic confusion → it is not enough to check each node only against its parent. In [5,1,4,null,null,3,6] the node 3 is a valid left child of 4 (3 < 4), yet the tree is invalid because 3 lives in 5's right subtree and must be greater than 5. Only the inherited (low, high) interval — not the immediate parent — catches this.

COST Complexity & alternatives

Check every pair / re-scan subtrees
O(n²)
Validating each node against all descendants repeats work.
DFS with (low, high) bounds
O(n)
Visit each node once; O(h) recursion stack.

Space note

Time is O(n) — one visit per node. Space is O(h) for the recursion stack, where h is the tree height: O(log n) when balanced, O(n) in the worst case (a degenerate chain).

Pattern transfer → the same range-threading DFS validates other ordered structures, and the in-order traversalalternative (a BST's in-order sequence is strictly increasing) powers Kth Smallest Element in a BST and Recover Binary Search Tree.

RUN IT Thread tightening (low, high) bounds down the tree

step 0 / 7
STARTDFS from the root carrying an open interval (low, high). Root may be any value, so start with (−∞, +∞).
State
node
−∞
low (>)
+∞
high (<)
in bounds?
current nodeinherited boundsin boundsviolation
slowfast

TYPESCRIPT The solution, annotated

isValidBST.ts
// LeetCode 98 — Validate Binary Search Tree
class TreeNode {
  val: number;
  left: TreeNode | null;
  right: TreeNode | null;
  constructor(val = 0, left: TreeNode | null = null, right: TreeNode | null = null) {
    this.val = val;
    this.left = left;
    this.right = right;
  }
}

function isValidBST(root: TreeNode | null): boolean {
  // Each node carries an OPEN interval (low, high) it must fall strictly inside.
  const dfs = (
    node: TreeNode | null,
    low: number,   // exclusive lower bound, -Infinity at root
    high: number,  // exclusive upper bound, +Infinity at root
  ): boolean => {
    if (node === null) return true;          // empty subtree is valid
    if (node.val <= low || node.val >= high) // must be strictly within bounds
      return false;
    // Tighten the bound as we descend: left shrinks high, right raises low.
    return (
      dfs(node.left, low, node.val) &&
      dfs(node.right, node.val, high)
    );
  };

  return dfs(root, -Infinity, Infinity);
}

Reading it block by block

The helper signature. dfs(node, low, high)asks: "is the subtree rooted here a valid BST whose every value falls strictly between low and high?" The bounds are exclusive.
Base case. A null node represents an empty subtree, which is trivially a valid BST → return true.
Range check. node.val <= low || node.val >= high fails the node. Using <= / >= (not < / >) rejects duplicate values, which a strict BST forbids.
Tighten and recurse. Left subtree inherits (low, node.val) — the parent becomes the new ceiling. Right subtree inherits (node.val, high)— the parent becomes the new floor. This is exactly what propagates the "greater than every left-ancestor, less than every right-ancestor" constraint.
Kick-off. The root has no constraints, so we call dfs(root, -Infinity, Infinity). Using ±Infinity avoids special-casing the root or worrying about Number.MIN_SAFE_INTEGER edge values.
Complexity → O(n) time — each node is visited exactly once. O(h) space for the recursion stack: O(log n) balanced, O(n) worst case (skewed tree).

INTERVIEWFollow-ups they'll ask

  • "Do it without bounds?"Do an in-order traversal and verify each value is strictly greater than the previous one — a BST's in-order sequence is strictly increasing.
  • "Iterative version?" Replace recursion with an explicit stack of [node, low, high] triples, or an iterative in-order walk tracking the previous value.
  • "Why ±Infinity instead of integer limits?" It cleanly handles nodes equal to Number.MIN_SAFE_INTEGER / MAX_SAFE_INTEGER without false failures.
  • "Allow duplicates?" Decide a convention (e.g. duplicates go right) and relax one comparison to < / >= accordingly.
  • "Recover a BST with two swapped nodes?" Use the in-order approach to find the two out-of-order elements and swap them back.

MNEMONIC The one-liner

"Each node lives in an open (low, high) box — left shrinks the ceiling, right raises the floor."

TRIGGERS When you see ___ → reach for ___

"is this tree a valid BST?"DFS carrying (low, high) bounds
child looks fine vs parent but tree invalidinherited ancestor bounds, not parent
going left in a BSTtighten high to node.val
going right in a BSTraise low to node.val

SKELETON The reusable shape

skeleton.ts
function isValidBST(root: TreeNode | null): boolean {
  const dfs = (node: TreeNode | null, low: number, high: number): boolean => {
    if (!node) return true;
    if (node.val <= low || node.val >= high) return false;
    return dfs(node.left, low, node.val)
        && dfs(node.right, node.val, high);
  };
  return dfs(root, -Infinity, Infinity);
}

FLASHCARDS Tap to flip

What interval does each node have to satisfy?
The open (low, high) inherited from its ancestors: low < node.val < high.
tap to flip
1 / 7
Answer to reveal explanations. No penalty for retries.Score: 0 / 7
QUESTION 01
What makes the bounds approach correct where "compare each node to its parent" fails?
QUESTION 02
When recursing into the LEFT child, the bounds become:
QUESTION 03
For the tree [5,1,4,null,null,3,6], the function returns:
QUESTION 04
Why use strict comparisons (< and >) rather than <= and >=?
QUESTION 05
Why initialize the root call with -Infinity and +Infinity?
QUESTION 06
Time and space complexity of the bounds DFS?
QUESTION 07
Which alternative also validates a BST without explicit bounds?