95. Unique Binary Search Trees II - Explanation
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:
Input: n = 1
Output: [[1]]Explanation: With only value 1, the only valid tree is a single node.
Example 2:
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:
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
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
- Define a recursive function
generate(left, right)that returns a list of all unique BSTs using values fromlefttoright. - Base case: If
left > right, return a list containingnull(representing an empty subtree). - For each value
valfromlefttoright:- Recursively generate all left subtrees using
generate(left, val - 1). - Recursively generate all right subtrees using
generate(val + 1, right). - For each combination of
leftTreeandrightTree, create a new tree withvalas root and add it tores.
- Recursively generate all left subtrees using
- Return
res. - 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:
- Space complexity:
- space for recursion stack.
- 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
- Create a 2D memoization table
dpto store results for each(left, right)pair. - Define a recursive function
generate(left, right)similar to the basic recursive approach. - Before computing, check if
dp[left][right]already has a value. If so, return the cached list. - Compute all trees for the range and store
resindp[left][right]. - 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:
- Space complexity:
- space for recursion stack.
- extra space.
- 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
- Create a 2D table
dp[left][right]to store all BSTs for each range. - Initialize base cases: For each
i, setdp[i][i-1] = [null](empty subtree). - For each
lengthfrom1ton:- For each starting position
leftwhereleft + length - 1 <= n:- Let
right = left + length - 1. - For each root value
valfromlefttoright:- Combine all trees from
dp[left][val-1]anddp[val+1][right]. - Create a new tree node for each combination and add to
dp[left][right].
- Combine all trees from
- Let
- For each starting position
- 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:
- Space complexity:
- extra space.
- 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
- Create an array
dp[length]to store all BST structures for trees withlengthnodes. - Initialize
dp[0] = [null](empty tree). - For each
lengthfrom1ton:- For each root position
valfrom1tolength:- Left subtree has
val - 1nodes (fromdp[val-1]). - Right subtree has
length - valnodes (fromdp[length-val]), but needs value shifting. - Create trees by combining
leftTreefromdp[val-1]withshift(rightTree, val)fromdp[length-val].
- Left subtree has
- For each root position
- Define a
shift(node, offset)function that creates a new tree with all values increased byoffset. - 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 rootTime & Space Complexity
- Time complexity:
- Space complexity:
- for the recursion stack.
- extra space.
- 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.
Sign in to join the discussion