Facebook Pixel

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 node y that lies on the path from x 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:

  1. We need to traverse the entire tree structure to locate all target nodes
  2. We need to identify the deepest node that is an ancestor to all target nodes
  3. DFS allows us to explore each subtree completely and determine if it contains any of our target nodes
  4. 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.

Quick Interview Experience
Help others by sharing your interview experience
Have you seen this problem before?

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:

  1. When we hit a target node, we can immediately return it as a potential ancestor
  2. For any non-target node, we check both its left and right subtrees
  3. If both subtrees contain target nodes (return non-null), then the current node must be where paths diverge - making it the LCA
  4. 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), return None
  • 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:

  1. Both left and right are non-null: This means we found target nodes in both subtrees, making the current node the LCA
  2. Only one is non-null: Pass up whichever subtree contains target nodes
  3. Both are null: Return None (implicitly handled by left 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 Evaluator

Example 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:

  1. dfs(3):

    • 3 is not in s, so continue
    • Call dfs(5) for left subtree
    • Call dfs(1) for right subtree
  2. 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
  3. dfs(6) (left of 5):

    • 6 is not in s
    • Both children are None
    • Returns None
  4. dfs(2) (right of 5):

    • 2 is not in s
    • Call dfs(7) for left subtree
    • Call dfs(4) for right subtree
  5. dfs(7) (left of 2):

    • 7 is in s!
    • Returns node 7 immediately
  6. dfs(4) (right of 2):

    • 4 is in s!
    • Returns node 4 immediately
  7. Back to dfs(2):

    • left = node 7 (non-null)
    • right = node 4 (non-null)
    • Both subtrees have target nodes!
    • Returns node 2 (the LCA)
  8. Back to dfs(5):

    • left = None (from node 6)
    • right = node 2 (contains our LCA)
    • Returns node 2
  9. dfs(1) (right subtree of 3):

    • 1 is not in s
    • Left child is None
    • Right child dfs(8) returns None
    • Returns None
  10. 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 takes O(m) time where m 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 where n 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 is O(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 requires O(m) space
  • The recursive call stack for DFS, which in the worst case (skewed tree) can go up to O(n) deep, where n 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.

Discover Your Strengths and Weaknesses: Take Our 5-Minute Quiz to Tailor Your Study Plan:

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

Want a Structured Path to Master System Design Too? Don’t Miss This!

Load More