95. Unique Binary Search Trees II - Explanation

Problem Link

Description

Return the roots of all structurally distinct binary search trees that contain each value from 1 through n exactly once.

In a binary search tree, every value in a node's left subtree is smaller than the node's value, and every value in its right subtree is larger. Trees with different left/right arrangements are distinct.

Return every valid tree exactly once, in any order. The examples show each tree in level order, using null for missing children and omitting trailing nulls.

Example 1:

Unique Binary Search Trees II, example 1: illustration of the example

Input: n = 1

Output: [[1]]

Explanation: With only value 1, the only valid tree is a single node.

Example 2:

Unique Binary Search Trees II, example 2: illustration of the example

Input: n = 2

Output: [
  [1,null,2],
  [2,1]
]

Explanation: Either value can be the root. Root 1 must have right child 2; root 2 must have left child 1. These are the only two structures.

Example 3:

Unique Binary Search Trees II, example 3: illustration of the example

Input: n = 3

Output: [
  [1,null,2,null,3],
  [1,null,3,2],
  [2,1,3],
  [3,1,null,null,2],
  [3,2,null,1]
]

Explanation: Root 1 permits two arrangements of values 2 and 3 on its right. Root 2 has left child 1 and right child 3. Root 3 permits two arrangements on its left, giving five distinct trees.

Constraints:

  • 1 <= n <= 8


Topics

Company Tags

Please upgrade to NeetCode Pro to view company tags.



Prerequisites

Before attempting this problem, you should be comfortable with:

  • Binary Search Tree Properties - Understanding that left subtree values are smaller and right subtree values are larger than the root
  • Recursion - Building solutions by combining results from recursive subproblems
  • Dynamic Programming - Using memoization to cache and reuse previously computed subtrees
  • Tree Construction - Creating and linking tree nodes to build complete tree structures

1. Recursion

Intuition

To generate all unique BSTs with values from 1 to n, we pick each value as the root and recursively generate all possible left and right subtrees. When val is the root, values 1 to val-1 form the left subtree and values val+1 to n form the right subtree. We combine every leftTree with every rightTree to create all unique trees with that root.

Algorithm

  1. Define a recursive function generate(left, right) that returns a list of all unique BSTs using values from left to right.
  2. Base case: If left > right, return a list containing null (representing an empty subtree).
  3. For each value val from left to right:
    • Recursively generate all left subtrees using generate(left, val - 1).
    • Recursively generate all right subtrees using generate(val + 1, right).
    • For each combination of leftTree and rightTree, create a new tree with val as root and add it to res.
  4. Return res.
  5. Call generate(1, n) to get the final result.
# 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
class Solution:
    def generateTrees(self, n: int) -> List[Optional[TreeNode]]:
        def generate(left, right):
            if left > right:
                return [None]

            res = []
            for val in range(left, right + 1):
                for leftTree in generate(left, val - 1):
                    for rightTree in generate(val + 1, right):
                        root = TreeNode(val, leftTree, rightTree)
                        res.append(root)
            return res

        return generate(1, n)

Time & Space Complexity

  • Time complexity: O(4nn)O(\frac {4 ^ n}{\sqrt {n}})
  • Space complexity:
    • O(n)O(n) space for recursion stack.
    • O(4nn)O(\frac {4 ^ n}{\sqrt {n}}) space for the output.

2. Dynamic Programming (Top-Down)

Intuition

The recursive solution recomputes the same ranges multiple times. For example, generate(2, 4) might be called from different parent recursions. We can cache results for each (left, right) pair to avoid regenerating the same subtrees.

Algorithm

  1. Create a 2D memoization table dp to store results for each (left, right) pair.
  2. Define a recursive function generate(left, right) similar to the basic recursive approach.
  3. Before computing, check if dp[left][right] already has a value. If so, return the cached list.
  4. Compute all trees for the range and store res in dp[left][right].
  5. Return generate(1, n).
# 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
class Solution:
    def generateTrees(self, n: int) -> List[Optional[TreeNode]]:
        dp = {}

        def generate(left, right):
            if left > right:
                return [None]
            if (left, right) in dp:
                return dp[(left, right)]

            res = []
            for val in range(left, right + 1):
                for leftTree in generate(left, val - 1):
                    for rightTree in generate(val + 1, right):
                        root = TreeNode(val, leftTree, rightTree)
                        res.append(root)

            dp[(left, right)] = res
            return res

        return generate(1, n)

Time & Space Complexity

  • Time complexity: O(4nn)O(\frac {4 ^ n}{\sqrt {n}})
  • Space complexity:
    • O(n)O(n) space for recursion stack.
    • O(n2)O(n ^ 2) extra space.
    • O(4nn)O(\frac {4 ^ n}{\sqrt {n}}) space for the output.

3. Dynamic Programming (Bottom-Up)

Intuition

We can build the solution iteratively by computing all BSTs for smaller ranges first. We process ranges by increasing length: first all ranges of length 1, then length 2, and so on up to length n. This ensures that when we compute dp[left][right], all smaller subproblems are already solved.

Algorithm

  1. Create a 2D table dp[left][right] to store all BSTs for each range.
  2. Initialize base cases: For each i, set dp[i][i-1] = [null] (empty subtree).
  3. For each length from 1 to n:
    • For each starting position left where left + length - 1 <= n:
      • Let right = left + length - 1.
      • For each root value val from left to right:
        • Combine all trees from dp[left][val-1] and dp[val+1][right].
        • Create a new tree node for each combination and add to dp[left][right].
  4. Return dp[1][n].
# 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
class Solution:
    def generateTrees(self, n: int) -> List[Optional[TreeNode]]:
        dp = [[[] for _ in range(n + 2)] for _ in range(n + 2)]
        for i in range(1, n + 2):
            dp[i][i - 1] = [None]

        for length in range(1, n + 1):
            for left in range(1, n - length + 2):
                right = left + length - 1
                dp[left][right] = []
                for val in range(left, right + 1):
                    for leftTree in dp[left][val - 1]:
                        for rightTree in dp[val + 1][right]:
                            root = TreeNode(val, leftTree, rightTree)
                            dp[left][right].append(root)

        return dp[1][n]

Time & Space Complexity

  • Time complexity: O(4nn)O(\frac {4 ^ n}{\sqrt {n}})
  • Space complexity:
    • O(n2)O(n ^ 2) extra space.
    • O(4nn)O(\frac {4 ^ n}{\sqrt {n}}) space for the output.

4. Dynamic Programming (Space Optimized)

Intuition

The number of unique BST structures depends only on the count of nodes, not their actual values. We can generate all BST structures for sizes 0, 1, 2, ..., n and then adjust node values using a shift function. For a tree structure with nodes 1 to k, we can create a tree with nodes offset+1 to offset+k by adding offset to each node's value.

Algorithm

  1. Create an array dp[length] to store all BST structures for trees with length nodes.
  2. Initialize dp[0] = [null] (empty tree).
  3. For each length from 1 to n:
    • For each root position val from 1 to length:
      • Left subtree has val - 1 nodes (from dp[val-1]).
      • Right subtree has length - val nodes (from dp[length-val]), but needs value shifting.
      • Create trees by combining leftTree from dp[val-1] with shift(rightTree, val) from dp[length-val].
  4. Define a shift(node, offset) function that creates a new tree with all values increased by offset.
  5. Return dp[n].
# 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
class Solution:
    def generateTrees(self, n: int) -> list[Optional[TreeNode]]:
        dp = [[] for _ in range(n + 1)]
        dp[0] = [None]

        for length in range(1, n + 1):
            dp[length] = []
            for val in range(1, length + 1):
                for leftTree in dp[val - 1]:
                    for rightTree in dp[length - val]:
                        root = TreeNode(val)
                        root.left = leftTree
                        root.right = self.shift(rightTree, val)
                        dp[length].append(root)

        return dp[n]

    def shift(self, node: Optional[TreeNode], offset: int) -> Optional[TreeNode]:
        if not node:
            return None
        root = TreeNode(node.val + offset)
        root.left = self.shift(node.left, offset)
        root.right = self.shift(node.right, offset)
        return root

Time & Space Complexity

  • Time complexity: O(4nn)O(\frac {4 ^ n}{\sqrt {n}})
  • Space complexity:
    • O(n)O(n) for the recursion stack.
    • O(n)O(n) extra space.
    • O(4nn)O(\frac {4 ^ n}{\sqrt {n}}) space for the output.

Common Pitfalls

Returning Empty List Instead of List with Null

When the range is invalid (left > right), the base case should return a list containing null (representing an empty subtree), not an empty list. Returning an empty list causes the nested loops to skip all combinations, producing no trees at all.

Reusing Tree Nodes Across Different Trees

In the space-optimized approach, left subtrees are shared across multiple result trees since they have identical structure and values. However, right subtrees need to be cloned with shifted values. Forgetting to create new nodes when shifting values causes all trees to share and mutate the same nodes incorrectly.

Incorrect Value Shifting for Right Subtrees

When using the space-optimized DP approach, right subtree values must be shifted by the root value. A common mistake is applying the wrong offset or forgetting to recursively shift all nodes in the subtree. Each node in the right subtree should have offset added to its value.