DSA sheet · Trees · Lowest Common Ancestor pattern

Lowest Common Ancestor of a Binary Tree

This is the first problem of the LCA pattern, and the next problems (like LCA of Deepest Leaves) build directly on it. Given two nodes, we must find the closest node above both of them. The teacher does not hand us the code. She tries small "what if" cases one by one, and each case adds one line to the solution. Then she shows a second approach using the parent map from the previous video (Nodes at Distance K) and explains why the first one is preferred in interviews.

Every part below follows the same order:
① the question in simple words → ② what the constraints tell us → ③ intuition → ④ building the conditions from examples → ⑤ approach steps → ⑥ code → ⑦ code line by line → ⑧ dry run → ⑨ complexity & remember

Part 0 · Before starting

            3
          /   \
         5     1
        / \   / \
       6   2 0   8
          / \
         7   4
given by LeetCode, don't write this in the solution
class TreeNode:
    def __init__(self, x):
        self.val = x
        self.left = None
        self.right = None

Part A · LCA with recursive DFS

LeetCode 236

1The question in simple words

You get the root of a binary tree and two nodes p and q that are both in the tree. Return their lowest common ancestor: the deepest node that has both p and q somewhere below it (or is one of them).

The teacher's examples on the tree above:

2What the constraints tell us

The smallest case: a node can be its own ancestor

   1           1
  /             \
 2               2

With only 2 nodes and p = 1, q = 2: the parent of 2 is 1, and 1 counts as its own ancestor. So the answer is 1, in both shapes. The point: the answer can be p or q itself. It doesn't have to be a third node sitting above them.

3Intuition: ask each subtree "did you find anyone?"

Stand at a node and send two messengers down: one into the left subtree, one into the right. Each messenger comes back and reports either "I found p or q (here it is)" or "I found nothing".

In code, "found something" means returning a node, and "found nothing" means returning None.

4Building the conditions from examples

Case 1: the root itself is p (or q) → return the root

Say p = 3 (the root) and q = 0. The LCA is 3: 3 is above 0, and 3 is its own ancestor. Now the teacher asks: once we've found 3, do we even need to look further down? No. Whatever is below, the answer is already 3.

This is true for any node, not just the real root: if the current node equals p or q, return it straight away.

Rule 1if root is p or root is q: return root
Doubt 1: we stopped at p without looking for q below it. What if q is somewhere else in the tree?
→ Two possibilities. If q is below p, then p is the answer, so stopping is right. If q is not below p, then q is in some other branch, and a higher node will get "found" reports from both sides and become the LCA. Either way, returning p is correct. This only works because the problem promises both nodes exist in the tree.

Case 2: p and q are on different sides → the root is the LCA

Say p = 5, q = 1. At 3: it's neither p nor q, so we go left and go right.

The left side says "I found one!" and the right side says "I found one too!". The teacher pictures 3 telling them: don't argue, I'm the common parent.

Rule 2left = self.lowestCommonAncestor(root.left, p, q)
right = self.lowestCommonAncestor(root.right, p, q)
if left and right: return root
Doubt 2: why do we save the results in left and right?
→ The function returns a node (or None). We need to look at both answers before deciding what to return, so we keep them in variables. They are of node type, not True/False.

Case 3: the empty spot → return None

When we go past a leaf we land on None. There's nothing to find there, so we return None. Since root is None in that case, "return None" is the same as "return root". So the teacher folds it into Rule 1:

Rule 1, final formif root is None or root is p or root is q: return root
None gives None, p gives p, q gives q. One line, three cases.

Case 4: p = 6, q = 4 → both None, or only one side found something

The answer should be 5. Let's follow it:

Both sides of 7 are None. What should 7 return? It's not p or q, and nothing was found below it. So it reports "nothing" → None. Returning a node means "I found it", so we may only return a node when we really found one.

Now 2 has left = None, right = 4. This is a new case. 2 is not the LCA, but it must pass the 4 upwards, because 5 is waiting for a "found" report from its right side. Without it, 5 could never announce itself.

The same goes for the mirror case: if 7 had been q instead of 4, the left side would bring the node back and the right side would bring None, and 2 would pass up the left one.

Rule 3If only one side is not None → return that side.
If both are None → return None.

Writing Rule 3 in one line

The teacher compresses the last part into a single line: return left if left is not None else right. Why does it cover every case?

leftrightwhat we returnwhy
nodenoderootcaught earlier by Rule 2, we never get to the one-liner
nodeNoneleftleft is not None → return left
Nonenoderightleft is None → return right, which is the node
NoneNoneNoneleft is None → return right, which is also None. Exactly what we wanted.

5Approach steps

  1. If the node is None, or is p, or is q → return it.
  2. Ask the left subtree, and ask the right subtree (two recursive calls). Save both answers.
  3. If both answers are nodes → p and q are split across this node → return this node.
  4. Otherwise return whichever answer is not None (or None if both are None).

6Code (Python)

LCA with recursive DFS
class Solution:
    def lowestCommonAncestor(self, root, p, q):
        if root is None or root is p or root is q:   # Rule 1
            return root

        left = self.lowestCommonAncestor(root.left, p, q)
        right = self.lowestCommonAncestor(root.right, p, q)

        if left is not None and right is not None:   # Rule 2: split here
            return root
        return left if left is not None else right   # Rule 3

We compare nodes with is (the very same node object), because LeetCode gives us p and q as nodes from this tree. Comparing values with == also works here because the values are unique.

7Code line by line

linewhat it means
if root is None or root is p or root is q: return rootBase case. Empty spot → None ("found nothing"). This node is p or q → return it ("found one"), with no need to go deeper.
left = self.lowestCommonAncestor(root.left, p, q)What did the left subtree find? A node, or None.
right = self.lowestCommonAncestor(root.right, p, q)What did the right subtree find?
if left is not None and right is not None: return rootOne target on each side, so this node is where their paths meet. It's the LCA. From here upwards, every parent just passes it along.
return left if left is not None else rightPass up whatever was found. If nothing was found anywhere, this gives None.

8Dry run using the call stack

Run 1: p = 5, q = 1

  1. f(3): not None, not 5, not 1 → call left. f(3) waits.
  2. f(5): 5 is p → return 5. f(5) leaves the stack. f(3).left = 5.
  3. f(3) calls right: f(1): 1 is q → return 1. f(3).right = 1.
  4. f(3): left = 5, right = 1, both found → return 3. Answer 3 ✓
step 2
f(3)f(5) → 5
step 3
f(3) left=5f(1) → 1

Run 2: p = 6, q = 4

  1. f(3) → no match → go left. f(5) → no match → go left.
  2. f(6): 6 is p → return 6. f(5).left = 6.
  3. f(5) goes right: f(2) → no match → go left: f(7) → no match.
  4. f(7) calls f(None) twice → None, None → returns None. f(2).left = None.
  5. f(2) goes right: f(4): 4 is q → return 4. f(2).right = 4.
  6. f(2): left None, right 4 → return 4. f(5).right = 4.
  7. f(5): left 6, right 4 → both found → return 5. f(3).left = 5.
  8. f(3) goes right: f(1) → f(0) and f(8) both give None (their children are None) → f(1) returns None.
  9. f(3): left 5, right None → return 5. Answer 5 ✓
step 4 (deepest)
f(3)f(5) left=6f(2)f(7) → None
step 6
f(3)f(5) left=6f(2) → 4
step 7
f(3)f(5) → 5

9Complexity & remember

Doubt 3: n can be 10⁵. Is Python recursion OK on a skewed tree that deep?
→ Python's default recursion limit is about 1000 calls, so a very tall, skewed tree could raise RecursionError. If that happens, add import sys; sys.setrecursionlimit(200000) at the top. The logic doesn't change. (This is a Python detail, not part of the teacher's explanation.)
Remember LCA (DFS) None / p / q → return it. Ask left, ask right. Both found → I'm the LCA. Otherwise pass up whichever side found something.

Part B · LCA with a parent map and a set

The teacher calls this second approach very basic and very common. It is how you'd find the LCA by hand: walk up from p to the root, walk up from q, and see where the two paths first meet.

1The question (same as Part A)

Same input, same output. Only the method changes.

2Constraints (same as Part A)

n up to 10⁵ → still must be O(n). This approach is O(n) too, but uses more memory (we'll see why that matters at the end).

3Intuition: two paths up to the root

Write down every node on the way from p up to the root. Then walk up from q. The first node on q's way up that's already on p's list is the LCA.

But there's a problem: trees only go down. From 6 we can't step up to 5. The fix is the one from the previous video (Nodes at Distance K): build a parent map first, so every node can look up its parent.

4Building the logic

Step 1: the parent map (DFS)

Exactly as before: start at the root with parent None. Store parent[node] = par, then call the left child and the right child with the current node as their parent. On our tree: 3 → None, 5 → 3, 1 → 3, 6 → 5, 2 → 5, 7 → 2, 4 → 2, 0 → 1, 8 → 1.

This is the only recursive part of this approach. The rest is two simple while loops.

Step 2: store all of p's ancestors in a set

Start at p, add it to a set, and jump to its parent. Repeat until we fall off the top. That happens right after the root, because parent[root] is None. So the loop is while p is not None.

For p = 6: add 6 → jump to 5 → add 5 → jump to 3 → add 3 → jump to None → stop. Set = {6, 5, 3}.

Doubt 4: in the video, the set first ends up as {6, 3}. Is that a bug?
→ That was a slip in the dry run, which the teacher fixes herself: she skipped 5 by mistake. The parent of 6 is 5, so the set must be {6, 5, 3}. Missing 5 would make p = 6, q = 4 return 3, which is wrong. The code itself adds every node it passes, so it doesn't have this bug.
Doubt 5: why a set?
→ Because next we ask "is this node one of p's ancestors?" for each step up from q. A set answers in O(1).

Step 3: walk up from q until we hit the set

Start at q. While q is not in the set, jump to its parent. The moment q is in the set, it's the first node shared by both paths: the LCA. Return it.

For q = 4: is 4 in {6, 5, 3}? No → go to 2. Is 2 in it? No → go to 5. Is 5 in it? Yes → stop → return 5 ✓.

Doubt 6: could this loop run off the top and crash?
→ No. The root is always in p's set (every path up ends at the root). So at the very latest, q stops at the root.
Doubt 7: does it matter whether I store p's path or q's path?
→ No. You can store q's ancestors and walk up from p instead. The teacher points out that one path may be short and the other long. Either way it works, and the cost is at most the height of the tree.

5Approach steps

  1. Build parent with a DFS from the root (root's parent = None).
  2. Walk from p up to the root, adding every node to a set ancestors.
  3. Walk from q upwards until q is in ancestors.
  4. Return q.

6Code (Python)

LCA with a parent map + set
class Solution:
    def lowestCommonAncestor(self, root, p, q):
        parent = {}
        self.buildParent(root, None, parent)

        ancestors = set()
        while p is not None:          # p, its parent, ..., the root
            ancestors.add(p)
            p = parent[p]

        while q not in ancestors:     # climb from q until the paths meet
            q = parent[q]
        return q

    def buildParent(self, node, par, parent):
        if node is None:
            return
        parent[node] = par
        self.buildParent(node.left, node, parent)
        self.buildParent(node.right, node, parent)

7Code line by line

linewhat it means
self.buildParent(root, None, parent)Every node learns its parent, so we can walk upwards.
while p is not None: ancestors.add(p) p = parent[p]Record p's whole path up to the root. After the root, parent[root] is None and the loop stops.
while q not in ancestors: q = parent[q]Climb from q. Each step asks "is this on p's path?". If it's already on it at the start (q is an ancestor of p), the loop doesn't run at all.
return qThe first shared node = the lowest common ancestor.

8Dry run

p = 6, q = 4

steppointeractionancestors set
1p = 6add 6, p = parent[6] = 5{6}
2p = 5add 5, p = parent[5] = 3{6, 5}
3p = 3add 3, p = parent[3] = None → stop{6, 5, 3}
4q = 44 not in set → q = 2same
5q = 22 not in set → q = 5same
6q = 55 in set → stop, return 5same

p = 6, q = 8

Set = {6, 5, 3}. Climb from 8: 8 no → 1 no → 3 yes → return 3 ✓.

9Complexity: why Part A is preferred

Both approaches are O(n) time, but this one needs an extra hash map and hash set. Part A only uses the recursion stack. So in an interview, the recursive DFS (Part A) is the one the interviewer expects. Part B is good to know because the "parent map" trick comes back in other problems.

Remember LCA (parent map) Parent map by DFS → put all of p's ancestors in a set → climb from q until you hit the set → that node is the LCA.

Part C · Revision page

Part A · recursive DFSPart B · parent map + set
ideaeach subtree reports what it found, and the node where both sides report back is the LCAp's path up in a set, climb from q until it hits the set
directionanswers bubble up from the bottomexplicit walk upwards using the map
extra structuresnone (only the call stack)dict + set (+ stack to build the dict)
timeO(n)O(n) + O(h) = O(n)
spaceO(h)O(n)
interview pickyes, preferredgood backup / reusable trick
left resultright resultreturn
nodenoderoot (the split point)
nodeNoneleft
Nonenoderight
NoneNoneNone
If you remember only 5 lines 1. A node counts as its own ancestor, so the answer can be p or q.
2. If the current node is None, p or q → return it (no need to go deeper).
3. Recurse left and right, and save both results.
4. Both not None → this node is the LCA.
5. Otherwise return the one that isn't None (left if left else right).
Mistakes to avoid ✗ returning True/False instead of nodes (we need to pass the actual node up)
✗ thinking the LCA is always the direct parent (p = 5, q = 8 → 3, not 1)
✗ forgetting that one side may be None and the other not, so that side must be passed up
✗ in Part B, skipping a node while filling p's ancestor set (the teacher's slip with 5)
✗ climbing from q with while q is not None instead of while q not in ancestors
test it yourself (paste under either solution above)
def T(v, l=None, r=None):
    n = TreeNode(v); n.left, n.right = l, r; return n
n6, n7, n4, n0, n8 = T(6), T(7), T(4), T(0), T(8)
n2 = T(2, n7, n4); n5 = T(5, n6, n2); n1 = T(1, n0, n8)
root = T(3, n5, n1)

s = Solution()
print(s.lowestCommonAncestor(root, n5, n1).val)   # 3
print(s.lowestCommonAncestor(root, n6, n4).val)   # 5
print(s.lowestCommonAncestor(root, n5, n4).val)   # 5 (p is above q)
print(s.lowestCommonAncestor(root, n6, n8).val)   # 3

Based on this video: Lowest Common Ancestor of a Binary Tree