Algorithm tutorials
Lowest Common Ancestor and Binary Lifting
From the classic one-query LCA in a binary tree to binary lifting, which answers k-th ancestor, LCA and distance queries in O(log n) each after O(n log n) preprocessing.
This is an extension topic beyond most interviews: a single LCA in a binary tree is standard interview material, while binary lifting pays off when you must answer many ancestor, LCA or distance queries on the same tree.
Intuition
The lowest common ancestor (LCA) of u and v in a rooted tree is the deepest vertex that is an ancestor of both (a vertex counts as its own ancestor). Every tree path u -> v goes up from u to lca(u, v) and then down to v, so
dist(u, v) = depth[u] + depth[v] - 2 * depth[lca(u, v)]
and many path questions reduce to LCA plus prefix information from the root.
Three levels of solutions:
- One query, binary tree. Recurse: if the current node is
porq, return it; ifpandqcome back from different subtrees, the current node is the LCA.O(n)per query. - Climbing. Lift the deeper vertex until both have equal depth, then lift both together until they meet.
O(depth)per query, fine for one query, too slow for10^5queries on a path-shaped tree. - Binary lifting. Precompute
up[j][v], the2^j-th ancestor ofv. Any upward move ofksteps is a sum of powers of two (the bits ofk), so it takesO(log n)jumps. For the LCA, equalize depths, then try jumps from the largest power down, taking a jump only if it keeps the two vertices apart. They end just below the LCA.
When to use it in interviews / OAs
- "Lowest common ancestor of a binary tree / BST / nodes with parent pointers": single-query techniques.
- "Kth ancestor of a tree node" with many queries: binary lifting.
- Many distance queries, or "how many operations to make all edge weights on the path equal" (path statistics from root prefix counts minus twice the LCA's).
- Path minimum or maximum edge weight for many queries: lift a second table alongside
up.
Template
One query in a binary tree
from dataclasses import dataclass
@dataclass(eq=False)
class TreeNode:
val: int
left: "TreeNode | None" = None
right: "TreeNode | None" = None
parent: "TreeNode | None" = None
def lca_binary_tree(root: TreeNode | None, p: TreeNode, q: TreeNode) -> TreeNode | None:
# assumes both p and q are in the tree
if root is None or root is p or root is q:
return root
left = lca_binary_tree(root.left, p, q)
right = lca_binary_tree(root.right, p, q)
if left and right:
return root # p and q sit in different subtrees
return left or right
def lca_with_parents(p: TreeNode, q: TreeNode) -> TreeNode:
# like the intersection of two linked lists: both pointers walk depth(p) + depth(q) steps
a, b = p, q
while a is not b:
a = a.parent if a else q
b = b.parent if b else p
return a
In a BST you can do even less: walk from the root and go left while both values are smaller, right while both are larger; the first split point is the LCA.
Binary lifting
class BinaryLifting:
def __init__(self, n: int, edges: list[tuple[int, int]], root: int = 0):
adj: list[list[int]] = [[] for _ in range(n)]
for u, v in edges:
adj[u].append(v)
adj[v].append(u)
self.depth = [0] * n
parent = [root] * n # the root is its own parent, so jumps clamp there
seen = [False] * n
seen[root] = True
stack = [root]
while stack:
u = stack.pop()
for v in adj[u]:
if not seen[v]:
seen[v] = True
parent[v] = u
self.depth[v] = self.depth[u] + 1
stack.append(v)
self.LOG = max(1, n.bit_length())
self.up = [parent] # up[j][v] = 2**j-th ancestor of v
for _ in range(1, self.LOG):
prev = self.up[-1]
self.up.append([prev[prev[v]] for v in range(n)])
def kth_ancestor(self, v: int, k: int) -> int:
if k > self.depth[v]:
return -1
j = 0
while k:
if k & 1: # take the jumps that match the set bits of k
v = self.up[j][v]
k >>= 1
j += 1
return v
def lca(self, u: int, v: int) -> int:
if self.depth[u] < self.depth[v]:
u, v = v, u
u = self.kth_ancestor(u, self.depth[u] - self.depth[v]) # equal depths now
if u == v:
return u
for j in range(self.LOG - 1, -1, -1):
if self.up[j][u] != self.up[j][v]: # jump only if we stay below the LCA
u, v = self.up[j][u], self.up[j][v]
return self.up[0][u]
def dist(self, u: int, v: int) -> int:
return self.depth[u] + self.depth[v] - 2 * self.depth[self.lca(u, v)]
Complexity
- Recursive binary-tree LCA:
O(n)time,O(h)stack. - Parent-pointer LCA:
O(depth(p) + depth(q)),O(1)memory. - Binary lifting:
O(n log n)time and memory to build;O(log n)per k-th ancestor, LCA or distance query.
Common pitfalls
- Assuming both nodes exist. The recursive version returns
peven whenqis missing from the tree. If that can happen, count how many targets were found. - Comparing by value instead of identity when node values repeat; compare node objects (
is). - Root handling. Make the root its own parent (or use
-1and guard every jump); otherwise jumps past the root index garbage. - Too few levels.
LOGmust satisfy2^LOG > max depth;n.bit_length()is enough. - Jumping in the wrong order. Equalize depths first, then go from the largest power down; going small to large skips past the answer.
- Deep recursion in Python for skewed trees: build depths and parents iteratively as above.
Interview variations
- Kth ancestor of a tree node.
kth_ancestor; with a parent array as input, skip the traversal and compute depths by memoized climbing. - Distance between two nodes / nodes at distance k.
dist(u, v)for many queries; a single query is easier with BFS after adding parent pointers. - LCA of the deepest leaves. One post-order pass returning (depth, lca) pairs; no lifting needed.
- Path aggregates. Store prefix counts from the root (for example, how many edges of each weight); the count on path
u-viscnt[u] + cnt[v] - 2 * cnt[lca]. - Path maximum edge. Keep
mx[j][v]= maximum edge on the2^jjump; combine while lifting. Useful for "minimum bottleneck between two cities" on an MST. - Other methods. Offline LCA with union-find (Tarjan), or Euler tour plus a sparse table for
O(1)queries; good to name, rarely needed.