Skip to main content

Lowest Common Ancestor in Binary Trees and Graphs

Comprehensive Java solution for finding the lowest common ancestor in binary trees, including iterative and recursive approaches with time complexity analysis.

The lowest common ancestor (LCA) problem involves finding the deepest node that is an ancestor of two given nodes in a tree structure. This fundamental problem appears frequently in FAANG interviews and requires understanding of tree traversal, parent tracking, and algorithmic optimization strategies.

1. Pattern Restatement

Clarifying questions to ask the interviewer:

  1. Are we guaranteed that both nodes p and q exist in the tree, or should we handle cases where one or both might be missing?
  2. Can we modify the input tree structure, or must we work with an immutable tree?
  3. What is the expected return type - should we return the TreeNode itself or just its value?
  4. Are we dealing with a binary tree or a general tree structure?
  5. What are the constraints on the number of nodes in the tree - will it be small enough to fit in memory?

Restatement of the pattern: The Lowest Common Ancestor (LCA) problem asks us to find the deepest node that is an ancestor of two given nodes in a tree structure. In a binary tree context, this means identifying the lowest node that has both target nodes as descendants (including themselves).

Signal words and constraint shape: Key indicators include "lowest common ancestor", "common parent", "deepest shared node", or "ancestors". The constraint shape typically involves a tree structure where we're looking for relationships between nodes, with the requirement that the solution must be efficient and handle various tree configurations (balanced vs unbalanced).

Assumptions made:

  • The input is a valid binary tree (or general tree) with unique node values
  • Both target nodes p and q exist in the tree (or we're to handle missing cases gracefully)
  • We can traverse from root to nodes, and the tree structure supports parent traversal or node references
  • The solution should handle all possible tree configurations (complete, skewed, etc.)

Scenario suitability: This pattern is best suited for static tree scenarios where the structure doesn't change during query processing. For streaming or online scenarios where trees are constantly modified, we'd need more sophisticated data structures like Euler tours with segment trees or heavy-light decomposition.

2. When to Use This Pattern

Signal words and constraint shapes in a problem statement that indicate this pattern:

  • "Lowest Common Ancestor" or "LCA" explicitly mentioned
  • References to tree structures with parent-child relationships
  • Queries asking for the deepest node that is an ancestor of two or more given nodes
  • "Find the lowest common ancestor of two nodes in a binary tree"
  • Tree traversal or path finding requirements

Why this pattern fits better than the most common alternatives: The LCA problem is fundamentally about finding shared ancestry in hierarchical structures. Unlike graph traversal problems that might use BFS/DFS, or array-based problems that use two pointers, LCA specifically requires understanding of tree structure and ancestor relationships. The key insight is that any general graph traversal approach would be inefficient, while LCA-specific optimizations like binary lifting or Euler tour techniques can achieve O(log n) query time after O(n) preprocessing.

Conditions where a variation or related pattern applies instead:

  • For binary trees, we can use parent pointers or path tracking, which is more efficient than general graph algorithms
  • For multiple queries, we might prefer Euler tour + RMQ or binary lifting for O(log n) query time
  • For general trees, we might consider the "Kth ancestor" problem or heavy-light decomposition for more complex queries
  • For offline queries, we might use Tarjan's algorithm or Euler tour techniques

The core insight that makes this pattern work: The key insight is that in any tree structure, there exists a unique lowest common ancestor for any pair of nodes. This is because trees form a directed acyclic graph where each node has exactly one parent (except root), and any two nodes must share a common ancestor in their paths to the root. The LCA is where these paths converge for the first time when traveling upward from both nodes.

The pattern works because it leverages the hierarchical nature of trees to efficiently compute shared ancestry without having to traverse entire tree structures repeatedly. The mathematical property that any two paths from leaf nodes will intersect at the LCA makes this problem tractable with specific algorithms that maintain ancestry relationships.

3. Approach Evolution

Interview communication script:

"My first instinct is to solve this using a brute force approach where I find the path from root to each of the two nodes, and then compare those paths to find the last common node. But that's O(n²) in worst case because for each node we might have to traverse the entire tree to find its path. Let me think about how to optimize this.

I notice that if I can preprocess the tree once, I could answer queries much faster. Actually, let me think about this more carefully - we're looking for the lowest common ancestor, which means I want to find the deepest node that is an ancestor of both target nodes.

I see a key insight here: if I can store parent pointers or precompute levels of each node, I could bring this down to O(log n) per query. But that requires preprocessing.

Wait, let me think about a cleaner approach - if I can find the path from root to each node, and then walk backwards from both paths to find where they first diverge, that would work. But I'm not actually storing the path, just the ancestors.

Actually, there's an even better approach: I can use a two-pointer technique similar to finding intersection of linked lists. If I had parent pointers, I could move the deeper node up until both are at same level, then move both up simultaneously until they meet.

But since we're not given parent pointers, let me think of the cleanest approach that works with just the tree structure. The most efficient approach is to do a single DFS traversal and use the fact that when we find one of our target nodes, we can return it up the call stack. If both left and right subtrees return non-null values, we've found our LCA.

Let me trace through this more carefully - if a node is the LCA, then one target must be in its left subtree and the other in its right subtree, or one of the targets is the node itself. This recursive approach should work and be O(n) time."

Brute-force approach:

  • Find path from root to each target node using DFS
  • Compare paths to find last common node
  • Time: O(n²) worst case, Space: O(n)
  • Not ideal because we're doing redundant traversals

Improved approach:

  • Use recursive DFS with early termination
  • When we find one target, return it up the call stack
  • If both left and right subtrees return non-null values, current node is LCA
  • Time: O(n), Space: O(h) where h is height

Final approach with step-by-step reasoning:

  1. Start DFS from root node
  2. For each node, recursively check left and right subtrees
  3. If current node matches either target, return it
  4. If both left and right recursive calls return non-null values, current node is LCA
  5. Otherwise return whichever subtree returned non-null value
  6. This works because we process nodes bottom-up, so first node that has both targets in different subtrees is the LCA

Important invariants:

  • At any node, if we have both targets in different subtrees, that node is the LCA
  • If we find one target, we return it up the call stack to indicate that subtree contains a target
  • The recursive structure ensures we process from leaves up to root, guaranteeing first valid LCA found is correct

4. Data Structure Choice

For the Lowest Common Ancestor (LCA) problem, I'll use a combination of data structures:

Primary Data Structures:

  1. HashMap for Parent Tracking: Map<TreeNode, TreeNode> to store each node's parent reference

    • Why needed: To efficiently traverse upward from any node to root
    • Time/Spatial impact: O(n) space, O(1) average lookup time
    • Essential for the two-pass approach where we build parent relationships first
  2. HashSet for Ancestor Tracking: Set<TreeNode> to store ancestors of one target node

    • Why needed: To quickly check if a node is an ancestor of the other target
    • Time/Spatial impact: O(h) space where h is height, O(1) average lookup time
    • Critical for the efficient LCA search once parent relationships are established
  3. TreeNode reference: Direct pointer to nodes in the tree

    • Why needed: To maintain references to the actual nodes being processed
    • Time/Spatial impact: O(1) space per node, O(n) total

Helper Structures:

  1. Queue for BFS traversal: Queue<TreeNode> to build parent relationships
    • Why needed: For level-order traversal to establish parent-child relationships
    • Time/Spatial impact: O(w) space where w is maximum width, O(n) time
    • Simplifies the parent mapping process

The combination of these structures allows us to build a clean, efficient solution that avoids expensive recursive traversals while maintaining optimal time complexity. The HashMap and HashSet together enable O(1) ancestor lookups, making the overall approach highly efficient for multiple queries on the same tree structure.

5. Algorithm Walkthrough

Let me trace through the algorithm with a concrete example to show how it works step by step.

Consider this binary tree:

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

We want to find LCA of nodes 5 and 1.

Step 1: Start at root (node 3)

  • Target nodes are 5 and 1
  • Current node 3 is not one of our targets, so we need to search both subtrees
  • Search left subtree (nodes 5 and 2) for node 5
  • Search right subtree (nodes 1, 0, 8) for node 1

Step 2: Recursively search left subtree (node 5)

  • Visit node 5
  • Node 5 matches one of our target nodes, so return node 5

Step 3: Recursively search right subtree (node 1)

  • Visit node 1
  • Node 1 matches one of our target nodes, so return node 1

Step 4: Back at root (node 3)

  • Left subtree returned node 5
  • Right subtree returned node 1
  • Both nodes are non-null, so root (node 3) is the LCA

Step 5: Return node 3 as the lowest common ancestor

The key insight is that we're looking for a node where:

  1. One target is in the left subtree and another in right subtree, OR
  2. The current node itself is one of the targets and the other target exists in either subtree

This approach handles all edge cases properly:

  • When one node is ancestor of another, we'll find the deeper node returning itself
  • When nodes don't exist in tree, we return null appropriately
  • When nodes are same, we find the node itself as LCA

The algorithm correctly terminates because:

  • Each node is visited at most once (O(n) time)
  • We return immediately when we find the LCA condition
  • The recursive structure ensures proper tree traversal

6. Java Solution

// Definition for a binary tree node.
class TreeNode {
    int val;
    TreeNode left;
    TreeNode right;
    TreeNode() {}
    TreeNode(int val) { this.val = val; }
    TreeNode(int val, TreeNode left, TreeNode right) {
        this.val = val;
        this.left = left;
        this.right = right;
    }
}

public class Solution {
    private TreeNode result;
    
    public TreeNode lowestCommonAncestor(TreeNode root, TreeNode p, TreeNode q) {
        result = null;
        findLCA(root, p, q);
        return result;
    }
    
    private boolean findLCA(TreeNode node, TreeNode p, TreeNode q) {
        // Base case: if node is null, return false
        if (node == null) {
            return false;
        }
        
        // Recursively check left and right subtrees
        boolean left = findLCA(node.left, p, q);
        boolean right = findLCA(node.right, p, q);
        
        // Check if current node is one of the target nodes
        boolean current = (node == p || node == q);
        
        // If we found both p and q in different subtrees, or 
        // one is found and current node is the other target
        if ((left && right) || (current && (left || right))) {
            result = node;
        }
        
        // Return true if any of the three conditions is met:
        // 1. p or q found in left subtree
        // 2. p or q found in right subtree  
        // 3. Current node is one of p or q
        return left || right || current;
    }
}

This solution uses a post-order traversal approach to find the LCA. The key insight is that we can determine if a subtree contains p or q by checking the return values from recursive calls. When we find that both p and q exist in different subtrees of a node, or one is found in a subtree and the current node is the other target, that node must be the LCA.

The algorithm maintains a global result variable to store the found LCA, and uses boolean return values to track whether p or q is found in each subtree. This approach avoids storing parent references and works efficiently with a single pass through the tree.

7. Complexity Analysis

Time Complexity: O(N + M) where N is the number of nodes in the tree and M is the number of queries.

The preprocessing phase requires O(N) time to build the parent and depth information for all nodes. Each query for LCA can then be answered in O(log N) time using binary lifting, but since we're considering the total cost across all queries, it's O(N + M log N) in the worst case.

Space Complexity: O(N log N)

This includes:

  • O(N) space for storing parent and depth arrays
  • O(N log N) space for the binary lifting table (sparse table), where each node requires O(log N) space to store its ancestors at different powers of 2
  • Additional O(N) for the initial tree traversal to populate data structures

Why These Bounds Are Correct:

The O(N) preprocessing is optimal because we must examine each node at least once to establish the parent-child relationships and depth information. The O(log N) query time comes from the fact that we can reach any node in at most log N steps by jumping to ancestors at powers of 2.

Optimality:

This solution is near-optimal for the static LCA problem with multiple queries. The preprocessing cost of O(N) is unavoidable since we must build the tree structure information, and the query cost of O(log N) is optimal for this type of static tree query problem using binary lifting techniques.

The solution achieves the best possible trade-off between preprocessing time and query efficiency for static LCA problems, making it ideal for scenarios where we have many queries on the same tree structure.

8. Edge Cases & Pitfalls

Empty and Minimal Inputs

  • Empty tree: If root is null, LCA is undefined. The solution should handle this gracefully by returning null or throwing an appropriate exception.
  • Single node: If the tree has only one node, both target nodes must be that node for a valid LCA query.
  • One target missing: If either p or q doesn't exist in the tree, the result should be handled consistently (typically return null).

Duplicate Values and Repeated States

  • Duplicate node values: The algorithm assumes unique node values. If duplicates exist, the solution must distinguish between nodes with same values, which requires additional tracking or structure.
  • Same node as both targets: If p and q refer to the same node, that node is the LCA.

Boundary Conditions

  • Root node as LCA: When one target is the root and the other is in its subtree, the root is the LCA.
  • Target nodes in same subtree: When both targets are in the left or right subtree, the LCA is the deepest node that contains both.
  • Target nodes in different subtrees: When one target is in the left subtree and another in the right, the LCA is the root node.

Common Implementation Mistakes

  • Incorrect base case handling: Not properly checking for null nodes or not handling the case where a node is one of the targets.
  • Overlooking the recursive return: Forgetting to return the result from recursive calls, leading to incorrect traversal.
  • Mutating input tree: Modifying the tree structure during traversal (e.g., setting child pointers to null) can cause issues or side effects.
  • Not handling edge case of one node being ancestor of another: If p is an ancestor of q, the solution should correctly return p.
  • Incorrect node comparison: Using == instead of .equals() for node value comparison, or not properly comparing node references when needed.

Implementation Considerations

  • Null checks: Ensure all null checks are properly handled to avoid NullPointerExceptions.
  • Node existence validation: Validate that both target nodes exist in the tree before proceeding with LCA computation.
  • Reference vs value comparison: When working with node values, ensure that the comparison logic is correct and consistent.

9. Alternative Approaches

There are several alternative approaches to solving the Lowest Common Ancestor (LCA) problem, each with different trade-offs:

Approach 1: Parent Pointer Tree If the tree nodes have parent pointers, we can traverse upward from both nodes to find their intersection. We can use a HashSet to store ancestors of one node, then traverse the second node's path upward until we find an ancestor that exists in the set.

Approach 2: Path to Root Method We can find paths from both nodes to the root and then compare these paths to find the last common node. This approach requires O(h) space where h is the height of the tree, and O(h) time to build paths.

Approach 3: Binary Lifting (Sparse Table) For multiple queries on the same tree, we can preprocess the tree using binary lifting to answer LCA queries in O(log n) time. This is useful for online scenarios but requires O(n log n) preprocessing.

Trade-offs Analysis:

The chosen solution (DFS + ancestor tracking) is optimal for single LCA queries because:

  • It has O(n) time and space complexity
  • It's simpler to implement than binary lifting
  • It doesn't require preprocessing or parent pointers
  • For the typical FAANG constraint of single queries, it's more practical than O(log n) solutions

The parent pointer approach is better when the tree structure already provides parent references, but it requires modifying the input data structure. The path-to-root approach is simpler conceptually but uses more space and is less efficient for large trees.

The binary lifting approach shines when there are many queries on the same tree, but it's overkill for single LCA problems and adds significant implementation complexity.

// Alternative: Parent Pointer Approach (if nodes have parent references)
/*
public TreeNode lowestCommonAncestorWithParent(TreeNode p, TreeNode q) {
    Set<TreeNode> ancestors = new HashSet<>();
    TreeNode current = p;
    
    // Store all ancestors of p
    while (current != null) {
        ancestors.add(current);
        current = current.parent;
    }
    
    // Traverse q's ancestors until we find one in the set
    current = q;
    while (current != null) {
        if (ancestors.contains(current)) {
            return current;
        }
        current = current.parent;
    }
    
    return null;
}
*/

10. Test Cases

public class TestCases {
    public static void main(String[] args) {
        // Test case 1: Basic scenario
        //       3
        //      / \
        //     5   1
        //    / \ / \
        //   6  2 0  8
        //     / \
        //    7   4
        TreeNode root = new TreeNode(3);
        root.left = new TreeNode(5);
        root.right = new TreeNode(1);
        root.left.left = new TreeNode(6);
        root.left.right = new TreeNode(2);
        root.right.left = new TreeNode(0);
        root.right.right = new TreeNode(8);
        root.left.right.left = new TreeNode(7);
        root.left.right.right = new TreeNode(4);
        
        // LCA of 5 and 1 should be 3
        assert new Solution().lowestCommonAncestor(root, root.left, root.right).val == 3;
        
        // LCA of 5 and 4 should be 5
        assert new Solution().lowestCommonAncestor(root, root.left, root.left.right.right).val == 5;
        
        // Test case 2: One node is ancestor of another
        // LCA of 2 and 7 should be 2 (node 2 is ancestor of node 7)
        assert new Solution().lowestCommonAncestor(root, root.left.right.right, root.left.right.left).val == 2;
        
        // Test case 3: One node is itself
        // LCA of 7 and 7 should be 7
        assert new Solution().lowestCommonAncestor(root, root.left.right.left, root.left.right.left).val == 7;
        
        // Test case 4: Empty tree
        assert new Solution().lowestCommonAncestor(null, null, null) == null;
        
        // Test case 5: Single node tree
        TreeNode single = new TreeNode(1);
        assert new Solution().lowestCommonAncestor(single, single, single).val == 1;
        
        // Test case 6: Nodes not in tree
        TreeNode other = new TreeNode(9);
        // This should handle gracefully or throw exception depending on requirements
        System.out.println("All test cases passed");
    }
}

11. Final Summary

The LCA problem is elegantly solved using the binary lifting technique with preprocessed parent relationships, achieving O(log n) query time after O(n log n) preprocessing. The core insight is to build a sparse table of ancestors where parent[i][j] represents the 2^j-th ancestor of node i, enabling binary search-like jumps to find the lowest common ancestor. This approach is optimal for static tree structures with multiple queries, trading preprocessing time for fast query performance, and handles all edge cases including duplicate nodes, single-node trees, and invalid queries gracefully through proper boundary checks and validation.

Was this article helpful?

Topics in this article

More from DSA Patterns

  • DSA Problems

    Same Tree and Symmetric Tree Problems in Java

    Comprehensive Java solution for same tree and symmetric tree problems including recursive and iterative approaches with time and space complexity analysis.

  • DSA Patterns

    Subtree of Another Tree Algorithm Solution

    Complete Java implementation for checking if one binary tree is a subtree of another, including multiple approaches and time complexity analysis.

  • DSA Problems

    Lowest Common Ancestor of a BST

    Find the lowest common ancestor in a binary search tree using iterative and recursive approaches with O(log n) time complexity.

  • DSA Problems

    Subtree of Another Tree Java Solution

    Complete Java implementation for checking if one binary tree is a subtree of another, including multiple approaches and time complexity analysis.

Stay in the Loop

Get curated DSA patterns, coding problems, frontend and backend LLD/HLD, and AI/ML System Design insights delivered straight to your inbox. No spam, just focused interview and engineering content.