1676. Lowest Common Ancestor of a Binary Tree IV 🔒
Problem Description
You are given the root
of a binary tree and an array of TreeNode
objects called nodes
. Your task is to find and return the lowest common ancestor (LCA) of all the nodes in the nodes
array.
The lowest common ancestor is defined as the lowest node in the tree that has every node in the nodes
array as a descendant. Note that a node can be considered a descendant of itself.
Key points to understand:
- All nodes in the
nodes
array are guaranteed to exist in the tree - All node values in the tree are unique
- A descendant of node
x
is any nodey
that lies on the path fromx
to any leaf node - The LCA is the deepest (lowest) node that is an ancestor of all target nodes
For example, if you have nodes p1, p2, ..., pn
in your array, you need to find the lowest node in the tree such that all of these nodes are either:
- The node itself, or
- In the subtree rooted at that node
The solution uses a hash table to store all target node values for quick lookup, then performs a depth-first search (DFS) traversal. During the traversal:
- If a node is
null
or its value matches one of the target nodes, return that node - Recursively search both left and right subtrees
- If both subtrees return non-null values, the current node is the LCA
- Otherwise, return whichever subtree returned a non-null value
This approach efficiently finds the common ancestor by identifying the point where paths to different target nodes diverge in the tree structure.
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 tree is a special type of graph where each node has at most two children and there are no cycles.
Is it a tree?
- Yes: The problem explicitly states we're working with a binary tree structure.
DFS
- Following the "yes" path from the tree node leads us directly to DFS.
Conclusion: The flowchart suggests using a Depth-First Search (DFS) approach for this problem.
This makes perfect sense for the Lowest Common Ancestor problem because:
- We need to traverse the entire tree structure to locate all target nodes
- We need to identify the deepest node that is an ancestor to all target nodes
- DFS allows us to explore each subtree completely and determine if it contains any of our target nodes
- By recursively checking left and right subtrees, we can identify the exact point where paths to different target nodes converge, which is the LCA
The DFS pattern fits naturally with the recursive nature of tree problems, where we process the current node and then recursively process its children, building up our solution from the bottom of the tree upward.
Intuition
When we need to find the lowest common ancestor of multiple nodes, we can think about what makes a node special - it's the deepest point in the tree where all paths from the root to our target nodes still overlap.
The key insight is that during a DFS traversal, we can identify important nodes by checking if they match any of our target nodes. If we traverse from the root downward and mark when we find target nodes, we can determine the LCA by looking at where these markings converge.
Think of it like finding where multiple rivers meet. If we trace each river (path to target node) backward from its source (target node) toward the ocean (root), the LCA is the last point where all these rivers flow through the same channel.
The recursive DFS approach works perfectly here because:
- When we hit a target node, we can immediately return it as a potential ancestor
- For any non-target node, we check both its left and right subtrees
- If both subtrees contain target nodes (return non-null), then the current node must be where paths diverge - making it the LCA
- If only one subtree contains target nodes, we propagate that result upward
Using a hash set to store target node values is crucial for efficiency - it lets us check in O(1)
time whether any node we visit is one of our targets, rather than scanning through the entire array each time.
The beauty of this solution is that it naturally handles all cases: whether the LCA is one of the target nodes itself, or an ancestor above all of them. The recursive structure ensures we always find the lowest (deepest) common ancestor by processing the tree bottom-up and identifying the exact convergence point.
Learn more about Tree, Depth-First Search and Binary Tree patterns.
Solution Approach
The solution implements a DFS traversal with a hash table for efficient lookups. Let's walk through the implementation step by step:
Step 1: Create a Hash Set for Target Nodes
s = {node.val for node in nodes}
We first create a hash set s
containing all the values of nodes in the nodes
array. This allows us to check in O(1)
time whether any node we encounter during traversal is one of our target nodes.
Step 2: Define the DFS Function
def dfs(root):
if root is None or root.val in s:
return root
The DFS function has two base cases:
- If we reach a
None
node (leaf's child), returnNone
- If the current node's value is in our hash set
s
, return this node immediately
This second condition is crucial - when we find a target node, we don't need to search its subtrees further because any target nodes below it would still have this node as their ancestor.
Step 3: Recursive Traversal
left, right = dfs(root.left), dfs(root.right)
We recursively traverse both the left and right subtrees. Each recursive call will return:
None
if no target nodes were found in that subtree- A node reference if target node(s) were found in that subtree
Step 4: Determine the LCA
if left and right: return root return left or right
After checking both subtrees, we have three scenarios:
- Both
left
andright
are non-null: This means we found target nodes in both subtrees, making the current node the LCA - Only one is non-null: Pass up whichever subtree contains target nodes
- Both are null: Return
None
(implicitly handled byleft or right
)
Time Complexity: O(n)
where n
is the number of nodes in the tree, as we visit each node at most once.
Space Complexity: O(h + m)
where h
is the height of the tree (for recursion stack) and m
is the number of target nodes (for the hash set).
The algorithm works by propagating information upward from the leaves. When multiple paths containing target nodes converge at a node, that node becomes our answer - the lowest common ancestor of all target nodes.
Ready to land your dream job?
Unlock your dream job with a 5-minute evaluator for a personalized learning plan!
Start EvaluatorExample Walkthrough
Let's walk through a concrete example to understand how the solution works.
Consider this binary tree with target nodes [4, 7]:
3 / \ 5 1 / \ \ 6 2 8 / \ 7 4
Step 1: Create Hash Set
s = {4, 7}
(values of our target nodes)
Step 2: DFS Traversal Starting from Root (3)
Let's trace through the recursive calls:
-
dfs(3):
- 3 is not in
s
, so continue - Call
dfs(5)
for left subtree - Call
dfs(1)
for right subtree
- 3 is not in
-
dfs(5) (left subtree of 3):
- 5 is not in
s
, so continue - Call
dfs(6)
for left subtree - Call
dfs(2)
for right subtree
- 5 is not in
-
dfs(6) (left of 5):
- 6 is not in
s
- Both children are
None
- Returns
None
- 6 is not in
-
dfs(2) (right of 5):
- 2 is not in
s
- Call
dfs(7)
for left subtree - Call
dfs(4)
for right subtree
- 2 is not in
-
dfs(7) (left of 2):
- 7 is in
s
! - Returns node 7 immediately
- 7 is in
-
dfs(4) (right of 2):
- 4 is in
s
! - Returns node 4 immediately
- 4 is in
-
Back to dfs(2):
left = node 7
(non-null)right = node 4
(non-null)- Both subtrees have target nodes!
- Returns node 2 (the LCA)
-
Back to dfs(5):
left = None
(from node 6)right = node 2
(contains our LCA)- Returns node 2
-
dfs(1) (right subtree of 3):
- 1 is not in
s
- Left child is
None
- Right child dfs(8) returns
None
- Returns
None
- 1 is not in
-
Back to dfs(3):
left = node 2
(the LCA from left subtree)right = None
(no targets in right subtree)- Returns node 2
Result: Node 2 is correctly identified as the LCA of nodes 4 and 7.
The algorithm efficiently found that node 2 is the deepest node where the paths to both target nodes converge. The key moment was at step 7, where node 2 received non-null returns from both subtrees, indicating it's the divergence point for paths to our target nodes.
Solution Implementation
1# Definition for a binary tree node.
2# class TreeNode:
3# def __init__(self, x):
4# self.val = x
5# self.left = None
6# self.right = None
7
8from typing import List, Optional
9
10class Solution:
11 def lowestCommonAncestor(
12 self, root: 'TreeNode', nodes: 'List[TreeNode]'
13 ) -> 'TreeNode':
14 """
15 Find the lowest common ancestor of all given nodes in a binary tree.
16
17 Args:
18 root: The root of the binary tree
19 nodes: List of nodes whose LCA needs to be found
20
21 Returns:
22 The lowest common ancestor node
23 """
24
25 def dfs(current_node: Optional['TreeNode']) -> Optional['TreeNode']:
26 """
27 Perform depth-first search to find the LCA.
28
29 Args:
30 current_node: Current node being processed
31
32 Returns:
33 The LCA if found in this subtree, otherwise the target node if found
34 """
35 # Base case: reached null node or found a target node
36 if current_node is None or current_node.val in target_values:
37 return current_node
38
39 # Recursively search left and right subtrees
40 left_result = dfs(current_node.left)
41 right_result = dfs(current_node.right)
42
43 # If both subtrees contain target nodes, current node is the LCA
44 if left_result and right_result:
45 return current_node
46
47 # Return whichever subtree contains a target node (or None if neither)
48 return left_result or right_result
49
50 # Create a set of target node values for O(1) lookup
51 target_values = {node.val for node in nodes}
52
53 # Start DFS from root
54 return dfs(root)
55
1/**
2 * Definition for a binary tree node.
3 * public class TreeNode {
4 * int val;
5 * TreeNode left;
6 * TreeNode right;
7 * TreeNode(int x) { val = x; }
8 * }
9 */
10class Solution {
11 // Set to store values of target nodes for O(1) lookup
12 private Set<Integer> targetNodeValues = new HashSet<>();
13
14 /**
15 * Finds the lowest common ancestor of all nodes in the given array.
16 *
17 * @param root The root of the binary tree
18 * @param nodes Array of TreeNode objects whose LCA needs to be found
19 * @return The lowest common ancestor node of all given nodes
20 */
21 public TreeNode lowestCommonAncestor(TreeNode root, TreeNode[] nodes) {
22 // Populate set with values of all target nodes
23 for (TreeNode node : nodes) {
24 targetNodeValues.add(node.val);
25 }
26
27 // Perform DFS to find the LCA
28 return findLCA(root);
29 }
30
31 /**
32 * Recursive helper method to find the lowest common ancestor.
33 * Uses post-order traversal to bubble up information from leaves to root.
34 *
35 * @param currentNode The current node being processed
36 * @return The LCA if found in this subtree, or a target node, or null
37 */
38 private TreeNode findLCA(TreeNode currentNode) {
39 // Base case: reached null or found one of the target nodes
40 if (currentNode == null || targetNodeValues.contains(currentNode.val)) {
41 return currentNode;
42 }
43
44 // Recursively search in left subtree
45 TreeNode leftResult = findLCA(currentNode.left);
46
47 // Recursively search in right subtree
48 TreeNode rightResult = findLCA(currentNode.right);
49
50 // If left subtree returns null, all targets (if any) are in right subtree
51 if (leftResult == null) {
52 return rightResult;
53 }
54
55 // If right subtree returns null, all targets (if any) are in left subtree
56 if (rightResult == null) {
57 return leftResult;
58 }
59
60 // If both subtrees return non-null, current node is the LCA
61 // (targets are distributed across both subtrees)
62 return currentNode;
63 }
64}
65
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 TreeNode* lowestCommonAncestor(TreeNode* root, vector<TreeNode*>& nodes) {
15 // Store all target node values in a set for O(1) lookup
16 unordered_set<int> targetNodeValues;
17 for (auto node : nodes) {
18 targetNodeValues.insert(node->val);
19 }
20
21 // Define recursive DFS function to find LCA
22 auto findLCA = [&](this auto&& findLCA, TreeNode* currentNode) -> TreeNode* {
23 // Base case: reached null or found a target node
24 if (!currentNode || targetNodeValues.contains(currentNode->val)) {
25 return currentNode;
26 }
27
28 // Recursively search in left and right subtrees
29 TreeNode* leftResult = findLCA(currentNode->left);
30 TreeNode* rightResult = findLCA(currentNode->right);
31
32 // If no target nodes found in left subtree, return right result
33 if (!leftResult) {
34 return rightResult;
35 }
36
37 // If no target nodes found in right subtree, return left result
38 if (!rightResult) {
39 return leftResult;
40 }
41
42 // If target nodes found in both subtrees, current node is the LCA
43 return currentNode;
44 };
45
46 // Start the search from root
47 return findLCA(root);
48 }
49};
50
1/**
2 * Definition for a binary tree node.
3 */
4interface TreeNode {
5 val: number;
6 left: TreeNode | null;
7 right: TreeNode | null;
8}
9
10/**
11 * Finds the lowest common ancestor of multiple nodes in a binary tree.
12 * @param root - The root of the binary tree
13 * @param nodes - Array of tree nodes to find the LCA for
14 * @returns The lowest common ancestor node or null
15 */
16const lowestCommonAncestor = function (root: TreeNode | null, nodes: TreeNode[]): TreeNode | null {
17 // Create a set to store the values of target nodes for O(1) lookup
18 const targetNodeValues = new Set<number>();
19 for (const node of nodes) {
20 targetNodeValues.add(node.val);
21 }
22
23 /**
24 * Performs depth-first search to find the lowest common ancestor.
25 * @param currentNode - The current node being processed
26 * @returns The LCA if found in this subtree, or a target node, or null
27 */
28 function dfs(currentNode: TreeNode | null): TreeNode | null {
29 // Base case: if node is null or is one of the target nodes
30 if (!currentNode || targetNodeValues.has(currentNode.val)) {
31 return currentNode;
32 }
33
34 // Recursively search left and right subtrees
35 const leftResult: TreeNode | null = dfs(currentNode.left);
36 const rightResult: TreeNode | null = dfs(currentNode.right);
37
38 // If both subtrees contain target nodes, current node is the LCA
39 if (leftResult && rightResult) {
40 return currentNode;
41 }
42
43 // Return whichever subtree contains a target node (or null if neither)
44 return leftResult || rightResult;
45 }
46
47 return dfs(root);
48};
49
Time and Space Complexity
Time Complexity: O(n + m)
The algorithm consists of two main parts:
- Creating a set
s
from the nodes list, which takesO(m)
time wherem
is the number of nodes in the input list - Performing a DFS traversal of the binary tree, which visits each node at most once, taking
O(n)
time wheren
is the total number of nodes in the tree
During the DFS traversal:
- Each node is visited exactly once
- At each node, we perform a constant-time operation to check if
root.val in s
(hash set lookup isO(1)
on average) - The recursive calls and comparisons are all constant-time operations
Therefore, the total time complexity is O(n + m)
.
Space Complexity: O(n + m)
The space complexity comes from:
- The set
s
storing the values of all nodes in the input list, which requiresO(m)
space - The recursive call stack for DFS, which in the worst case (skewed tree) can go up to
O(n)
deep, wheren
is the number of nodes in the tree - No additional data structures are used during the traversal
Therefore, the total space complexity is O(n + m)
.
Learn more about how to find time and space complexity quickly.
Common Pitfalls
Pitfall 1: Early Termination Prevents Finding the True LCA
The Problem:
The most critical pitfall in this solution is the early termination when encountering a target node. The line if current_node is None or current_node.val in target_values: return current_node
stops the traversal immediately upon finding any target node. This creates a blind spot - if one target node is an ancestor of another target node, the algorithm incorrectly returns the higher (ancestor) node instead of continuing to search for the true LCA.
Example Scenario: Consider a tree where node A is at depth 1 and node B is at depth 3, with B being a descendant of A. If both A and B are in the target nodes list:
- The DFS reaches node A first
- It immediately returns A without checking A's subtrees
- It never discovers that B exists below A
- The algorithm returns A as the LCA, which is correct in this specific case but for the wrong reason
Why This Actually Works (But Shouldn't): Interestingly, this "bug" produces the correct result because when one target node is an ancestor of another, that ancestor node IS the LCA. However, the algorithm arrives at this answer through flawed logic - it's right by accident rather than by design.
Pitfall 2: Assumption About Node Relationships
The Problem: The code assumes that no target node is an ancestor of another without explicitly verifying this relationship. While the early termination happens to handle this case correctly, it makes the code's logic unclear and potentially fragile if requirements change.
Solution: More Explicit Handling
To make the algorithm more robust and its logic clearer, consider this alternative approach:
def lowestCommonAncestor(self, root: 'TreeNode', nodes: 'List[TreeNode]') -> 'TreeNode':
target_values = {node.val for node in nodes}
def dfs(current_node):
if current_node is None:
return None
# Count how many target nodes are in current subtree
matches = 0
# Check current node
if current_node.val in target_values:
matches = 1
# Always explore both subtrees
left_result = dfs(current_node.left)
right_result = dfs(current_node.right)
# If current node is a target and either subtree has targets,
# or both subtrees have targets, this is the LCA
if (matches and (left_result or right_result)) or (left_result and right_result):
return current_node
# Return current node if it's a target, or propagate result from subtrees
if matches:
return current_node
return left_result or right_result
return dfs(root)
This solution explicitly handles all cases and makes the logic transparent, though the original solution's "bug" happens to produce correct results due to the mathematical properties of the LCA problem.
Consider the classic dynamic programming of longest increasing subsequence:
Find the length of the longest subsequence of a given sequence such that all elements of the subsequence are sorted in increasing order.
For example, the length of LIS for [50, 3, 10, 7, 40, 80]
is 4
and LIS is
[3, 7, 40, 80]
.
What is the recurrence relation?
Recommended Readings
Everything About Trees A tree is a type of graph data structure composed of nodes and edges Its main properties are It is acyclic doesn't contain any cycles There exists a path from the root to any node Has N 1 edges where N is the number of nodes in the tree and
https assets algo monster cover_photos dfs svg Depth First Search Prereqs Recursion Review problems recursion_intro Trees problems tree_intro With a solid understanding of recursion under our belts we are now ready to tackle one of the most useful techniques in coding interviews Depth First Search DFS As the name suggests
Binary Tree Min Depth Prereq BFS on Tree problems bfs_intro Given a binary tree find the depth of the shallowest leaf node https assets algo monster binary_tree_min_depth png Explanation We can solve this problem with either DFS or BFS With DFS we traverse the whole tree looking for leaf nodes and record and update the minimum depth as we go With BFS though since we search level by level we are guaranteed to find the shallowest leaf node
Want a Structured Path to Master System Design Too? Don’t Miss This!