LeetCode 1676 - Lowest Common Ancestor of a Binary Tree IV
This problem asks us to find the Lowest Common Ancestor, or LCA, of multiple nodes in a binary tree. Unlike the classic
Difficulty: 🟡 Medium
Topics: Hash Table, Tree, Depth-First Search, Binary Tree
Solution
Problem Understanding
This problem asks us to find the Lowest Common Ancestor, or LCA, of multiple nodes in a binary tree. Unlike the classic LCA problem that involves only two nodes, this variation generalizes the definition to any number of target nodes.
The input consists of:
root, the root node of a binary treenodes, an array containing references to distinct nodes that exist somewhere in the tree
The goal is to return the single node that is the lowest node in the tree whose subtree contains every node in nodes.
The phrase "lowest common ancestor" is important. A node is considered an ancestor of itself, so if the list contains only one node, that node is automatically its own LCA.
For example, if the target nodes are 4 and 7, their paths upward eventually meet at node 2, so the answer is 2.
The constraints tell us several important things:
- The tree can contain up to
10^4nodes, so we need an efficient traversal algorithm - All node values are unique, which allows us to safely identify nodes by value if needed
- Every target node is guaranteed to exist in the tree
- All target nodes are distinct
These guarantees simplify the implementation because we never need to handle invalid or duplicate targets.
Several edge cases are important:
- The
nodesarray may contain only one node - One target node may already be an ancestor of all the others
- The LCA may be the root itself
- The target nodes may all lie entirely within one subtree
- The tree may be extremely unbalanced, behaving like a linked list
A naive implementation can easily become inefficient if it repeatedly searches subtrees for each target node. Since the tree size can reach 10^4, we should aim for a linear time solution.
Approaches
Brute Force Approach
A straightforward approach is to compute the ancestor chain for every target node and then compare them.
One way to do this is:
- Build parent pointers for the entire tree
- For each target node, walk upward toward the root and record all ancestors
- Intersect the ancestor sets
- Find the deepest common ancestor
This approach works because the LCA must appear in the ancestor chain of every target node.
However, this solution introduces unnecessary overhead. We spend extra time building parent mappings and repeatedly traversing upward for every target node. While still acceptable for moderate constraints, it is more complicated and less elegant than necessary.
Optimal DFS Approach
The key insight is that this problem can be solved using the exact same recursive structure as the classic two-node LCA problem.
For any subtree:
- If the current node is one of the targets, we return it
- Otherwise, recursively search the left and right subtrees
- If both sides return non-null values, the current node is the LCA
- If only one side returns a non-null value, propagate that upward
The only difference from the classic problem is that instead of checking whether the current node equals p or q, we check whether it belongs to the set of target nodes.
This works because the first node whose subtree contains targets from multiple branches must be the lowest common ancestor of all target nodes.
Using a hash set for the targets allows constant time membership checks.
| Approach | Time Complexity | Space Complexity | Notes |
|---|---|---|---|
| Brute Force | O(N + KH) | O(N + KH) | Builds parent map and ancestor chains |
| Optimal | O(N) | O(H + K) | Single DFS traversal with hash set lookup |
Where:
Nis the number of nodes in the treeKis the number of target nodesHis the height of the tree
Algorithm Walkthrough
- Convert the
nodeslist into a hash set.
We use a hash set because membership checks become O(1). During DFS, we need to quickly determine whether the current node is one of the targets.
2. Start a depth-first search from the root.
The recursive function explores the tree bottom-up. Each recursive call returns either:
None, if no target node exists in that subtree- A target node or LCA candidate, if one exists in that subtree
- Check whether the current node is a target.
If the current node exists in the target set, immediately return it.
This is important because a target node can itself become the LCA if all other targets lie beneath it. 4. Recursively search the left subtree.
The left recursive call tells us whether any target nodes exist in the left branch. 5. Recursively search the right subtree.
The right recursive call tells us whether any target nodes exist in the right branch. 6. Determine how to propagate results upward.
There are three possibilities:
- Both left and right are non-null
This means target nodes were found in both branches, so the current node is their lowest common ancestor.
- Only one side is non-null
This means all target nodes discovered so far lie within one subtree, so propagate that result upward.
- Both sides are null
This subtree contains no target nodes, so return None.
7. Continue until recursion completes.
The root DFS call eventually returns the overall LCA.
Why it works
The algorithm works because every recursive call summarizes whether its subtree contains any target nodes.
The first node where targets appear in multiple branches must be the lowest node whose subtree contains all targets. Since recursion processes children before parents, the algorithm naturally discovers the lowest valid ancestor before any higher ancestor.
Python Solution
# Definition for a binary tree node.
# class TreeNode:
# def __init__(self, x):
# self.val = x
# self.left = None
# self.right = None
from typing import List, Optional
class Solution:
def lowestCommonAncestor(
self,
root: 'TreeNode',
nodes: 'List[TreeNode]'
) -> 'TreeNode':
target_nodes = set(nodes)
def dfs(current: Optional['TreeNode']) -> Optional['TreeNode']:
if current is None:
return None
if current in target_nodes:
return current
left_result = dfs(current.left)
right_result = dfs(current.right)
if left_result and right_result:
return current
return left_result if left_result else right_result
return dfs(root)
The implementation begins by converting the nodes list into a set called target_nodes. This allows constant time membership testing during traversal.
The recursive dfs function processes the tree bottom-up.
The base case handles null nodes by returning None, which indicates that no target node exists in that subtree.
Next, the algorithm checks whether the current node belongs to the target set. If it does, the current node is returned immediately. This allows ancestor relationships among targets to work correctly.
The recursive calls search both subtrees independently.
If both recursive calls return non-null results, the current node must be the lowest node connecting targets from different branches, so it becomes the LCA.
Otherwise, whichever subtree returned a non-null result is propagated upward.
Finally, the original DFS call from the root returns the overall answer.
Go Solution
/**
* Definition for a binary tree node.
* type TreeNode struct {
* Val int
* Left *TreeNode
* Right *TreeNode
* }
*/
func lowestCommonAncestor(root *TreeNode, nodes []*TreeNode) *TreeNode {
targetSet := make(map[*TreeNode]bool)
for _, node := range nodes {
targetSet[node] = true
}
var dfs func(node *TreeNode) *TreeNode
dfs = func(node *TreeNode) *TreeNode {
if node == nil {
return nil
}
if targetSet[node] {
return node
}
leftResult := dfs(node.Left)
rightResult := dfs(node.Right)
if leftResult != nil && rightResult != nil {
return node
}
if leftResult != nil {
return leftResult
}
return rightResult
}
return dfs(root)
}
The Go implementation follows the same recursive strategy as the Python solution.
Instead of a Python set, Go uses a map[*TreeNode]bool to track target nodes. Since node references are used as keys, membership checks remain constant time.
Go uses nil instead of None for missing nodes. The recursive DFS function returns a *TreeNode pointer, which naturally represents either a valid node or absence of a result.
No integer overflow concerns exist because the algorithm never performs arithmetic on node values.
Worked Examples
Example 1
Input:
root = [3,5,1,6,2,0,8,null,null,7,4]
nodes = [4,7]
Target set:
{4, 7}
Tree structure:
3
/ \
5 1
/ \ / \
6 2 0 8
/ \
7 4
DFS traversal:
| Current Node | Left Result | Right Result | Returned Value |
|---|---|---|---|
| 7 | None | None | 7 |
| 4 | None | None | 4 |
| 2 | 7 | 4 | 2 |
| 5 | None | 2 | 2 |
| 3 | 2 | None | 2 |
Final answer:
2
Example 2
Input:
nodes = [1]
Target set:
{1}
Traversal:
| Current Node | Action |
|---|---|
| 1 | Node is target, return 1 |
Since only one node exists in the target list, the node itself is the LCA.
Final answer:
1
Example 3
Input:
nodes = [7,6,2,4]
Target set:
{7,6,2,4}
Traversal summary:
| Current Node | Left Result | Right Result | Returned Value |
|---|---|---|---|
| 6 | None | None | 6 |
| 7 | None | None | 7 |
| 4 | None | None | 4 |
| 2 | 7 | 4 | 2 |
| 5 | 6 | 2 | 5 |
| 3 | 5 | None | 5 |
Final answer:
5
Complexity Analysis
| Measure | Complexity | Explanation |
|---|---|---|
| Time | O(N) | Every node is visited once during DFS |
| Space | O(H + K) | Recursion stack plus target hash set |
The DFS traversal touches each node exactly once, so the total runtime is linear in the size of the tree.
The recursion stack requires O(H) space, where H is the tree height. In the worst case of a completely skewed tree, this becomes O(N).
The target set stores all target nodes, requiring O(K) additional space.
Test Cases
# Definition for testing
class TreeNode:
def __init__(self, x):
self.val = x
self.left = None
self.right = None
def build_tree():
"""
3
/ \
5 1
/ \ / \
6 2 0 8
/ \
7 4
"""
nodes = {v: TreeNode(v) for v in [3, 5, 1, 6, 2, 0, 8, 7, 4]}
nodes[3].left = nodes[5]
nodes[3].right = nodes[1]
nodes[5].left = nodes[6]
nodes[5].right = nodes[2]
nodes[1].left = nodes[0]
nodes[1].right = nodes[8]
nodes[2].left = nodes[7]
nodes[2].right = nodes[4]
return nodes[3], nodes
solution = Solution()
# Example 1
root, nodes = build_tree()
assert solution.lowestCommonAncestor(
root,
[nodes[4], nodes[7]]
).val == 2 # LCA inside subtree
# Example 2
root, nodes = build_tree()
assert solution.lowestCommonAncestor(
root,
[nodes[1]]
).val == 1 # Single node case
# Example 3
root, nodes = build_tree()
assert solution.lowestCommonAncestor(
root,
[nodes[7], nodes[6], nodes[2], nodes[4]]
).val == 5 # Multiple nodes under same ancestor
# Root is the LCA
root, nodes = build_tree()
assert solution.lowestCommonAncestor(
root,
[nodes[6], nodes[8]]
).val == 3 # Targets split across root
# One target is ancestor of another
root, nodes = build_tree()
assert solution.lowestCommonAncestor(
root,
[nodes[5], nodes[4]]
).val == 5 # Ancestor node included in targets
# All targets in left subtree
root, nodes = build_tree()
assert solution.lowestCommonAncestor(
root,
[nodes[6], nodes[7], nodes[4]]
).val == 5 # Entirely within one subtree
# Two identical depth leaves
root, nodes = build_tree()
assert solution.lowestCommonAncestor(
root,
[nodes[0], nodes[8]]
).val == 1 # Sibling leaves
# Entire tree target set
root, nodes = build_tree()
assert solution.lowestCommonAncestor(
root,
list(nodes.values())
).val == 3 # Every node included
| Test | Why |
|---|---|
[4,7] |
Standard subtree LCA case |
[1] |
Single target node |
[7,6,2,4] |
Multiple targets across subtree branches |
[6,8] |
Root becomes LCA |
[5,4] |
Ancestor node included in targets |
[6,7,4] |
All targets within one subtree |
[0,8] |
Sibling leaf nodes |
| Entire tree | Maximum target coverage |
Edge Cases
Single Target Node
If the nodes array contains only one node, the correct answer is that node itself. Some implementations incorrectly continue searching for another matching branch and may accidentally return an ancestor instead.
This implementation handles the case naturally because the DFS immediately returns the node when it appears in the target set.
One Target Is an Ancestor of Others
Suppose the target list contains both node 5 and node 4, where 5 is an ancestor of 4.
A common bug is continuing recursion after discovering node 5, which can incorrectly promote a higher ancestor.
This implementation avoids that problem because target nodes are returned immediately. Once node 5 is found, it propagates upward as the valid LCA.
Root Is the Lowest Common Ancestor
When target nodes lie in completely different branches of the tree, the root itself may be the answer.
For example, nodes 6 and 8 exist in opposite subtrees of root 3.
The algorithm correctly handles this because left and right recursive calls both return non-null values at the root, causing the root to become the LCA.
Highly Unbalanced Tree
A skewed tree behaves like a linked list and can create deep recursion.
The algorithm still works correctly because each node is processed exactly once. The only impact is that recursion depth becomes O(N) instead of O(log N) for balanced trees.