DEV Community

Cover image for LeetCode Meditations: Lowest Common Ancestor of a Binary Search Tree
Eda
Eda

Posted on • Originally published at rivea0.github.io

LeetCode Meditations: Lowest Common Ancestor of a Binary Search Tree

The description for this one states:

Given a binary search tree (BST), find the lowest common ancestor (LCA) node of two given nodes in the BST.

According to the definition of LCA on Wikipedia: “The lowest common ancestor is defined between two nodes p and q as the lowest node in T that has both p and q as descendants (where we allow a node to be a descendant of itself).”

For example:

Example 1 image

Input: root = [6, 2, 8, 0, 4, 7, 9, null, null, 3, 5], p = 2, q = 8
Output: 6
Explanation: The LCA of nodes 2 and 8 is 6.
Enter fullscreen mode Exit fullscreen mode

Or:

Example 2 image

Input: root = [6, 2, 8, 0, 4, 7, 9, null, null, 3, 5], p = 2, q = 4
Output: 2
Explanation: The LCA of nodes 2 and 4 is 2, since a node can be a descendant of itself according to the LCA definition.
Enter fullscreen mode Exit fullscreen mode

If this is the first time you're hearing the term lowest common ancestor in the context of trees, it might sound a bit intimidating. But, it's indeed easy to see how it works.

Like many other problems, the solution awaits that we find which cases to look out for.

One of those cases is when p and q are in the different subtrees: that is, either p is greater than root and q is less than root, or p is less than root and q is greater than root. In that case, we know that the root will be their lowest common ancestor.

Since a node can be a descendant of itself, we can also return it when it's the lowest common ancestor to both nodes, as in the second example above.

My initial solution in TypeScript was this:

/**
 * Definition for a binary tree node.
 * class TreeNode {
 *   val: number
 *   left: TreeNode | null
 *   right: TreeNode | null
 *   constructor(val?: number, left?: TreeNode | null, right?: TreeNode | null) {
 *     this.val = (val === undefined ? 0 : val)
 *     this.left = (left === undefined ? null : left)
 *     this.right = (right === undefined ? null : right)
 *   }
 * }
 */

function lowestCommonAncestor(root: TreeNode | null, p: TreeNode | null, q: TreeNode | null): TreeNode | null {
  if (
    root.val === p.val ||
    root.val === q.val ||
    (p.val < root.val && q.val > root.val) ||
    (p.val > root.val && q.val < root.val)
  ) {
    return root;
  }

  if (p.val > root.val && q.val > root.val) {
    return lowestCommonAncestor(root.right, p, q);
  }

  if (p.val < root.val && q.val < root.val) {
    return lowestCommonAncestor(root.left, p, q);
  }
}
Enter fullscreen mode Exit fullscreen mode

We're using (yet again) recursion. If the values of p and q are greater than root, we pass root.right as root to our function, otherwise, we pass root.left.

The base cases are the ones mentioned above. It looks a bit confusing, so let's look at it closely.

This one:

(p.val < root.val && q.val > root.val)
Enter fullscreen mode Exit fullscreen mode

And this one:

(p.val > root.val && q.val < root.val)
Enter fullscreen mode Exit fullscreen mode

check whether p and q are in different subtrees — that is, when a split happens. In that case, root is their lowest common ancestor. If one of them is true, we need to return root.

Other conditions are when root.val === p.val or root.val === q.val. In these cases, either p or q is the same as the root, which means that it is the lowest common ancestor, so we can return root as well.

Time and space complexity

Both the time and space complexity will be O(h)O(h) —where hh is the height of the tree. We touch one node at each level as we go (hence the time complexity). And, because of the recursion, we create a new stack frame for each function call, so the additional space is proportionate to the height of the tree.

Note
Since this is a BST, and we're halving the search space as we go, the time complexity can also be said to be O(log n)O(log \ n) .

Non-recursive approach

We can do better for the space complexity, and not use recursion at all — like this:

/**
 * Definition for a binary tree node.
 * class TreeNode {
 *   val: number
 *   left: TreeNode | null
 *   right: TreeNode | null
 *   constructor(val?: number, left?: TreeNode | null, right?: TreeNode | null) {
 *     this.val = (val === undefined ? 0 : val)
 *     this.left = (left === undefined ? null : left)
 *     this.right = (right === undefined ? null : right)
 *   }
 * }
 */

function lowestCommonAncestor(root: TreeNode | null, p: TreeNode | null, q: TreeNode | null): TreeNode | null {
  let currentNode = root;

  while (currentNode !== null) {
    if (p.val > currentNode.val && q.val > currentNode.val) {
      currentNode = currentNode.right;
    } else if (p.val < currentNode.val && q.val < currentNode.val) {
      currentNode = currentNode.left;
    // One of them is larger and one of them is smaller than 
    // the currentNode (i.e., a split happened)
    } else { 
      return currentNode;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

This version is perhaps more readable and cleaner than the first one. When both p and q have greater values than currentNode (which is initially root), we go to the right subtree, otherwise if their values are less, we go to the left subtree. In all the other cases (the ones we've looked at in the recursive version), we just return currentNode.

Time and space complexity

The time complexity is again O(h)O(h) where hh is the height, for the same reason that we touch each node at every level. However, the space complexity is O(1)O(1) as we don't need additional space.


The next problem is a fun and essential one, Binary Tree Level Order Traversal. Until then, happy coding.

Top comments (0)