โ† Back to list

๐Ÿง  I Thought I Was Solving Lowest Common Ancestor. I Ended Up Learning Backtracking.

By Sai Pranav Moluguri

Sai Pranav Moluguri ยท 2026-08-23 20:41 ยท 0 claps ยท 5.7 min read
#data-structures #algorithms #backtracking #binary-tree #recursion
Open on Medium โ†—
Wiki topics: EDU ยท Education & Learning ๐Ÿ’ป ยท Programming

๐Ÿง  I Thought I Was Solving Lowest Common Ancestor. I Ended Up Learning Backtracking.

By Sai Pranav Moluguri

Recently, while continuing my DSA journey, I came across a Binary Tree problem:

Lowest Common Ancestor.

At first, the problem sounded simple.

Given two nodes in a binary tree, find their lowest common ancestor.

For example:

a
      /   \
     b     c
    / \     \
   d   e     f
      / \
     g   h

If the two nodes are:

d and h

their lowest common ancestor is:

b

Simple to see.

But when I tried to write the code, I immediately had a question:

Ancestors are above a node. How am I supposed to travel upward when my Node only has left and right pointers?

There is no:

node.parent

That was where the problem became interesting.

The First Idea

Instead of trying to travel upward from a node, I could travel downward from the root.

If I could find the path from the root to both target nodes:

root โ†’ ... โ†’ val1
root โ†’ ... โ†’ val2

then the lowest common ancestor should be the last node those two paths have in common.

For the example above:

path to d:
a โ†’ b โ†’ d

and:

path to h:
a โ†’ b โ†’ e โ†’ h

Compare them:

a โ†’ b โ†’ d
a โ†’ b โ†’ e โ†’ h
    โ†‘

a is common.

b is common.

Then the paths diverge at d and e.

So the last common node is:

b

And thatโ€™s our lowest common ancestor.

At this point, I thought I understood the problem.

Then I tried to actually build those paths.

And thatโ€™s when I learned something much more important than Lowest Common Ancestor.

The Real Problem Was Backtracking

To find a target node, I can perform DFS.

Suppose Iโ€™m looking for h.

I start at:

a

So my path becomes:

[a]

Then I explore b:

[a, b]

Then DFS might explore d:

[a, b, d]

But d isn't h.

Now I had a problem.

d is sitting inside my path, even though d isn't actually part of the path from a to h.

This is where backtracking clicked for me.

If I choose a node:

path.append(root.val)

and later discover:

This branch does not lead to my target

then I need to undo that choice:

path.pop()

So:

[a, b, d]
       โ†‘
    wrong path

becomes:

[a, b]

Then DFS can try another direction.

[a, b, e]

Maybe it tries g:

[a, b, e, g]

Wrong again.

Backtrack:

[a, b, e]

Then try h:

[a, b, e, h]

Found it.

That was the real lesson.

The Backtracking Recipe That Clicked

I started seeing the DFS as three simple operations:

CHOOSE
  โ†“
path.append(node)
EXPLORE
  โ†“
search children
DIDN'T WORK?
  โ†“
UNDO
path.pop()

Or even more simply:

Add โ†’ Explore โ†’ Not Found โ†’ Pop

That pop() isn't some random line at the bottom of a recursive function.

It has meaning.

It says:

โ€œThe decision I made at this recursive state did not lead to the solution, so Iโ€™m undoing it before returning.โ€

That changed the way I looked at the recursion.

Every Recursive Call Owns Its Choice

This reminded me of something I learned while studying Dynamic Programming.

In DP, I learned:

Every recursive call owns its own state.

Here I realized something similar:

Every recursive call is responsible for the choice it adds to the path.

If the call succeeds, keep the choice.

If the call fails, undo it.

For example:

DFS(d)

adds:

d

If d doesn't lead to the target, DFS(d) should remove d before returning False.

The parent shouldnโ€™t have to clean up its childโ€™s mistake.

That makes the recursion much easier to reason about.

Another Mistake I Made

Initially, I wrote something like:

if dfs(root.left, node, path):
    return True
else:
    path.pop()
    return False

It looked reasonable.

If the left side doesnโ€™t contain the target, return False.

Exceptโ€ฆ

What about the right side?

Consider:

e
     / \
    g   h

If Iโ€™m searching for h, DFS searches g first.

g returns False.

If I immediately return False, I never even look at:

h

So the correct thinking became:

Am I the target?
        โ†“ no
Can LEFT find it?
        โ†“ no
Can RIGHT find it?
        โ†“ no
Okay.
Neither worked.
NOW undo my current choice
and return False.

That produced this beautiful structure:

if root.val == node:
    return True
if dfs(root.left, node, path):
    return True
if dfs(root.right, node, path):
    return True
path.pop()
return False

The placement of path.pop() matters.

It only happens when:

The current node AND both of its subtrees failed to lead to the target.

Thatโ€™s backtracking.

Once I Had the Paths, LCA Became Easy

After DFS, I had:

path1 = root โ†’ ... โ†’ val1
path2 = root โ†’ ... โ†’ val2

For example:

path1 = [a, b, d]
path2 = [a, b, e, h]

Now I could use two pointers:

i
โ†“
a โ†’ b โ†’ d
a โ†’ b โ†’ e โ†’ h
โ†‘
j

Compare:

a == a

Move both.

b == b

Move both.

Then:

d != e

The paths have diverged.

Therefore the node immediately before the divergence:

b

is the lowest common ancestor.

And Then an Old Pattern Appeared Again

There was one more edge case.

Suppose weโ€™re finding the LCA of:

b and h

Their paths are:

a โ†’ b
a โ†’ b โ†’ e โ†’ h

We compare:

a == a
b == b

And then the first path ends.

There is no mismatch.

But thatโ€™s actually meaningful.

It means:

a โ†’ b

is a complete prefix of:

a โ†’ b โ†’ e โ†’ h

Therefore b itself is an ancestor of h.

So:

LCA = b

And suddenly I recognized an old friend.

Iโ€™ve seen this same broad idea while solving:

Merge Sort

Process both lists
โ†’ one ends
โ†’ handle what remains

Merge Two Sorted Linked Lists

Process both linked lists
โ†’ one ends
โ†’ attach the remaining list

Lexical Order

Compare both words
โ†’ one ends
โ†’ handle the prefix case

And now:

Lowest Common Ancestor

Compare both root-to-target paths
โ†’ one ends
โ†’ handle the ancestor/prefix case

Different problems.

Same underlying instinct:

If my main loop depends on BOTH sequences existing, I need to ask what it means when one sequence runs out first.

I love when patterns start appearing across completely different problems.

My Recipe for This Problem

If I had to solve this again from scratch, this is the recipe I would follow:

  1. Perform DFS to find the path from the root to the first target.
  2. Perform DFS again to find the path from the root to the second target.
  3. During DFS, if root is None, return False.
  4. Add the current node to the path.
  5. If the current node is the target, return True.
  6. Search the left subtree.
  7. If left doesnโ€™t find it, search the right subtree.
  8. If neither subtree finds the target, pop() the current node and return False.
  9. Now compare both root-to-target paths from the beginning.
  10. While values are equal, move both pointers.
  11. At the first mismatch, the previous value is the Lowest Common Ancestor.
  12. If one path ends before a mismatch, the final matching value is the ancestor.

But the recipe I really want to remember is much smaller:

CHOOSE
โ†“
ADD
EXPLORE
โ†“
DFS
FAIL?
โ†“
UNDO
path.pop()

Thatโ€™s backtracking.

My Final Solution

# class Node:
#   def __init__(self, val):
#     self.val = val
#     self.left = None
#     self.right = None
def lowest_common_ancestor(root, val1, val2):
  path1, path2 = [], []
  dfs(root, val1, path1)
  dfs(root, val2, path2)
  i, j = 0, 0
  while i < len(path1) and j < len(path2):
    if path1[i] == path2[j]:
      i += 1
      j += 1
    else:
      return path1[i - 1]
  if i == len(path1):
    return path1[i - 1]
  if j == len(path2):
    return path2[j - 1]
def dfs(root, node, path):
  if root is None:
    return False
  path.append(root.val)
  if root.val == node:
    return True
  if dfs(root.left, node, path):
    return True
  if dfs(root.right, node, path):
    return True
  path.pop()
  return False

My Takeaway

I started this problem thinking I was learning how to find the Lowest Common Ancestor in a Binary Tree.

I did learn that.

But that wasnโ€™t the part I want to remember.

The bigger lesson was:

When recursion makes a choice, explores that choice, and discovers that the choice doesnโ€™t lead to the solution, undo the choice before returning.

Choose.
Explore.
Fail.
Undo.
Choose.
Explore.
Fail.
Undo.
Choose.
Explore.
Found it.
Keep it.

Thatโ€™s backtracking.

And now path.pop() means something completely different to me.

Itโ€™s not merely removing the last element of a Python list.

Itโ€™s saying:

โ€œThis branch wasnโ€™t part of my answer. Undo my decision and try another path.โ€

Those are the moments I enjoy most in DSA.

I might begin with one problem.

But somewhere between the wrong code, the dry runs, the recursion, and finally getting it to work, I discover a reusable way of thinking.

And that is worth much more than memorizing another solution.

โ€œWith great power, there must also come great responsibility.โ€

Forever Learning. Forever Growing.

โ€” Sai Pranav Moluguri


๋ฉ”ํƒ€๋ฐ์ดํ„ฐ
post_id
19d9b2bf8aae
slug
i-thought-i-was-solving-lowest-common-ancestor-i-ended-up-learning-backtracking-19d9b2bf8aae
url
https://medium.com/@saipranavmoluguri2001/i-thought-i-was-solving-lowest-common-ancestor-i-ended-up-learning-backtracking-19d9b2bf8aae
canonical_url
https://medium.com/@saipranavmoluguri2001/i-thought-i-was-solving-lowest-common-ancestor-i-ended-up-learning-backtracking-19d9b2bf8aae
author_url
https://medium.com/@saipranavmoluguri2001
status
ok
fetched_at
2026-08-24 06:22:01