Taro Logo

Lowest Common Ancestor of a Binary Tree II

Medium
Meta logo
Meta
2 views
Topics:
TreesRecursion

You are given the root of a binary tree, along with two nodes, p and q. Your task is to write a function that finds the lowest common ancestor (LCA) of the nodes p and q in the tree. Unlike the standard LCA problem, if either p or q does not exist in the tree, the function should return null. Assume all node values are unique.

Here's a more detailed explanation:

  1. Lowest Common Ancestor: The LCA of two nodes p and q is defined as the lowest node in the tree that has both p and q as descendants (where we allow a node to be a descendant of itself).

  2. Non-Existent Nodes: If either p or q is not present in the tree, the function must return null.

  3. Input: The function will receive the root of the binary tree (root), and the two nodes to find the LCA for (p and q). The tree node structure is defined as follows:

    public class TreeNode {
        int val;
        TreeNode left;
        TreeNode right;
        TreeNode(int x) { val = x; }
    }
    
  4. Output: The function should return the TreeNode that represents the LCA of p and q. If either p or q is not in the tree, return null.

Example:

Consider the following binary tree:

        3
       / \
      5   1
     / \ / \
    6  2 0  8
       / \
      7   4
  • If p is node 5 and q is node 1, the LCA is node 3.
  • If p is node 5 and q is node 4, the LCA is node 5.
  • If p is node 5 and q is node 10 (which is not in the tree), the function should return null.

How would you implement this function efficiently, considering the possibility of non-existent nodes?

Solution


Clarifying Questions

When you get asked this question in a real-life environment, it will often be ambiguous (especially at FAANG). Make sure to ask these questions in that case:

  1. Can the values of the nodes in the tree be duplicated?
  2. What should I return if either p or q is not present in the tree?
  3. Is it guaranteed that both p and q are different nodes within the tree when they are present?
  4. Can p or q ever be the root node?
  5. Can I assume that p and q are valid `TreeNode` objects or do I need to handle the case where they are null?

Brute Force Solution

Approach

The brute force way to find the lowest common ancestor of two people in a family tree is to check every possibility. We can do this by looking at all the ancestors of each person. The first ancestor they share in common as we go up the tree is the lowest common ancestor.

Here's how the algorithm would work step-by-step:

  1. First, list all the ancestors of the first person, going up the family tree until you reach the very top.
  2. Next, list all the ancestors of the second person, doing the same thing.
  3. Now, compare the two lists of ancestors, starting with the most recent ancestors (the parents).
  4. Go through the lists until you find the first ancestor that appears in both lists. This common ancestor is the lowest one they share.
  5. If you get to the top of both lists without finding a common ancestor, it means there is no common ancestor in the family tree.

Code Implementation

def lowest_common_ancestor_brute_force(root, node_one, node_two):
    def find_ancestors(node):
        ancestors = []
        current_node = node

        while current_node:
            ancestors.append(current_node)
            # Assuming each node has a 'parent' attribute.
            if not hasattr(current_node, 'parent'):
                return []
            current_node = current_node.parent
        return ancestors

    node_one_ancestors = find_ancestors(node_one)
    node_two_ancestors = find_ancestors(node_two)

    # Reverse the lists to start from the immediate parents.
    node_one_ancestors.reverse()
    node_two_ancestors.reverse()

    # Iterate to find the lowest common ancestor
    lowest_common_ancestor = None
    for i in range(min(len(node_one_ancestors), len(node_two_ancestors))):
        # Stop when there's a divergence in ancestors.
        if node_one_ancestors[i] == node_two_ancestors[i]:
            lowest_common_ancestor = node_one_ancestors[i]
        else:
            break

    # Return the found common ancestor.
    return lowest_common_ancestor

Big(O) Analysis

Time Complexity
O(n)Let n be the number of nodes in the binary tree. The algorithm first finds all ancestors of node p, which in the worst case (a skewed tree) can be n. Similarly, finding all ancestors of node q can also take O(n) time. Comparing the two lists of ancestors, each of maximum size n, to find the first common element takes O(n) time. Therefore, the overall time complexity is O(n) + O(n) + O(n) which simplifies to O(n).
Space Complexity
O(N)The algorithm creates two lists to store the ancestors of each node. In the worst-case scenario, both nodes are leaf nodes in a tree that resembles a linked list, requiring storage for all nodes from the root to each leaf. Therefore, each list could contain up to N elements, where N is the number of nodes in the tree. Thus, the auxiliary space is proportional to N, resulting in O(N) space complexity.

Optimal Solution

Approach

The most efficient way to find the lowest common ancestor (LCA) in a binary tree when we know two nodes exist involves a single tree traversal. The idea is to recursively search for the nodes and track when they are found, returning relevant information up the tree. If both nodes are found in a subtree, the root of that subtree is the LCA.

Here's how the algorithm would work step-by-step:

  1. Start at the root of the tree.
  2. Recursively explore the left side of the current node, searching for the two target nodes.
  3. Recursively explore the right side of the current node, searching for the two target nodes.
  4. If the current node is one of the target nodes OR if both target nodes were found within its left and right subtrees, then the current node is the lowest common ancestor.
  5. If only one of the target nodes was found in either the left or right subtrees, return that found node to the parent node.
  6. If neither target node was found in either subtree, return nothing to the parent node.
  7. Continue this process upwards to the root, and the first node encountered where both target nodes are 'underneath' it (either directly or within its subtrees) is the LCA.

Code Implementation

def lowest_common_ancestor(root, first_node, second_node):
    def recurse_tree(node):
        if not node:
            return None

        # Recursively search the left subtree
        left_result = recurse_tree(node.left)

        # Recursively search the right subtree
        right_result = recurse_tree(node.right)

        # If the current node is one of the target nodes
        node_is_target = node == first_node or node == second_node

        # If the target nodes were found in left and right subtrees
        if left_result and right_result:
            return node

        # If the current node is a target node and either target was found in a subtree
        if node_is_target and (left_result or right_result):
            return node

        # If the current node is a target node
        if node_is_target:
            return node

        # Propagate results up the tree.
        return left_result or right_result

    return recurse_tree(root)

Big(O) Analysis

Time Complexity
O(n)The provided solution performs a single traversal of the binary tree to find the lowest common ancestor (LCA). In the worst-case scenario, we visit each node of the tree once. The number of nodes in the tree is denoted by 'n', where 'n' represents the size of the input. Therefore, the time complexity is directly proportional to the number of nodes visited, leading to O(n) time complexity, as the algorithm examines each node at most once during the recursive traversal.
Space Complexity
O(H)The space complexity is determined primarily by the recursion depth of the function calls. In the worst-case scenario, the binary tree might resemble a linked list, forcing the recursive calls to go as deep as the height (H) of the tree. Each recursive call adds a new frame to the call stack, which consumes memory. Therefore, the auxiliary space used corresponds to the maximum depth of the recursion, which is O(H), where H is the height of the binary tree. In a balanced tree, H would be log(N), but in a skewed tree, H could be N.

Edge Cases

CaseHow to Handle
Root is nullReturn null immediately since there is no tree to traverse.
p or q is nullReturn null if either p or q are null as LCA cannot be defined.
p and q are the same nodeReturn p (or q) since the lowest common ancestor of a node with itself is the node itself.
Either p or q, or both, do not exist in the tree.Return null if either p or q are not in the tree, which can be determined during the recursive traversal.
Large, unbalanced tree (e.g., skewed tree)Recursive solution may lead to stack overflow; consider iterative solution using a parent pointer map.
Tree with a single node that is either p or q.If the root is equal to either p or q, check if the other node is present in the tree; if not, return null, otherwise return the root.
p is an ancestor of q or q is an ancestor of pThe algorithm should correctly identify the ancestor as the LCA in these situations.
All node values are the same, potentially including p and q.The algorithm relies on object identity (pointer comparison), not value comparison, so identical values should not affect correctness.