Facebook Pixel

2476. Closest Nodes Queries in a Binary Search Tree

Problem Description

You have a binary search tree and need to find the closest values for multiple queries.

Given:

For each query value in queries[i], you need to find two values from the tree:

  1. The floor value (min_i): The largest value in the tree that is less than or equal to queries[i]. If no such value exists, use -1.

  2. The ceiling value (max_i): The smallest value in the tree that is greater than or equal to queries[i]. If no such value exists, use -1.

Return a 2D array answer where each answer[i] = [min_i, max_i] corresponds to the floor and ceiling values for queries[i].

Example walkthrough: If the BST contains values [1, 4, 6, 9] and you query for 5:

  • The floor (largest value ≤ 5) would be 4
  • The ceiling (smallest value ≥ 5) would be 6
  • Result: [4, 6]

If you query for 10 in the same tree:

  • The floor (largest value ≤ 10) would be 9
  • The ceiling (smallest value ≥ 10) doesn't exist, so it's -1
  • Result: [9, -1]

The solution leverages the BST property by performing an in-order traversal to get a sorted array of all values, then uses binary search (bisect_left) to efficiently find the floor and ceiling values for each query.

Flowchart Walkthrough

First, let's pin down the algorithm using the Flowchart. Here's a step-by-step walkthrough:

Is it a graph?

  • Yes: A binary search tree is a special type of graph where each node has at most two children (left and right), forming a hierarchical structure with nodes and edges.

Is it a tree?

  • Yes: The problem explicitly states we're working with a binary search tree, which is a tree data structure.

DFS

  • Result: Based on the flowchart, when we have a tree structure, the algorithm directs us to use DFS (Depth-First Search).

Conclusion: The flowchart suggests using DFS for this problem. This aligns perfectly with the solution approach, which uses DFS (specifically, in-order traversal) to traverse the binary search tree and extract all values in sorted order. The DFS traversal visits nodes recursively by going deep into the left subtree first, then processing the current node, and finally traversing the right subtree. This in-order DFS traversal of a BST naturally gives us a sorted array, which can then be used with binary search to efficiently answer the queries for floor and ceiling values.

Quick Interview Experience
Help others by sharing your interview experience
Have you seen this problem before?

Intuition

The key insight is recognizing that a binary search tree has a special property: an in-order traversal always gives us values in sorted order. This is because in a BST, all values in the left subtree are smaller than the root, and all values in the right subtree are larger than the root.

When we need to find the floor (largest value ≤ query) and ceiling (smallest value ≥ query) for multiple queries, we could traverse the tree for each query separately. However, this would be inefficient, especially with many queries.

Instead, we can leverage the BST's sorted property. By performing a single DFS in-order traversal upfront, we extract all values into a sorted array. This transforms our tree search problem into an array search problem.

Once we have a sorted array, finding floor and ceiling values becomes a classic binary search problem:

  • For the floor value: We need to find the rightmost element that is ≤ the query value
  • For the ceiling value: We need to find the leftmost element that is ≥ the query value

The bisect_left function helps us here. For a query value x:

  • bisect_left(nums, x) gives us the index where x would be inserted to keep the array sorted - this is exactly the ceiling position
  • bisect_left(nums, x + 1) - 1 gives us the index of the largest value ≤ x - this is the floor position

This approach is efficient because we traverse the tree only once (O(n) time) and then each query takes only O(log n) time using binary search, rather than traversing the tree for each query which would take O(n * q) time where q is the number of queries.

Learn more about Tree, Depth-First Search, Binary Search Tree, Binary Search and Binary Tree patterns.

Solution Approach

The solution follows a two-phase approach: first extracting values from the BST, then efficiently answering queries.

Phase 1: Extract Values using DFS In-order Traversal

We implement a recursive DFS function that performs in-order traversal:

def dfs(root: Optional[TreeNode]):
    if root is None:
        return
    dfs(root.left)    # Visit left subtree
    nums.append(root.val)  # Process current node
    dfs(root.right)   # Visit right subtree

This traversal pattern (left → root → right) guarantees that values are collected in ascending order due to the BST property. We store these values in the nums array.

Phase 2: Binary Search for Each Query

For each query value x, we need to find:

  1. The floor: largest value ≤ x
  2. The ceiling: smallest value ≥ x

The implementation uses Python's bisect_left function strategically:

i = bisect_left(nums, x + 1) - 1  # Index for floor
j = bisect_left(nums, x)          # Index for ceiling

Here's why this works:

  • bisect_left(nums, x) returns the leftmost position where x could be inserted. If x exists in the array, this gives us its index (the ceiling). If x doesn't exist, it gives us the index of the smallest value greater than x.
  • bisect_left(nums, x + 1) - 1 finds where x + 1 would be inserted, then backs up one position. This gives us the largest value that is ≤ x (the floor).

Handling Edge Cases

The solution checks if the computed indices are valid:

mi = nums[i] if 0 <= i < len(nums) else -1
mx = nums[j] if 0 <= j < len(nums) else -1
  • If i is out of bounds (negative), no floor exists, so we use -1
  • If j is out of bounds (≥ array length), no ceiling exists, so we use -1

Time Complexity:

  • DFS traversal: O(n) where n is the number of nodes
  • Binary search for q queries: O(q × log n)
  • Total: O(n + q × log n)

Space Complexity: O(n) for storing the sorted array of values.

Ready to land your dream job?

Unlock your dream job with a 5-minute evaluator for a personalized learning plan!

Start Evaluator

Example Walkthrough

Let's walk through a concrete example to illustrate the solution approach.

Given BST:

       4
      / \
     2   7
    /   / \
   1   5   8

Queries: [3, 6, 9]

Phase 1: Extract values using in-order traversal

Starting the DFS in-order traversal:

  1. Go to leftmost node (1), visit left subtree (none), add 1, visit right (none)
  2. Back to node 2, add 2, visit right subtree (none)
  3. Back to root 4, add 4
  4. Visit right subtree starting at 7
  5. Visit left of 7 (node 5), add 5
  6. Add 7
  7. Visit right of 7 (node 8), add 8

Result: nums = [1, 2, 4, 5, 7, 8] (sorted array)

Phase 2: Process each query using binary search

Query 1: x = 3

  • Find floor: bisect_left(nums, 4) - 1 = 2 - 1 = 1
    • nums[1] = 2 (largest value ≤ 3)
  • Find ceiling: bisect_left(nums, 3) = 2
    • nums[2] = 4 (smallest value ≥ 3)
  • Result: [2, 4]

Query 2: x = 6

  • Find floor: bisect_left(nums, 7) - 1 = 4 - 1 = 3
    • nums[3] = 5 (largest value ≤ 6)
  • Find ceiling: bisect_left(nums, 6) = 4
    • nums[4] = 7 (smallest value ≥ 6)
  • Result: [5, 7]

Query 3: x = 9

  • Find floor: bisect_left(nums, 10) - 1 = 6 - 1 = 5
    • nums[5] = 8 (largest value ≤ 9)
  • Find ceiling: bisect_left(nums, 9) = 6
    • Index 6 is out of bounds (array length is 6), so ceiling = -1
  • Result: [8, -1]

Final Answer: [[2, 4], [5, 7], [8, -1]]

This example demonstrates how the solution efficiently converts the tree problem into an array binary search problem, handling both standard cases and edge cases where no ceiling exists.

Solution Implementation

1from typing import Optional, List
2from bisect import bisect_left
3
4# Definition for a binary tree node.
5class TreeNode:
6    def __init__(self, val=0, left=None, right=None):
7        self.val = val
8        self.left = left
9        self.right = right
10
11class Solution:
12    def closestNodes(
13        self, root: Optional[TreeNode], queries: List[int]
14    ) -> List[List[int]]:
15        """
16        Find the closest smaller-or-equal and greater-or-equal values in a BST for each query.
17      
18        Args:
19            root: Root node of the binary search tree
20            queries: List of values to search for
21          
22        Returns:
23            List of [min_val, max_val] pairs where:
24            - min_val is the largest value <= query (or -1 if none exists)
25            - max_val is the smallest value >= query (or -1 if none exists)
26        """
27      
28        def inorder_traversal(node: Optional[TreeNode]) -> None:
29            """
30            Perform in-order traversal to collect all values in sorted order.
31          
32            Args:
33                node: Current node being visited
34            """
35            if node is None:
36                return
37          
38            # Traverse left subtree
39            inorder_traversal(node.left)
40          
41            # Process current node
42            sorted_values.append(node.val)
43          
44            # Traverse right subtree
45            inorder_traversal(node.right)
46      
47        # Collect all BST values in sorted order
48        sorted_values = []
49        inorder_traversal(root)
50      
51        # Process each query
52        result = []
53        for query_value in queries:
54            # Find the index for the largest value <= query_value
55            # bisect_left(sorted_values, query_value + 1) finds the insertion point for (query_value + 1)
56            # Subtracting 1 gives us the index of the largest value <= query_value
57            max_smaller_or_equal_idx = bisect_left(sorted_values, query_value + 1) - 1
58          
59            # Find the index for the smallest value >= query_value
60            min_greater_or_equal_idx = bisect_left(sorted_values, query_value)
61          
62            # Get the actual values or -1 if index is out of bounds
63            min_val = sorted_values[max_smaller_or_equal_idx] if 0 <= max_smaller_or_equal_idx < len(sorted_values) else -1
64            max_val = sorted_values[min_greater_or_equal_idx] if 0 <= min_greater_or_equal_idx < len(sorted_values) else -1
65          
66            # Append the pair to results
67            result.append([min_val, max_val])
68      
69        return result
70
1/**
2 * Definition for a binary tree node.
3 * public class TreeNode {
4 *     int val;
5 *     TreeNode left;
6 *     TreeNode right;
7 *     TreeNode() {}
8 *     TreeNode(int val) { this.val = val; }
9 *     TreeNode(int val, TreeNode left, TreeNode right) {
10 *         this.val = val;
11 *         this.left = left;
12 *         this.right = right;
13 *     }
14 * }
15 */
16class Solution {
17    // List to store BST values in sorted order
18    private List<Integer> sortedValues = new ArrayList<>();
19
20    /**
21     * Finds the closest nodes for each query in a BST.
22     * For each query, returns [maxValueLessThanOrEqual, minValueGreaterThanOrEqual]
23     * 
24     * @param root The root of the binary search tree
25     * @param queries List of query values to search for
26     * @return List of pairs containing the closest smaller and larger values for each query
27     */
28    public List<List<Integer>> closestNodes(TreeNode root, List<Integer> queries) {
29        // Perform in-order traversal to get sorted values from BST
30        inOrderTraversal(root);
31      
32        // Result list to store answer pairs for each query
33        List<List<Integer>> result = new ArrayList<>();
34      
35        // Process each query
36        for (int queryValue : queries) {
37            // Find the position where (queryValue + 1) would be inserted
38            // This helps find the largest value <= queryValue
39            int indexForLargerValue = Collections.binarySearch(sortedValues, queryValue + 1);
40          
41            // Find the position where queryValue would be inserted
42            // This helps find the smallest value >= queryValue
43            int indexForCurrentValue = Collections.binarySearch(sortedValues, queryValue);
44          
45            // Convert binary search results to actual indices
46            // If not found, binarySearch returns -(insertion point) - 1
47            int maxLessOrEqualIndex = indexForLargerValue < 0 ? 
48                -indexForLargerValue - 2 : indexForLargerValue - 1;
49            int minGreaterOrEqualIndex = indexForCurrentValue < 0 ? 
50                -indexForCurrentValue - 1 : indexForCurrentValue;
51          
52            // Get the actual values or -1 if index is out of bounds
53            int maxLessOrEqual = (maxLessOrEqualIndex >= 0 && maxLessOrEqualIndex < sortedValues.size()) ? 
54                sortedValues.get(maxLessOrEqualIndex) : -1;
55            int minGreaterOrEqual = (minGreaterOrEqualIndex >= 0 && minGreaterOrEqualIndex < sortedValues.size()) ? 
56                sortedValues.get(minGreaterOrEqualIndex) : -1;
57          
58            // Add the pair to result
59            result.add(List.of(maxLessOrEqual, minGreaterOrEqual));
60        }
61      
62        return result;
63    }
64
65    /**
66     * Performs in-order traversal of the BST to collect values in sorted order
67     * 
68     * @param node Current node being processed
69     */
70    private void inOrderTraversal(TreeNode node) {
71        // Base case: if node is null, return
72        if (node == null) {
73            return;
74        }
75      
76        // Traverse left subtree
77        inOrderTraversal(node.left);
78      
79        // Process current node
80        sortedValues.add(node.val);
81      
82        // Traverse right subtree
83        inOrderTraversal(node.right);
84    }
85}
86
1/**
2 * Definition for a binary tree node.
3 * struct TreeNode {
4 *     int val;
5 *     TreeNode *left;
6 *     TreeNode *right;
7 *     TreeNode() : val(0), left(nullptr), right(nullptr) {}
8 *     TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
9 *     TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
10 * };
11 */
12class Solution {
13public:
14    vector<vector<int>> closestNodes(TreeNode* root, vector<int>& queries) {
15        // Store all values from BST in sorted order
16        vector<int> sortedValues;
17      
18        // In-order traversal to get sorted values from BST
19        function<void(TreeNode*)> inorderTraversal = [&](TreeNode* node) {
20            if (!node) {
21                return;
22            }
23          
24            // Traverse left subtree
25            inorderTraversal(node->left);
26          
27            // Process current node
28            sortedValues.push_back(node->val);
29          
30            // Traverse right subtree
31            inorderTraversal(node->right);
32        };
33      
34        // Perform in-order traversal to populate sorted values
35        inorderTraversal(root);
36      
37        // Result vector to store [min, max] pairs for each query
38        vector<vector<int>> result;
39        int arraySize = sortedValues.size();
40      
41        // Process each query
42        for (int& queryValue : queries) {
43            // Find the largest value <= queryValue
44            // lower_bound(x+1) - 1 gives us the last element <= x
45            int maxIndexLessOrEqual = lower_bound(sortedValues.begin(), sortedValues.end(), queryValue + 1) 
46                                      - sortedValues.begin() - 1;
47          
48            // Find the smallest value >= queryValue
49            // lower_bound(x) gives us the first element >= x
50            int minIndexGreaterOrEqual = lower_bound(sortedValues.begin(), sortedValues.end(), queryValue) 
51                                         - sortedValues.begin();
52          
53            // Get the actual values or -1 if index is out of bounds
54            int minValue = (maxIndexLessOrEqual >= 0 && maxIndexLessOrEqual < arraySize) 
55                          ? sortedValues[maxIndexLessOrEqual] : -1;
56            int maxValue = (minIndexGreaterOrEqual >= 0 && minIndexGreaterOrEqual < arraySize) 
57                          ? sortedValues[minIndexGreaterOrEqual] : -1;
58          
59            // Add the pair [minValue, maxValue] to result
60            result.push_back({minValue, maxValue});
61        }
62      
63        return result;
64    }
65};
66
1/**
2 * Definition for a binary tree node.
3 * class TreeNode {
4 *     val: number
5 *     left: TreeNode | null
6 *     right: TreeNode | null
7 *     constructor(val?: number, left?: TreeNode | null, right?: TreeNode | null) {
8 *         this.val = (val===undefined ? 0 : val)
9 *         this.left = (left===undefined ? null : left)
10 *         this.right = (right===undefined ? null : right)
11 *     }
12 * }
13 */
14
15/**
16 * Finds the closest nodes in a BST for each query value
17 * @param root - The root of the binary search tree
18 * @param queries - Array of values to find closest nodes for
19 * @returns Array of [min, max] pairs where min <= query <= max
20 */
21function closestNodes(root: TreeNode | null, queries: number[]): number[][] {
22    // Array to store all node values in sorted order
23    const sortedValues: number[] = [];
24  
25    /**
26     * Performs in-order traversal to collect all node values in sorted order
27     * @param node - Current node being traversed
28     */
29    const inOrderTraversal = (node: TreeNode | null): void => {
30        if (!node) {
31            return;
32        }
33      
34        // Traverse left subtree
35        inOrderTraversal(node.left);
36      
37        // Process current node
38        sortedValues.push(node.val);
39      
40        // Traverse right subtree
41        inOrderTraversal(node.right);
42    };
43  
44    /**
45     * Binary search to find the leftmost position where target can be inserted
46     * @param target - Value to search for
47     * @returns Index of the first element >= target
48     */
49    const binarySearch = (target: number): number => {
50        let left: number = 0;
51        let right: number = sortedValues.length;
52      
53        while (left < right) {
54            const mid: number = Math.floor((left + right) / 2);
55          
56            if (sortedValues[mid] >= target) {
57                // Target could be at mid or to the left
58                right = mid;
59            } else {
60                // Target must be to the right
61                left = mid + 1;
62            }
63        }
64      
65        return left;
66    };
67  
68    // Collect all values from the BST in sorted order
69    inOrderTraversal(root);
70  
71    // Process each query to find closest nodes
72    const result: number[][] = [];
73  
74    for (const queryValue of queries) {
75        // Find the index of the largest element <= queryValue
76        const maxLessOrEqualIndex: number = binarySearch(queryValue + 1) - 1;
77      
78        // Find the index of the smallest element >= queryValue
79        const minGreaterOrEqualIndex: number = binarySearch(queryValue);
80      
81        // Determine the maximum value <= queryValue (or -1 if none exists)
82        const maxLessOrEqual: number = maxLessOrEqualIndex >= 0 
83            ? sortedValues[maxLessOrEqualIndex] 
84            : -1;
85      
86        // Determine the minimum value >= queryValue (or -1 if none exists)
87        const minGreaterOrEqual: number = minGreaterOrEqualIndex < sortedValues.length 
88            ? sortedValues[minGreaterOrEqualIndex] 
89            : -1;
90      
91        result.push([maxLessOrEqual, minGreaterOrEqual]);
92    }
93  
94    return result;
95}
96

Time and Space Complexity

Time Complexity: O(n + m × log n)

The time complexity consists of two main parts:

  • O(n) for the DFS traversal to collect all node values from the BST, where n is the number of nodes. The DFS visits each node exactly once.
  • O(m × log n) for processing all queries, where m is the number of queries. For each query:
    • bisect_left(nums, x + 1) takes O(log n) time (binary search on sorted array)
    • bisect_left(nums, x) takes O(log n) time (binary search on sorted array)
    • Array indexing and appending to result takes O(1) time

Since we perform 2 × log n operations for each of the m queries, the total query processing time is O(m × log n).

Therefore, the overall time complexity is O(n + m × log n).

Space Complexity: O(n)

The space complexity breaks down as follows:

  • O(n) for the nums array that stores all node values from the BST
  • O(n) for the recursion call stack in the worst case (skewed tree)
  • O(m) for the ans array storing the results, but this is typically considered as output space

The dominant space usage is O(n) for storing the sorted array of node values and the recursion stack, giving us an overall space complexity of O(n).

Learn more about how to find time and space complexity quickly.

Common Pitfalls

1. Incorrect Binary Search Logic for Floor Values

A common mistake is using bisect_left(sorted_values, query_value) for finding the floor value and then simply subtracting 1. This fails when the query value exists in the array.

Incorrect approach:

# This fails when query_value exists in the array
floor_idx = bisect_left(sorted_values, query_value) - 1

Why it fails: If query_value = 5 and sorted_values = [1, 3, 5, 7], bisect_left(sorted_values, 5) returns 2 (index of 5). Subtracting 1 gives index 1 (value 3), but the correct floor should be 5 itself!

Correct solution:

# Use query_value + 1 to ensure we get the correct floor
floor_idx = bisect_left(sorted_values, query_value + 1) - 1

2. Not Handling Empty Trees

The code assumes the tree has at least one node. An empty tree will result in an empty sorted_values array, causing issues with binary search.

Problem scenario:

root = None
queries = [5, 10]
# sorted_values will be empty []

Solution - Add explicit check:

def closestNodes(self, root: Optional[TreeNode], queries: List[int]) -> List[List[int]]:
    if root is None:
        return [[-1, -1] for _ in queries]
  
    # Rest of the code...

3. Misunderstanding bisect_left Behavior

Developers often confuse bisect_left with bisect_right. Using bisect_right for ceiling values would give incorrect results when the query value exists in the array.

Incorrect:

# Using bisect_right for ceiling - WRONG!
ceiling_idx = bisect_right(sorted_values, query_value)

Why it fails: If query_value = 5 exists in the array, bisect_right would skip over it and return the index of the next larger value, missing that 5 itself is a valid ceiling.

Correct:

# bisect_left correctly finds the ceiling
ceiling_idx = bisect_left(sorted_values, query_value)

4. Off-by-One Errors in Index Validation

A subtle pitfall is incorrectly checking index bounds, especially forgetting that valid indices range from 0 to len(sorted_values) - 1.

Common mistake:

# Forgetting to check upper bound properly
min_val = sorted_values[floor_idx] if floor_idx >= 0 else -1  # Missing upper bound check!

Correct validation:

min_val = sorted_values[floor_idx] if 0 <= floor_idx < len(sorted_values) else -1

5. Inefficient Query Processing Without Sorted Array

Some might attempt to search the BST directly for each query without first extracting values, leading to O(q × h) complexity where h is tree height, which could be O(q × n) for unbalanced trees.

Inefficient approach:

def find_floor_ceiling(node, query):
    # Searching BST for each query individually
    # This is O(h) per query, potentially O(n) for unbalanced trees

Better approach (as in the solution):

  • Extract all values once: O(n)
  • Use binary search for queries: O(q × log n)
  • Total: O(n + q × log n) - much better for multiple queries
Discover Your Strengths and Weaknesses: Take Our 5-Minute Quiz to Tailor Your Study Plan:

In a binary min heap, the maximum element can be found in:


Recommended Readings

Want a Structured Path to Master System Design Too? Don’t Miss This!

Load More