Facebook Pixel

1644. Lowest Common Ancestor of a Binary Tree II 🔒

Problem Description

This problem asks you to find the Lowest Common Ancestor (LCA) of two nodes p and q in a binary tree, with an important twist: either p or q might not exist in the tree.

Key requirements:

  • Given a binary tree with root node and two target nodes p and q
  • Find the lowest node that has both p and q as descendants
  • If either p or q doesn't exist in the tree, return null
  • All node values in the tree are unique
  • A node can be a descendant of itself

Understanding LCA: The lowest common ancestor is the deepest node in the tree that has both target nodes in its subtree. For example, if we're looking for the LCA of nodes with values 5 and 1 in this tree:

      3
     / \
    5   1
   / \
  6   2

The LCA would be node 3, as it's the lowest node that contains both 5 and 1 as descendants.

The twist in this problem: Unlike the standard LCA problem where both nodes are guaranteed to exist, here we must verify that both p and q actually exist in the tree before returning an ancestor. If either node is missing, we should return null.

Solution approach: The solution uses a depth-first search (DFS) that:

  1. Traverses the entire tree to check if nodes exist
  2. Returns True if a subtree contains either p or q
  3. When a node has both target nodes in its left and right subtrees (or one in a subtree and is itself one of the targets), it becomes the LCA
  4. Uses a nonlocal variable ans to store the LCA once found
  5. Returns the LCA if both nodes exist, otherwise returns null

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 with a root node and parent-child relationships.

DFS

  • We arrive at DFS: For tree problems, DFS is often the natural choice, especially when we need to explore paths from root to leaves or find relationships between nodes at different depths.

Conclusion: The flowchart suggests using a Depth-First Search (DFS) approach for finding the Lowest Common Ancestor.

Why DFS is Perfect for This Problem

The DFS pattern fits naturally because:

  1. Tree Traversal: We need to traverse the entire tree to verify both nodes exist
  2. Bottom-up Information: DFS allows us to collect information from children before processing the parent (post-order traversal)
  3. Path Tracking: We need to track which subtrees contain our target nodes
  4. Ancestor Relationship: The LCA is found when a node has both targets in its subtrees (left/right) or is itself one of the targets while having the other in a subtree

The solution uses DFS to:

  • Recursively search left and right subtrees
  • Return boolean values indicating whether p or q was found
  • Identify the LCA when both targets are found in different subtrees of a node
  • Handle the edge case where one or both nodes don't exist by returning null
Quick Interview Experience
Help others by sharing your interview experience
Have you seen this problem before?

Intuition

The key insight is that we need to solve two problems simultaneously: verify both nodes exist in the tree AND find their lowest common ancestor if they do exist.

Think about how we'd manually find the LCA of two nodes. We'd start from the root and ask: "Are both nodes in my left subtree? Are both in my right subtree? Or are they split between my subtrees?" The node where they "split" (one in left, one in right, or I'm one of them with the other below me) is the LCA.

But here's the catch - we can't assume both nodes exist. So we need to traverse the entire tree to confirm their existence while simultaneously tracking potential ancestors.

The natural approach is to use DFS to bubble up information from the bottom. Each recursive call answers: "Did I find p or q in this subtree?" By returning True or False from each subtree, we can identify the exact moment when:

  • Both left and right subtrees contain one target each (current node is LCA)
  • One subtree contains a target and the current node is the other target (current node is LCA)

Why can't we stop early when we find a potential LCA? Because we must verify both nodes exist. If we stopped at the first node that looks like an LCA but q doesn't exist in the tree, we'd incorrectly return that node instead of null.

The trick is using a nonlocal variable ans to store our potential LCA. We only update ans when we've confirmed we have both nodes in the current node's domain. After traversing the entire tree, if both nodes existed, ans will hold the LCA; otherwise, it remains null.

This approach elegantly handles all cases:

  • Both nodes exist → return their LCA
  • One or both nodes missing → return null
  • One node is an ancestor of the other → return that ancestor node

Learn more about Tree, Depth-First Search and Binary Tree patterns.

Solution Approach

The implementation uses a recursive DFS helper function that traverses the tree and collects information from the bottom up.

Core Algorithm Structure:

  1. Base Case: If we reach a null node, return False (neither target found in this path)

  2. Recursive Calls:

    • l = dfs(root.left, p, q) - Check if either target exists in left subtree
    • r = dfs(root.right, p, q) - Check if either target exists in right subtree
  3. LCA Detection Logic:

    • Case 1: If both l and r are True, the current node is the LCA (targets are in different subtrees)
    if l and r:
        ans = root
    • Case 2: If one subtree contains a target AND current node is the other target, current node is the LCA
    if (l or r) and (root.val == p.val or root.val == q.val):
        ans = root
  4. Return Value: The function returns True if the current subtree contains at least one target:

    return l or r or root.val == p.val or root.val == q.val

    This ensures parent nodes know if targets exist below them.

Key Implementation Details:

  • Nonlocal Variable: ans is declared as nonlocal to persist the LCA across recursive calls. It starts as None and only gets updated when we confirm both nodes are present.

  • Full Tree Traversal: Unlike standard LCA where we can return early, we must traverse the entire tree to verify both nodes exist. Even after finding a potential LCA, we continue to ensure both nodes are actually in the tree.

  • Value Comparison: Since all node values are unique, we compare root.val with p.val and q.val rather than comparing node references.

Time Complexity: O(n) where n is the number of nodes, as we visit each node exactly once.

Space Complexity: O(h) where h is the height of the tree, due to the recursive call stack.

The beauty of this solution is that it handles both requirements (existence check and LCA finding) in a single pass through the tree, making it efficient despite the additional constraint.

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 finding the LCA of nodes with values 5 and 4 in this tree:

       3
      / \
     5   1
    / \   \
   6   2   8
      / \
     7   4

Step-by-step DFS traversal:

  1. Start at root (3): Call dfs(3, 5, 4)

    • Recursively explore left subtree first
  2. Visit node 5: Call dfs(5, 5, 4)

    • Explore left: dfs(6, 5, 4) → returns False (6 ≠ 5 and 6 ≠ 4, no children)
    • Explore right: dfs(2, 5, 4)
      • Left: dfs(7, 5, 4) → returns False
      • Right: dfs(4, 5, 4) → returns True (4 = 4)
      • Node 2: l=False, r=True, returns True (found 4 in right subtree)
    • Node 5: l=False, r=True, and root.val=5 (matches p)
      • Since r=True AND node 5 matches p, node 5 is the LCA
      • Set ans = node 5
      • Returns True (both conditions met)
  3. Back at root (3): Left subtree returned True

    • Continue with right subtree: dfs(1, 5, 4)
    • Node 1 and its subtree don't contain 5 or 4, returns False
  4. Final result: ans = node 5 is returned as the LCA

Why node 5 is the LCA:

  • Node 5 itself is one of our targets (p=5)
  • Node 4 exists in node 5's right subtree
  • This makes node 5 the lowest node containing both targets

What if node 4 didn't exist? If we replaced node 4 with any other value (say 9), the algorithm would:

  • Still traverse the entire tree
  • Never find both targets in any node's domain
  • Keep ans = None throughout
  • Return None since not both nodes exist

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
8
9class Solution:
10    def lowestCommonAncestor(
11        self, root: 'TreeNode', p: 'TreeNode', q: 'TreeNode'
12    ) -> 'TreeNode':
13        """
14        Find the lowest common ancestor of nodes p and q in a binary tree.
15      
16        Args:
17            root: The root of the binary tree
18            p: First target node
19            q: Second target node
20          
21        Returns:
22            The lowest common ancestor node of p and q
23        """
24      
25        # Variable to store the LCA result
26        self.lca_node = None
27      
28        def find_nodes(current_node: 'TreeNode', target_p: 'TreeNode', target_q: 'TreeNode') -> bool:
29            """
30            Recursively search for target nodes and identify the LCA.
31          
32            Returns:
33                True if current subtree contains at least one target node
34            """
35            # Base case: reached a null node
36            if current_node is None:
37                return False
38          
39            # Recursively check left subtree for target nodes
40            left_contains_target = find_nodes(current_node.left, target_p, target_q)
41          
42            # Recursively check right subtree for target nodes
43            right_contains_target = find_nodes(current_node.right, target_p, target_q)
44          
45            # Check if current node is one of the target nodes
46            is_current_target = (current_node.val == target_p.val or 
47                                current_node.val == target_q.val)
48          
49            # LCA Case 1: Both left and right subtrees contain target nodes
50            if left_contains_target and right_contains_target:
51                self.lca_node = current_node
52          
53            # LCA Case 2: One subtree contains a target and current node is the other target
54            if (left_contains_target or right_contains_target) and is_current_target:
55                self.lca_node = current_node
56          
57            # Return True if current subtree contains any target node
58            return left_contains_target or right_contains_target or is_current_target
59      
60        # Start the DFS traversal from root
61        find_nodes(root, p, q)
62      
63        return self.lca_node
64
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    private TreeNode lowestCommonAncestor;
12  
13    /**
14     * Finds the lowest common ancestor of two nodes in a binary tree.
15     * @param root The root of the binary tree
16     * @param p First target node
17     * @param q Second target node
18     * @return The lowest common ancestor of nodes p and q
19     */
20    public TreeNode lowestCommonAncestor(TreeNode root, TreeNode p, TreeNode q) {
21        findLCA(root, p, q);
22        return lowestCommonAncestor;
23    }
24  
25    /**
26     * Performs depth-first search to find if the current subtree contains p or q.
27     * Updates the LCA when found.
28     * @param currentNode The current node being processed
29     * @param p First target node
30     * @param q Second target node
31     * @return true if the current subtree contains at least one of p or q
32     */
33    private boolean findLCA(TreeNode currentNode, TreeNode p, TreeNode q) {
34        // Base case: reached null node
35        if (currentNode == null) {
36            return false;
37        }
38      
39        // Recursively search left subtree
40        boolean leftContainsTarget = findLCA(currentNode.left, p, q);
41      
42        // Recursively search right subtree
43        boolean rightContainsTarget = findLCA(currentNode.right, p, q);
44      
45        // Case 1: Both left and right subtrees contain one target each
46        // Current node is the LCA
47        if (leftContainsTarget && rightContainsTarget) {
48            lowestCommonAncestor = currentNode;
49        }
50      
51        // Case 2: One subtree contains a target and current node is the other target
52        // Current node is the LCA
53        if ((leftContainsTarget || rightContainsTarget) && 
54            (currentNode.val == p.val || currentNode.val == q.val)) {
55            lowestCommonAncestor = currentNode;
56        }
57      
58        // Return true if current subtree contains at least one target node
59        return leftContainsTarget || rightContainsTarget || 
60               currentNode.val == p.val || currentNode.val == q.val;
61    }
62}
63
1/**
2 * Definition for a binary tree node.
3 * struct TreeNode {
4 *     int val;
5 *     TreeNode *left;
6 *     TreeNode *right;
7 *     TreeNode(int x) : val(x), left(NULL), right(NULL) {}
8 * };
9 */
10class Solution {
11public:
12    /**
13     * Finds the lowest common ancestor of two nodes in a binary tree
14     * @param root The root of the binary tree
15     * @param p First target node
16     * @param q Second target node
17     * @return The lowest common ancestor of nodes p and q
18     */
19    TreeNode* lowestCommonAncestor(TreeNode* root, TreeNode* p, TreeNode* q) {
20        findLCA(root, p, q);
21        return lca_node;
22    }
23
24private:
25    TreeNode* lca_node = nullptr;  // Stores the lowest common ancestor
26  
27    /**
28     * Performs DFS to find if current subtree contains p or q
29     * @param current Current node being processed
30     * @param p First target node
31     * @param q Second target node
32     * @return true if current subtree contains at least one of p or q
33     */
34    bool findLCA(TreeNode* current, TreeNode* p, TreeNode* q) {
35        // Base case: reached null node
36        if (!current) {
37            return false;
38        }
39      
40        // Recursively search left and right subtrees
41        bool found_in_left = findLCA(current->left, p, q);
42        bool found_in_right = findLCA(current->right, p, q);
43      
44        // Case 1: Both p and q found in different subtrees
45        // Current node is the LCA
46        if (found_in_left && found_in_right) {
47            lca_node = current;
48        }
49      
50        // Case 2: One target found in subtree and current node is the other target
51        // Current node is the LCA
52        if ((found_in_left || found_in_right) && 
53            (current->val == p->val || current->val == q->val)) {
54            lca_node = current;
55        }
56      
57        // Return true if current subtree contains at least one target node
58        return found_in_left || found_in_right || 
59               current->val == p->val || current->val == q->val;
60    }
61};
62
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 two nodes in a binary tree.
12 * @param root - The root of the binary tree
13 * @param p - The first target node
14 * @param q - The second target node
15 * @returns The lowest common ancestor node of p and q
16 */
17const lowestCommonAncestor = function(root: TreeNode | null, p: TreeNode, q: TreeNode): TreeNode | null {
18    // Variable to store the result (lowest common ancestor)
19    let answer: TreeNode | null = null;
20  
21    /**
22     * Depth-first search to find the lowest common ancestor.
23     * @param node - Current node being processed
24     * @returns True if the current subtree contains p or q, false otherwise
25     */
26    const dfs = (node: TreeNode | null): boolean => {
27        // Base case: if node is null, return false
28        if (!node) {
29            return false;
30        }
31      
32        // Recursively search in the left subtree
33        const leftContainsTarget: boolean = dfs(node.left);
34      
35        // Recursively search in the right subtree
36        const rightContainsTarget: boolean = dfs(node.right);
37      
38        // If both left and right subtrees contain targets, current node is LCA
39        if (leftContainsTarget && rightContainsTarget) {
40            answer = node;
41        }
42      
43        // If one subtree contains a target and current node is the other target, current node is LCA
44        if ((leftContainsTarget || rightContainsTarget) && (node.val === p.val || node.val === q.val)) {
45            answer = node;
46        }
47      
48        // Return true if current subtree contains at least one target node
49        return leftContainsTarget || rightContainsTarget || node.val === p.val || node.val === q.val;
50    };
51  
52    // Start DFS from root
53    dfs(root);
54  
55    return answer;
56};
57

Time and Space Complexity

Time Complexity: O(n) where n is the number of nodes in the binary tree. The DFS traversal visits each node exactly once. At each node, we perform constant time operations (checking conditions, comparing values, and updating the answer variable), so the overall time complexity is linear with respect to the number of nodes.

Space Complexity: O(h) where h is the height of the binary tree. The space complexity is determined by the recursive call stack. In the worst case (a skewed tree), the height could be O(n), making the space complexity O(n). In the best case (a balanced tree), the height would be O(log n), making the space complexity O(log n). The additional space used for the ans variable is O(1).

Learn more about how to find time and space complexity quickly.

Common Pitfalls

1. Early Termination When Finding First Target

The Pitfall: A common mistake is trying to optimize by returning early when finding the first target node, similar to the standard LCA problem. For example:

def find_nodes(current_node, target_p, target_q):
    if current_node is None:
        return False
  
    # WRONG: Early return when finding a target
    if current_node.val == target_p.val or current_node.val == target_q.val:
        return True  # This prevents checking if the other node exists!
  
    left = find_nodes(current_node.left, target_p, target_q)
    right = find_nodes(current_node.right, target_p, target_q)
    # ...

Why This Fails: If p is an ancestor of q (or vice versa), returning early at p prevents us from verifying that q actually exists in the tree. For example, if we're looking for nodes 3 and 7 in a tree where 7 doesn't exist, but 3 is the root, we'd incorrectly return 3 as the LCA.

The Solution: Always complete the full traversal of both subtrees before checking the current node:

def find_nodes(current_node, target_p, target_q):
    if current_node is None:
        return False
  
    # CORRECT: Check subtrees first
    left_contains_target = find_nodes(current_node.left, target_p, target_q)
    right_contains_target = find_nodes(current_node.right, target_p, target_q)
  
    # Then check current node
    is_current_target = (current_node.val == target_p.val or 
                        current_node.val == target_q.val)
  
    # Process LCA logic...
    return left_contains_target or right_contains_target or is_current_target

2. Not Verifying Both Nodes Exist

The Pitfall: Setting the LCA without confirming both nodes are actually present in the tree:

def lowestCommonAncestor(self, root, p, q):
    self.lca_node = None
  
    def find_nodes(current_node, target_p, target_q):
        # ... traversal logic ...
      
        # WRONG: Setting LCA without verification
        if left_contains_target and right_contains_target:
            self.lca_node = current_node
            return True  # But what if only one node actually exists?
  
    find_nodes(root, p, q)
    return self.lca_node  # Might return incorrect LCA

Why This Fails: Consider searching for nodes 5 and 99 where 99 doesn't exist. If node 5 has children, the algorithm might incorrectly identify 5 as the LCA when it finds 5 in one subtree and nothing in the other.

The Solution: The correct approach implicitly verifies both nodes exist by only setting the LCA when we have concrete evidence of both nodes:

# CORRECT: LCA is only set when we have proof of both nodes
if left_contains_target and right_contains_target:
    # Both nodes must exist, one in each subtree
    self.lca_node = current_node

if (left_contains_target or right_contains_target) and is_current_target:
    # Current node is one target, and the other exists in a subtree
    self.lca_node = current_node

3. Using Node References Instead of Values

The Pitfall: Comparing node objects directly instead of their values:

# WRONG: Direct node comparison
is_current_target = (current_node == target_p or current_node == target_q)

Why This Fails: The nodes p and q passed to the function might be different object instances than the actual nodes in the tree, even if they have the same values. Direct reference comparison would fail to identify matching nodes.

The Solution: Always compare node values since they're guaranteed to be unique:

# CORRECT: Compare values
is_current_target = (current_node.val == target_p.val or 
                    current_node.val == target_q.val)
Discover Your Strengths and Weaknesses: Take Our 5-Minute Quiz to Tailor Your Study Plan:

What data structure does Breadth-first search typically uses to store intermediate states?


Recommended Readings

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

Load More