๐ŸŽ“ A1Learner
๐Ÿ”

DSA Lowest Common Ancestor in a BST

The lowest common ancestor of two nodes in a BST is the deepest node whose subtree holds both of them, and one walk down from the root finds it. The walk moves while both targets sit on the same side and stops at the first split.

The problem

The lowest common ancestor of two nodes is the deepest node whose subtree contains both of them. Lowest means closest to the two nodes. A node counts as an ancestor of itself, so a target inside another target's subtree has that other target as its answer.

The tree in this problem is a binary search tree. Smaller values sit on the left and bigger values sit on the right at every node. That ordering is what makes a short walk possible.

The cases used in this article

class TreeNode:
    def __init__(self, value, left=None, right=None):
        self.value = value
        self.left = left
        self.right = right

def level_order(root):
    if root is None:
        return []
    out = []
    queue = [root]
    while queue:
        node = queue.pop(0)
        if node is None:
            out.append(None)
        else:
            out.append(node.value)
            queue.append(node.left)
            queue.append(node.right)
    while out and out[-1] is None:
        out.pop()
    return out

def insert(root, value):
    if root is None:
        return TreeNode(value)
    if value < root.value:
        root.left = insert(root.left, value)
    else:
        root.right = insert(root.right, value)
    return root

def build_bst(values):
    tree = None
    for v in values:
        tree = insert(tree, v)
    return tree

tree = build_bst([8, 3, 10, 1, 6, 14, 4, 7])
print("level order:", level_order(tree))
print("LCA of 1 and 7: expected 3")
print("LCA of 4 and 7: expected 6")
print("LCA of 1 and 14: expected 8")
print("LCA of 6 and 7: expected 6")
print("LCA of 1 and 4: expected 3")

The tree holds eight values. Its root is 8, with 3 and 10 below it. The five pairs above cover the shapes that matter. One pair splits at the root, one pair splits deep, and one pair has its answer at one of the targets itself.

Intuition

The first idea checks only the root. If the targets sit on different sides of the root, the root is the answer. If they sit on the same side, a root-only check has nothing more to say, and a guess of the root is wrong.

The pair 1 and 7 shows the gap. Both values are smaller than 8, so both live in the left subtree. Their common ancestor sits inside that subtree, deeper than the root.

A root-only check guesses wrong

class TreeNode:
    def __init__(self, value, left=None, right=None):
        self.value = value
        self.left = left
        self.right = right

def level_order(root):
    if root is None:
        return []
    out = []
    queue = [root]
    while queue:
        node = queue.pop(0)
        if node is None:
            out.append(None)
        else:
            out.append(node.value)
            queue.append(node.left)
            queue.append(node.right)
    while out and out[-1] is None:
        out.pop()
    return out

def insert(root, value):
    if root is None:
        return TreeNode(value)
    if value < root.value:
        root.left = insert(root.left, value)
    else:
        root.right = insert(root.right, value)
    return root

def build_bst(values):
    tree = None
    for v in values:
        tree = insert(tree, v)
    return tree

def root_only(root, a, b):
    if (a < root.value and b > root.value) or (a > root.value and b < root.value):
        return root.value
    if a == root.value or b == root.value:
        return root.value
    return root.value

tree = build_bst([8, 3, 10, 1, 6, 14, 4, 7])
print("pair 1 and 7 both smaller than root 8:", 1 < 8 and 7 < 8)
print("root-only answer for 1 and 7:", root_only(tree, 1, 7))
print("expected answer: 3")

The root-only answer is 8 and the true answer is 3. The fix is to repeat the same side test at each node on the way down. The walk keeps moving while both targets sit on one side, and it stops at the first split.

Brute force

A general route ignores the ordering completely. It searches the whole tree and records the parent of every node. It then collects every ancestor of the first target and walks up from the second target until it meets one of them.

The route works on any binary tree. Its cost is the full search. Every node is visited even when the two targets sit near the root.

Parent map from a full search, with a visit counter

class TreeNode:
    def __init__(self, value, left=None, right=None):
        self.value = value
        self.left = left
        self.right = right

def level_order(root):
    if root is None:
        return []
    out = []
    queue = [root]
    while queue:
        node = queue.pop(0)
        if node is None:
            out.append(None)
        else:
            out.append(node.value)
            queue.append(node.left)
            queue.append(node.right)
    while out and out[-1] is None:
        out.pop()
    return out

def insert(root, value):
    if root is None:
        return TreeNode(value)
    if value < root.value:
        root.left = insert(root.left, value)
    else:
        root.right = insert(root.right, value)
    return root

def build_bst(values):
    tree = None
    for v in values:
        tree = insert(tree, v)
    return tree

visits = 0

def brute_lca(root, a, b):
    global visits
    visits = 0
    parent = {root.value: None}
    order = [root]
    while order:
        node = order.pop(0)
        visits += 1
        for child in (node.left, node.right):
            if child is not None:
                parent[child.value] = node.value
                order.append(child)
    ancestors = set()
    x = a
    while x is not None:
        ancestors.add(x)
        x = parent[x]
    y = b
    while y not in ancestors:
        y = parent[y]
    return y

tree = build_bst([8, 3, 10, 1, 6, 14, 4, 7])
for a, b in [(1, 7), (4, 7), (1, 14), (6, 7)]:
    print("LCA of", a, "and", b, ":", brute_lca(tree, a, b), "visits:", visits)

Every answer is right and every search visits all eight nodes. The parent map also costs one entry per node. The BST ordering can cut both costs to a single downward path.

The optimal solution: one walk down

The key idea is one sentence. Walk down from the root, go left while both targets are smaller and right while both are bigger, and stop at the first node where they split or where one of them equals the node.

That stopping node is the answer. Everything above it holds both targets on the same side, so it is a shallower common ancestor. Everything below it can hold at most one of them.

The BST walk, run on every case

class TreeNode:
    def __init__(self, value, left=None, right=None):
        self.value = value
        self.left = left
        self.right = right

def level_order(root):
    if root is None:
        return []
    out = []
    queue = [root]
    while queue:
        node = queue.pop(0)
        if node is None:
            out.append(None)
        else:
            out.append(node.value)
            queue.append(node.left)
            queue.append(node.right)
    while out and out[-1] is None:
        out.pop()
    return out

def insert(root, value):
    if root is None:
        return TreeNode(value)
    if value < root.value:
        root.left = insert(root.left, value)
    else:
        root.right = insert(root.right, value)
    return root

def build_bst(values):
    tree = None
    for v in values:
        tree = insert(tree, v)
    return tree

def lca(root, a, b):
    node = root
    while node is not None:
        if a < node.value and b < node.value:
            node = node.left
        elif a > node.value and b > node.value:
            node = node.right
        else:
            return node.value
    return None

tree = build_bst([8, 3, 10, 1, 6, 14, 4, 7])
for a, b in [(1, 7), (4, 7), (1, 14), (6, 7), (1, 4)]:
    print("LCA of", a, "and", b, ":", lca(tree, a, b))

All five cases from the first section pass. The walk never visits a subtree that cannot hold the answer. For the pair 1 and 14 it stops at once, because the targets split at the root itself.

Walkthrough

Follow the pair 4 and 7 through the tree. The root is 8. Both targets are smaller than 8, so the walk goes left before any answer is possible.

Tracing the walk for 4 and 7

class TreeNode:
    def __init__(self, value, left=None, right=None):
        self.value = value
        self.left = left
        self.right = right

def level_order(root):
    if root is None:
        return []
    out = []
    queue = [root]
    while queue:
        node = queue.pop(0)
        if node is None:
            out.append(None)
        else:
            out.append(node.value)
            queue.append(node.left)
            queue.append(node.right)
    while out and out[-1] is None:
        out.pop()
    return out

def insert(root, value):
    if root is None:
        return TreeNode(value)
    if value < root.value:
        root.left = insert(root.left, value)
    else:
        root.right = insert(root.right, value)
    return root

def build_bst(values):
    tree = None
    for v in values:
        tree = insert(tree, v)
    return tree

def traced(root, a, b):
    node = root
    while node is not None:
        if a < node.value and b < node.value:
            print("at", node.value, ": both targets smaller, go left")
            node = node.left
        elif a > node.value and b > node.value:
            print("at", node.value, ": both targets bigger, go right")
            node = node.right
        else:
            print("at", node.value, ": targets split or match, answer is", node.value)
            return node.value
    return None

tree = build_bst([8, 3, 10, 1, 6, 14, 4, 7])
print("answer:", traced(tree, 4, 7))

At 8 the walk goes left. At 3 both targets are bigger, so the walk goes right. At 6 the targets split, with 4 on the left and 7 on the right. No deeper node can hold both, so 6 is the lowest common ancestor.

Complexity

Time for the walk is O(h) for a tree of height h. The walk follows one path from the root, so it makes one comparison step per level.

Space is O(1). The walk holds one current node and nothing else. The brute force pays O(n) time and O(n) space for its parent map on a tree of n nodes.

Counting walk steps as trees grow

class TreeNode:
    def __init__(self, value, left=None, right=None):
        self.value = value
        self.left = left
        self.right = right

def level_order(root):
    if root is None:
        return []
    out = []
    queue = [root]
    while queue:
        node = queue.pop(0)
        if node is None:
            out.append(None)
        else:
            out.append(node.value)
            queue.append(node.left)
            queue.append(node.right)
    while out and out[-1] is None:
        out.pop()
    return out

def insert(root, value):
    if root is None:
        return TreeNode(value)
    if value < root.value:
        root.left = insert(root.left, value)
    else:
        root.right = insert(root.right, value)
    return root

def build_bst(values):
    tree = None
    for v in values:
        tree = insert(tree, v)
    return tree

steps = 0

def lca_counted(root, a, b):
    global steps
    steps = 0
    node = root
    while node is not None:
        steps += 1
        if a < node.value and b < node.value:
            node = node.left
        elif a > node.value and b > node.value:
            node = node.right
        else:
            return node.value
    return None

def balanced(sorted_vals):
    if not sorted_vals:
        return None
    mid = len(sorted_vals) // 2
    node = TreeNode(sorted_vals[mid])
    node.left = balanced(sorted_vals[:mid])
    node.right = balanced(sorted_vals[mid + 1:])
    return node

for n in [7, 15, 31]:
    tree = balanced(list(range(1, n + 1)))
    ans = lca_counted(tree, 1, n)
    print("nodes:", n, "answer:", ans, "walk steps:", steps, "full search visits:", n)

The node count doubles and then doubles again. The walk grows by one step each time, because a balanced tree gains one level. The full search grows with every node added, which is the gap the ordering buys.

Edge cases and pitfalls

The answer can be the root itself. That happens when the targets split at the root or when one target is the root. The walk handles it on its first step, with no special case.

The answer can also be one of the targets. The split test uses strict comparisons, so a node equal to a target stops the walk. Both targets must be present in the tree. The walk never checks the rest of the tree, so a missing target gives a meaningless answer instead of an error.

Edge cases, run

class TreeNode:
    def __init__(self, value, left=None, right=None):
        self.value = value
        self.left = left
        self.right = right

def level_order(root):
    if root is None:
        return []
    out = []
    queue = [root]
    while queue:
        node = queue.pop(0)
        if node is None:
            out.append(None)
        else:
            out.append(node.value)
            queue.append(node.left)
            queue.append(node.right)
    while out and out[-1] is None:
        out.pop()
    return out

def insert(root, value):
    if root is None:
        return TreeNode(value)
    if value < root.value:
        root.left = insert(root.left, value)
    else:
        root.right = insert(root.right, value)
    return root

def build_bst(values):
    tree = None
    for v in values:
        tree = insert(tree, v)
    return tree

def lca(root, a, b):
    node = root
    while node is not None:
        if a < node.value and b < node.value:
            node = node.left
        elif a > node.value and b > node.value:
            node = node.right
        else:
            return node.value
    return None

tree = build_bst([8, 3, 10, 1, 6, 14, 4, 7])
single = TreeNode(9)
print("split at root, 1 and 14:", lca(tree, 1, 14))
print("one target is the root, 8 and 7:", lca(tree, 8, 7))
print("answer is a target, 6 and 7:", lca(tree, 6, 7))
print("same target twice, 6 and 6:", lca(tree, 6, 6))
print("both deep right, 10 and 14:", lca(tree, 10, 14))
print("single-node tree, 9 and 9:", lca(single, 9, 9))

The same-target case stops at that node itself. A node is its own ancestor under this problem's rule, so the pair 6 and 6 answers 6. Forgetting that rule is the most common way to send the walk one step too deep.

Exercise

In the closing example's BST, what is the lowest common ancestor of 1 and 4?