238 lines
10 KiB
YAML
238 lines
10 KiB
YAML
title: All Possible Full Binary Trees
|
||
slug: all-possible-full-binary-trees
|
||
difficulty: medium
|
||
leetcode_id: 894
|
||
leetcode_url: https://leetcode.com/problems/all-possible-full-binary-trees/
|
||
categories:
|
||
- trees
|
||
- recursion
|
||
- dynamic-programming
|
||
patterns:
|
||
- slug: backtracking
|
||
is_optimal: false
|
||
- slug: dynamic-programming
|
||
is_optimal: true
|
||
|
||
function_signature: "def all_possible_fbt(n: int) -> list[TreeNode]:"
|
||
|
||
test_cases:
|
||
visible:
|
||
- input: { n: 7 }
|
||
expected: 5
|
||
- input: { n: 3 }
|
||
expected: 1
|
||
hidden:
|
||
- input: { n: 1 }
|
||
expected: 1
|
||
- input: { n: 2 }
|
||
expected: 0
|
||
- input: { n: 5 }
|
||
expected: 2
|
||
- input: { n: 9 }
|
||
expected: 14
|
||
- input: { n: 4 }
|
||
expected: 0
|
||
|
||
description: |
|
||
Given an integer `n`, return *a list of all possible **full binary trees** with* `n` *nodes*. Each node of each tree in the answer must have `Node.val == 0`.
|
||
|
||
Each element of the answer is the root node of one possible tree. You may return the final list of trees in **any order**.
|
||
|
||
A **full binary tree** is a binary tree where each node has exactly `0` or `2` children.
|
||
|
||
constraints: |
|
||
- `1 <= n <= 20`
|
||
|
||
examples:
|
||
- input: "n = 7"
|
||
output: "[[0,0,0,null,null,0,0,null,null,0,0],[0,0,0,null,null,0,0,0,0],[0,0,0,0,0,0,0],[0,0,0,0,0,null,null,null,null,0,0],[0,0,0,0,0,null,null,0,0]]"
|
||
explanation: "With 7 nodes, there are 5 possible full binary trees. Each tree has nodes with exactly 0 or 2 children."
|
||
- input: "n = 3"
|
||
output: "[[0,0,0]]"
|
||
explanation: "With 3 nodes, there is only one possible full binary tree: a root with two children."
|
||
|
||
explanation:
|
||
intuition: |
|
||
A **full binary tree** has a special property: every node has either 0 children (leaf) or exactly 2 children. This constraint immediately tells us something crucial — a full binary tree can only exist when `n` is **odd**.
|
||
|
||
Why? Think about it: we start with a root (1 node). Every time we add children, we must add exactly 2 nodes (not 1). So the count goes 1 → 3 → 5 → 7... always odd. If `n` is even, no valid full binary tree exists.
|
||
|
||
Now, how do we construct all possible trees with `n` nodes? Imagine you're the root node. You must have exactly two subtrees: a left and a right. If you use `i` nodes for the left subtree, you have `n - 1 - i` nodes remaining for the right subtree (subtracting 1 for yourself, the root).
|
||
|
||
The key insight is that this is a **divide and conquer** problem. For each valid split of nodes between left and right subtrees, we recursively find all possible left subtrees and all possible right subtrees, then combine every left-right pair with a new root.
|
||
|
||
This naturally leads to a recursive structure where the answer for `n` nodes depends on answers for smaller values of `n`.
|
||
|
||
approach: |
|
||
We solve this using **Recursion with Memoisation** — breaking the problem into smaller subproblems and caching results.
|
||
|
||
**Step 1: Handle base cases**
|
||
|
||
- If `n` is even, return an empty list (no full binary tree possible)
|
||
- If `n == 1`, return a list containing a single leaf node
|
||
|
||
|
||
|
||
**Step 2: Recursively build trees**
|
||
|
||
- For a tree with `n` nodes, try all ways to split remaining `n - 1` nodes between left and right subtrees
|
||
- Left subtree gets `i` nodes, right subtree gets `n - 1 - i` nodes
|
||
- Since both subtrees must be full binary trees, `i` must be odd and between 1 and `n - 2`
|
||
- Iterate `i` from 1 to `n - 1` in steps of 2 (only odd values)
|
||
|
||
|
||
|
||
**Step 3: Combine subtrees**
|
||
|
||
- Recursively get all possible left subtrees with `i` nodes
|
||
- Recursively get all possible right subtrees with `n - 1 - i` nodes
|
||
- For each combination of left and right subtree, create a new root node connecting them
|
||
- Add each complete tree to the result list
|
||
|
||
|
||
|
||
**Step 4: Memoise results**
|
||
|
||
- Cache results for each value of `n` to avoid recomputation
|
||
- When building trees for `n = 7`, we might need trees for `n = 3` multiple times
|
||
- Memoisation ensures we compute each subproblem only once
|
||
|
||
|
||
|
||
The total number of full binary trees with `n` nodes follows the **Catalan number** sequence, which grows exponentially but is well within bounds for `n <= 20`.
|
||
|
||
common_pitfalls:
|
||
- title: Forgetting the Odd Constraint
|
||
description: |
|
||
A full binary tree can only have an odd number of nodes. If you don't check this upfront, you'll waste computation or return incorrect results for even `n`.
|
||
|
||
For `n = 4`, the answer should be an empty list, not an error or an invalid tree.
|
||
wrong_approach: "Try to build trees for any n without checking parity"
|
||
correct_approach: "Return empty list immediately when n is even"
|
||
|
||
- title: Skipping Memoisation
|
||
description: |
|
||
Without memoisation, the same subproblems get solved repeatedly. For `n = 15`, computing all trees for `n = 7` happens multiple times during different splits.
|
||
|
||
This leads to exponential time complexity instead of the manageable complexity with caching. The difference can be dramatic — solving `n = 20` goes from impractical to instant.
|
||
wrong_approach: "Pure recursion without caching"
|
||
correct_approach: "Use a dictionary or array to cache results for each n"
|
||
|
||
- title: Incorrect Node Splitting
|
||
description: |
|
||
When splitting `n` nodes between left and right subtrees, remember that the root takes 1 node. So if the total is `n`, and the root takes 1, you have `n - 1` nodes to distribute.
|
||
|
||
Also, both subtrees must have an odd number of nodes. Iterating through all values of `i` instead of just odd values wastes time on impossible cases.
|
||
wrong_approach: "Split n nodes instead of n-1, or try even values for subtree sizes"
|
||
correct_approach: "Iterate i from 1 to n-2 in steps of 2 (odd values only)"
|
||
|
||
key_takeaways:
|
||
- "**Structural recursion**: When a data structure is defined recursively (like trees), solutions often follow the same recursive structure"
|
||
- "**Divide and conquer with combinatorics**: Generate all combinations by recursively generating sub-solutions and combining them"
|
||
- "**Catalan numbers**: Full binary tree counts follow the Catalan sequence — this appears in many tree-related combinatorial problems"
|
||
- "**Memoisation is essential**: Overlapping subproblems make caching crucial for efficiency in tree-generation problems"
|
||
|
||
time_complexity: "O(2^n). The number of full binary trees with n nodes is the Catalan number C((n-1)/2), which grows exponentially. We generate each tree exactly once."
|
||
space_complexity: "O(n × 2^n). We store all generated trees, and each tree has O(n) nodes. The recursion stack adds O(n) depth."
|
||
|
||
solutions:
|
||
- approach_name: Recursion with Memoisation
|
||
is_optimal: true
|
||
code: |
|
||
class TreeNode:
|
||
def __init__(self, val=0, left=None, right=None):
|
||
self.val = val
|
||
self.left = left
|
||
self.right = right
|
||
|
||
|
||
def all_possible_fbt(n: int) -> list[TreeNode]:
|
||
# Cache to store results for each n
|
||
memo = {}
|
||
|
||
def build(num_nodes: int) -> list[TreeNode]:
|
||
# Check cache first
|
||
if num_nodes in memo:
|
||
return memo[num_nodes]
|
||
|
||
# Full binary trees only exist for odd n
|
||
if num_nodes % 2 == 0:
|
||
return []
|
||
|
||
# Base case: single node is a valid full binary tree
|
||
if num_nodes == 1:
|
||
return [TreeNode(0)]
|
||
|
||
result = []
|
||
|
||
# Try all ways to split n-1 nodes between left and right
|
||
# Both subtrees need odd number of nodes, so step by 2
|
||
for left_count in range(1, num_nodes, 2):
|
||
right_count = num_nodes - 1 - left_count
|
||
|
||
# Get all possible left and right subtrees
|
||
left_trees = build(left_count)
|
||
right_trees = build(right_count)
|
||
|
||
# Combine every left-right pair with a new root
|
||
for left in left_trees:
|
||
for right in right_trees:
|
||
root = TreeNode(0)
|
||
root.left = left
|
||
root.right = right
|
||
result.append(root)
|
||
|
||
# Cache and return
|
||
memo[num_nodes] = result
|
||
return result
|
||
|
||
return build(n)
|
||
explanation: |
|
||
**Time Complexity:** O(2^n) — We generate all Catalan((n-1)/2) trees, each requiring O(n) construction time.
|
||
|
||
**Space Complexity:** O(n × 2^n) — Storing all trees, each with O(n) nodes, plus O(n) recursion depth.
|
||
|
||
The memoisation dictionary stores all trees for each odd value from 1 to n. This prevents recomputation when the same subtree size is needed for different parent splits. The nested loops combine all valid left-right subtree pairs.
|
||
|
||
- approach_name: Bottom-Up Dynamic Programming
|
||
is_optimal: false
|
||
code: |
|
||
class TreeNode:
|
||
def __init__(self, val=0, left=None, right=None):
|
||
self.val = val
|
||
self.left = left
|
||
self.right = right
|
||
|
||
|
||
def all_possible_fbt(n: int) -> list[TreeNode]:
|
||
# Full binary trees only exist for odd n
|
||
if n % 2 == 0:
|
||
return []
|
||
|
||
# dp[i] = list of all full binary trees with i nodes
|
||
dp = {1: [TreeNode(0)]}
|
||
|
||
# Build up from 3 nodes to n nodes (odd values only)
|
||
for num_nodes in range(3, n + 1, 2):
|
||
dp[num_nodes] = []
|
||
|
||
# Try all ways to split num_nodes-1 between left and right
|
||
for left_count in range(1, num_nodes, 2):
|
||
right_count = num_nodes - 1 - left_count
|
||
|
||
# Combine all left-right pairs
|
||
for left in dp[left_count]:
|
||
for right in dp[right_count]:
|
||
root = TreeNode(0)
|
||
root.left = left
|
||
root.right = right
|
||
dp[num_nodes].append(root)
|
||
|
||
return dp.get(n, [])
|
||
explanation: |
|
||
**Time Complexity:** O(2^n) — Same as recursive, we build all valid trees.
|
||
|
||
**Space Complexity:** O(n × 2^n) — We store trees for all odd values up to n.
|
||
|
||
This iterative approach builds solutions bottom-up, starting from the base case of 1 node and working up to n nodes. It's equivalent to the recursive solution but makes the memoisation explicit as a dictionary indexed by node count. Some prefer this style as it avoids recursion overhead.
|