LeetCode 272 - Closest Binary Search Tree Value II
This problem asks us to find the k values in a Binary Search Tree, or BST, whose values are numerically closest to a given floating point target. A BST has a very important property: - Every value in the left subtree is smaller than the current node.
Difficulty: 🔴 Hard
Topics: Two Pointers, Stack, Tree, Depth-First Search, Binary Search Tree, Heap (Priority Queue), Binary Tree
Solution
Problem Understanding
This problem asks us to find the k values in a Binary Search Tree, or BST, whose values are numerically closest to a given floating point target.
A BST has a very important property:
- Every value in the left subtree is smaller than the current node.
- Every value in the right subtree is larger than the current node.
This ordering allows us to avoid scanning every node in some optimized solutions.
The input consists of:
root, the root node of a BSTtarget, a floating point numberk, the number of closest values we must return
The output should contain exactly k node values from the tree whose absolute difference from target is smallest.
For example, if the target is 3.7, then:
4has distance|4 - 3.7| = 0.33has distance|3 - 3.7| = 0.75has distance1.3
So the two closest values are [4, 3].
The problem guarantees that there is only one unique correct set of k closest values. This removes ambiguity when multiple answers could otherwise exist.
The constraints are important:
- Up to
10^4nodes - Values can be very large
kmay be as large asn
A naive solution that sorts all nodes is acceptable for 10^4, but the follow up specifically asks whether we can do better than O(n) for balanced BSTs.
Several edge cases are important:
- A tree with only one node
k = n, meaning every value must be returned- A target smaller than all node values
- A target larger than all node values
- A target exactly equal to a node value
- Highly unbalanced trees
A naive implementation can easily fail when deciding which side of the BST to explore or when handling predecessor and successor traversal correctly.
Approaches
Brute Force Approach
The simplest solution is to traverse the entire tree and collect all node values into an array.
Once we have all values:
- Compute each value's distance from
target - Sort by absolute difference
- Return the first
kelements
Because every node is examined, this approach is always correct. We directly compare all candidates and choose the closest ones.
However, it ignores the BST structure completely. Even though the tree is ordered, we still scan every node and perform a sort.
The complexity becomes:
- Traversal:
O(n) - Sorting:
O(n log n)
This is slower than necessary, especially when the BST is balanced.
Optimal Approach
The key insight is that BST inorder traversal produces values in sorted order.
Instead of collecting every node and sorting afterward, we can use the BST structure to efficiently move outward from the target value.
The optimal solution uses two stacks:
- A predecessor stack for values smaller than the target
- A successor stack for values larger than or equal to the target
These stacks simulate two iterators:
- One moves backward through sorted BST values
- One moves forward through sorted BST values
At every step, we compare:
- the closest smaller candidate
- the closest larger candidate
Whichever is closer to the target gets added to the result.
This works similarly to merging two sorted streams around the target.
Because each stack operation follows BST paths, the runtime becomes:
O(log n)setup for balanced BSTsO(k log n)in the worst case for extracting results
This is significantly better than traversing all nodes when k is small.
Approach Comparison
| Approach | Time Complexity | Space Complexity | Notes |
|---|---|---|---|
| Brute Force | O(n log n) | O(n) | Traverse entire tree and sort by distance |
| Optimal | O(log n + k) average, O(n) worst | O(log n) average | Uses BST ordering with predecessor and successor stacks |
Algorithm Walkthrough
Optimal Algorithm Using Two BST Iterators
- Initialize two stacks,
predecessorsandsuccessors.
The predecessor stack will store nodes whose values are smaller than the target. The successor stack will store nodes whose values are greater than or equal to the target. 2. Traverse the BST from the root to initialize both stacks.
While traversing:
- If
node.val < target, push the node intopredecessorsand move right. - Otherwise, push the node into
successorsand move left.
This effectively positions two iterators around the target. 3. Define a helper function to get the next predecessor.
The predecessor iterator behaves like reverse inorder traversal:
- Pop the top node
- Move to its left child
- Then repeatedly move right while pushing nodes
This gives the next smaller BST value. 4. Define another helper function to get the next successor.
The successor iterator behaves like normal inorder traversal:
- Pop the top node
- Move to its right child
- Then repeatedly move left while pushing nodes
This gives the next larger BST value.
5. Repeat k times.
At each step:
-
If one stack is empty, use the other.
-
Otherwise compare:
-
abs(predecessor - target) -
abs(successor - target)
Choose the closer value and advance that iterator.
6. Append the selected value to the result list.
7. Return the result after collecting exactly k values.
Why it works
The BST inorder traversal produces sorted values. The predecessor stack always points to the next largest value smaller than the target, while the successor stack always points to the next smallest value larger than or equal to the target.
At every step, the closest remaining value to the target must be at the top of one of these two stacks. By always choosing the closer top element, we greedily construct the correct set of k closest values in sorted proximity order.
Python Solution
# Definition for a binary tree node.
# class TreeNode:
# def __init__(self, val=0, left=None, right=None):
# self.val = val
# self.left = left
# self.right = right
from typing import List, Optional
class Solution:
def closestKValues(
self,
root: Optional['TreeNode'],
target: float,
k: int
) -> List[int]:
predecessors = []
successors = []
def init_predecessors(node):
while node:
if node.val < target:
predecessors.append(node)
node = node.right
else:
node = node.left
def init_successors(node):
while node:
if node.val >= target:
successors.append(node)
node = node.left
else:
node = node.right
def get_predecessor():
node = predecessors.pop()
value = node.val
node = node.left
while node:
predecessors.append(node)
node = node.right
return value
def get_successor():
node = successors.pop()
value = node.val
node = node.right
while node:
successors.append(node)
node = node.left
return value
init_predecessors(root)
init_successors(root)
result = []
for _ in range(k):
if not predecessors:
result.append(get_successor())
elif not successors:
result.append(get_predecessor())
else:
pred_diff = abs(predecessors[-1].val - target)
succ_diff = abs(successors[-1].val - target)
if pred_diff <= succ_diff:
result.append(get_predecessor())
else:
result.append(get_successor())
return result
The implementation begins by building two stacks.
The predecessor stack stores candidate nodes smaller than the target. During initialization, whenever a node value is smaller than the target, the algorithm pushes it and moves right because larger values may still remain below the target.
The successor stack stores candidate nodes greater than or equal to the target. Whenever a node qualifies, it is pushed and traversal continues left to search for even smaller valid successors.
The get_predecessor() helper simulates reverse inorder traversal. After removing the current predecessor node, it explores the left subtree and pushes all right descendants. This guarantees the next top element is the next smaller BST value.
Similarly, get_successor() simulates standard inorder traversal. After removing the current successor node, it explores the right subtree and pushes all left descendants.
The main loop runs exactly k times. At each iteration, the algorithm compares the closest available predecessor and successor values. The closer one is appended to the answer and its iterator advances.
Because the stacks always represent the next available candidates in sorted order, the algorithm remains correct throughout execution.
Go Solution
/**
* Definition for a binary tree node.
* type TreeNode struct {
* Val int
* Left *TreeNode
* Right *TreeNode
* }
*/
import "math"
func closestKValues(root *TreeNode, target float64, k int) []int {
predecessors := []*TreeNode{}
successors := []*TreeNode{}
initPredecessors := func(node *TreeNode) {
for node != nil {
if float64(node.Val) < target {
predecessors = append(predecessors, node)
node = node.Right
} else {
node = node.Left
}
}
}
initSuccessors := func(node *TreeNode) {
for node != nil {
if float64(node.Val) >= target {
successors = append(successors, node)
node = node.Left
} else {
node = node.Right
}
}
}
var getPredecessor func() int
getPredecessor = func() int {
n := len(predecessors)
node := predecessors[n-1]
predecessors = predecessors[:n-1]
value := node.Val
node = node.Left
for node != nil {
predecessors = append(predecessors, node)
node = node.Right
}
return value
}
var getSuccessor func() int
getSuccessor = func() int {
n := len(successors)
node := successors[n-1]
successors = successors[:n-1]
value := node.Val
node = node.Right
for node != nil {
successors = append(successors, node)
node = node.Left
}
return value
}
initPredecessors(root)
initSuccessors(root)
result := []int{}
for i := 0; i < k; i++ {
if len(predecessors) == 0 {
result = append(result, getSuccessor())
} else if len(successors) == 0 {
result = append(result, getPredecessor())
} else {
predDiff := math.Abs(float64(predecessors[len(predecessors)-1].Val) - target)
succDiff := math.Abs(float64(successors[len(successors)-1].Val) - target)
if predDiff <= succDiff {
result = append(result, getPredecessor())
} else {
result = append(result, getSuccessor())
}
}
}
return result
}
The Go implementation follows the same logic as the Python solution, but several language-specific details differ.
Go does not support nested named functions with automatic closures as conveniently as Python, so helper functions are declared as function variables.
Stacks are implemented using slices. Appending uses append(), and popping removes the last element using slice truncation.
Go also requires explicit conversion from int to float64 before distance calculations.
Nil tree handling is naturally supported because traversal loops terminate when a pointer becomes nil.
Worked Examples
Example 1
Input:
root = [4,2,5,1,3]
target = 3.714286
k = 2
BST structure:
4
/ \
2 5
/ \
1 3
Initialization Phase
Predecessor Stack
| Node | Action | Stack |
|---|---|---|
| 4 | 4 >= target, go left | [] |
| 2 | 2 < target, push and go right | [2] |
| 3 | 3 < target, push and go right | [2, 3] |
Successor Stack
| Node | Action | Stack |
|---|---|---|
| 4 | 4 >= target, push and go left | [4] |
| 2 | 2 < target, go right | [4] |
| 3 | 3 < target, go right | [4] |
Now:
predecessors = [2, 3]
successors = [4]
Iteration 1
Compare:
|3 - 3.714286| = 0.714286
|4 - 3.714286| = 0.285714
Choose successor 4.
Result:
[4]
Advance successor iterator.
Next successor becomes 5.
Iteration 2
Compare:
|3 - 3.714286| = 0.714286
|5 - 3.714286| = 1.285714
Choose predecessor 3.
Result:
[4, 3]
Return:
[4, 3]
Example 2
Input:
root = [1]
target = 0
k = 1
Initialization
Predecessor Stack
No values smaller than target.
[]
Successor Stack
[1]
Iteration 1
Predecessor stack is empty, so choose successor.
Result:
[1]
Return:
[1]
Complexity Analysis
| Measure | Complexity | Explanation |
|---|---|---|
| Time | O(log n + k) average, O(n) worst | Stack initialization follows BST height, each extraction processes one node |
| Space | O(log n) average, O(n) worst | Stacks store root-to-leaf traversal paths |
In a balanced BST, the height is O(log n), so initialization is efficient. Each predecessor or successor extraction visits nodes only once across the entire algorithm.
In the worst case of a completely skewed BST, the height becomes O(n), which increases both time and space complexity accordingly.
Test Cases
# Helper TreeNode class
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
s = Solution()
# Example 1
root1 = TreeNode(4)
root1.left = TreeNode(2)
root1.right = TreeNode(5)
root1.left.left = TreeNode(1)
root1.left.right = TreeNode(3)
assert sorted(s.closestKValues(root1, 3.714286, 2)) == [3, 4] # standard example
# Example 2
root2 = TreeNode(1)
assert s.closestKValues(root2, 0.0, 1) == [1] # single-node tree
# Target exactly matches node
root3 = TreeNode(5)
root3.left = TreeNode(3)
root3.right = TreeNode(7)
assert sorted(s.closestKValues(root3, 5.0, 2)) == [3, 5] # exact target match
# k equals total nodes
root4 = TreeNode(2)
root4.left = TreeNode(1)
root4.right = TreeNode(3)
assert sorted(s.closestKValues(root4, 2.0, 3)) == [1, 2, 3] # return all nodes
# Target smaller than all values
root5 = TreeNode(10)
root5.left = TreeNode(5)
root5.right = TreeNode(15)
assert sorted(s.closestKValues(root5, -100.0, 2)) == [5, 10] # extreme small target
# Target larger than all values
root6 = TreeNode(10)
root6.left = TreeNode(5)
root6.right = TreeNode(15)
assert sorted(s.closestKValues(root6, 100.0, 2)) == [10, 15] # extreme large target
# Left-skewed tree
root7 = TreeNode(5)
root7.left = TreeNode(4)
root7.left.left = TreeNode(3)
root7.left.left.left = TreeNode(2)
assert sorted(s.closestKValues(root7, 3.5, 2)) == [3, 4] # skewed BST
# Right-skewed tree
root8 = TreeNode(1)
root8.right = TreeNode(2)
root8.right.right = TreeNode(3)
root8.right.right.right = TreeNode(4)
assert sorted(s.closestKValues(root8, 2.8, 2)) == [2, 3] # opposite skew
# Floating-point target between nodes
root9 = TreeNode(8)
root9.left = TreeNode(4)
root9.right = TreeNode(12)
root9.left.left = TreeNode(2)
root9.left.right = TreeNode(6)
assert sorted(s.closestKValues(root9, 5.1, 3)) == [4, 6, 8] # decimal target
Test Case Summary
| Test | Why |
|---|---|
| Standard balanced BST | Verifies normal behavior |
| Single-node tree | Smallest valid input |
| Exact target match | Ensures equality handled correctly |
| k equals n | Ensures all nodes can be returned |
| Target below minimum | Tests empty predecessor stack |
| Target above maximum | Tests empty successor stack |
| Left-skewed BST | Worst-case height scenario |
| Right-skewed BST | Opposite skew structure |
| Decimal target | Verifies floating-point distance handling |
Edge Cases
Target Smaller Than Every Node
If the target is smaller than all BST values, the predecessor stack becomes empty during initialization. A buggy implementation may attempt to access the top of an empty stack.
This solution explicitly checks whether one stack is empty before comparing distances. If predecessors are unavailable, values are taken only from successors.
Target Larger Than Every Node
This is the symmetric opposite case. Here, the successor stack becomes empty because no node value is greater than or equal to the target.
The implementation safely falls back to extracting only predecessors until k values are collected.
Highly Unbalanced BST
A skewed BST behaves almost like a linked list. Recursive solutions can risk excessive recursion depth or lose expected logarithmic performance.
This implementation uses iterative stack traversal instead of recursion, avoiding recursion depth issues entirely. Although worst-case complexity becomes O(n), correctness remains intact.
k Equals Total Number of Nodes
Some implementations accidentally stop early or fail after one iterator becomes empty.
This algorithm continues pulling values from whichever iterator still has remaining nodes, ensuring all n values are eventually returned correctly.