Compute shortest path between tree nodes
Company: Snowflake
Role: Software Engineer
Category: Coding & Algorithms
Difficulty: Medium
Interview Round: Onsite
Quick Answer: Given a binary tree and two node values u and v, return the shortest path between them as an ordered list of node values. This Snowflake onsite question tests lowest-common-ancestor logic, path reconstruction, iterative vs. recursive implementation, complexity analysis, and preprocessing strategies (parent pointers, binary lifting, Euler tour + RMQ) for answering many queries efficiently.
Part 1: Core LCA Path Between Two Values
Constraints
- 0 <= len(nodes) <= 100000
- If nodes is non-empty, node 0 is the root.
- Each child index is either -1 or a valid node index.
- The input structure is a valid binary tree.
- All node values are unique.
- The returned path length is not counted as extra space.
Examples
Input: ([[3, 1, 2], [5, 3, 4], [1, 5, 6], [6, -1, -1], [2, 7, 8], [0, -1, -1], [8, -1, -1], [7, -1, -1], [4, -1, -1]], 5, 4)
Expected Output: [5, 2, 4]
Explanation: Node 5 is an ancestor of node 4, so the path goes downward through 2.
Input: ([[3, 1, 2], [5, 3, 4], [1, 5, 6], [6, -1, -1], [2, 7, 8], [0, -1, -1], [8, -1, -1], [7, -1, -1], [4, -1, -1]], 6, 8)
Expected Output: [6, 5, 3, 1, 8]
Explanation: The LCA is the root value 3.
Input: ([[3, 1, 2], [5, 3, 4], [1, 5, 6], [6, -1, -1], [2, 7, 8], [0, -1, -1], [8, -1, -1], [7, -1, -1], [4, -1, -1]], 5, 99)
Expected Output: []
Explanation: Value 99 does not exist in the tree.
Input: ([], 1, 2)
Expected Output: []
Explanation: An empty tree contains neither target.
Input: ([[42, -1, -1]], 42, 42)
Expected Output: [42]
Explanation: When u == v and the value exists, the path contains that one node.
Solution
def solution(nodes, u, v):
if not nodes:
return []
path_u = None
path_v = None
current = []
stack = [(0, 0)]
while stack:
index, state = stack.pop()
if index == -1 or index < 0 or index >= len(nodes):
continue
if state == 0:
value, left, right = nodes[index]
current.append(value)
if value == u:
path_u = current[:]
if value == v:
path_v = current[:]
if path_u is not None and path_v is not None:
break
stack.append((index, 1))
if right != -1:
stack.append((right, 0))
if left != -1:
stack.append((left, 0))
else:
current.pop()
if path_u is None or path_v is None:
return []
common = 0
limit = min(len(path_u), len(path_v))
while common < limit and path_u[common] == path_v[common]:
common += 1
lca_pos = common - 1
return path_u[lca_pos:][::-1] + path_v[common:]Time complexity: O(n), where n is the number of nodes.. Space complexity: O(h) extra space excluding the returned path, where h is the tree height..
Hints
- Find the path from the root to u and the path from the root to v. Their longest common prefix ends at the LCA.
- Once you know the LCA position, reverse the suffix of the u path up to the LCA, then append the suffix of the v path below the LCA.
Part 2: Compare Recursive and Iterative Tree Path Implementations
Constraints
- 0 <= len(nodes) <= 2000
- If nodes is non-empty, node 0 is the root.
- Each child index is either -1 or a valid node index.
- The input structure is a valid binary tree.
- All node values are unique.
Examples
Input: ([[3, 1, 2], [5, 3, 4], [1, 5, 6], [6, -1, -1], [2, 7, 8], [0, -1, -1], [8, -1, -1], [7, -1, -1], [4, -1, -1]], 6, 4)
Expected Output: [[6, 5, 2, 4], [6, 5, 2, 4]]
Explanation: Both implementations should produce the same path through LCA value 5.
Input: ([[3, 1, 2], [5, 3, 4], [1, 5, 6], [6, -1, -1], [2, 7, 8], [0, -1, -1], [8, -1, -1], [7, -1, -1], [4, -1, -1]], 3, 7)
Expected Output: [[3, 5, 2, 7], [3, 5, 2, 7]]
Explanation: The root is one endpoint of the path.
Input: ([[3, 1, 2], [5, 3, 4], [1, 5, 6], [6, -1, -1], [2, 7, 8], [0, -1, -1], [8, -1, -1], [7, -1, -1], [4, -1, -1]], 42, 4)
Expected Output: [[], []]
Explanation: One target is missing, so both paths are empty.
Input: ([], 1, 2)
Expected Output: [[], []]
Explanation: The empty tree edge case should be handled by both implementations.
Input: ([[10, -1, -1]], 10, 10)
Expected Output: [[10], [10]]
Explanation: For a single-node tree with u == v, both paths contain only that node.
Solution
def solution(nodes, u, v):
def combine_value_paths(path_a, path_b):
if path_a is None or path_b is None:
return []
common = 0
limit = min(len(path_a), len(path_b))
while common < limit and path_a[common] == path_b[common]:
common += 1
lca_pos = common - 1
return path_a[lca_pos:][::-1] + path_b[common:]
def recursive_path(target):
path = []
def dfs(index):
if index == -1 or index < 0 or index >= len(nodes):
return False
value, left, right = nodes[index]
path.append(value)
if value == target:
return True
if dfs(left) or dfs(right):
return True
path.pop()
return False
if not nodes or not dfs(0):
return None
return path
def recursive_result():
return combine_value_paths(recursive_path(u), recursive_path(v))
def iterative_result():
if not nodes:
return []
parent = {0: -1}
value_to_index = {}
visited = set()
stack = [0]
while stack:
index = stack.pop()
if index in visited or index < 0 or index >= len(nodes):
continue
visited.add(index)
value, left, right = nodes[index]
value_to_index[value] = index
if right != -1 and 0 <= right < len(nodes):
parent[right] = index
stack.append(right)
if left != -1 and 0 <= left < len(nodes):
parent[left] = index
stack.append(left)
if u not in value_to_index or v not in value_to_index:
return []
def root_path(index):
result = []
while index != -1:
result.append(index)
index = parent[index]
return result[::-1]
path_u = root_path(value_to_index[u])
path_v = root_path(value_to_index[v])
common = 0
limit = min(len(path_u), len(path_v))
while common < limit and path_u[common] == path_v[common]:
common += 1
lca_pos = common - 1
index_path = path_u[lca_pos:][::-1] + path_v[common:]
return [nodes[index][0] for index in index_path]
return [recursive_result(), iterative_result()]Time complexity: Recursive implementation: O(n). Iterative implementation: O(n). The combined function is O(n) with a constant-factor overhead.. Space complexity: Recursive implementation: O(h) call stack plus path storage. Iterative implementation: O(n) for parent pointers, visited set, and stack..
Hints
- The recursive version can search for a root-to-target path by appending on entry and popping on backtracking.
- The iterative version can build parent pointers with an explicit stack, then walk from each target up to the root.
Part 3: Answer Many Tree Path Queries with Preprocessing
Constraints
- 0 <= len(nodes) <= 100000
- 0 <= len(queries) <= 100000
- If nodes is non-empty, node 0 is the root.
- Each child index is either -1 or a valid node index.
- The input structure is a valid binary tree.
- All node values are unique.
Examples
Input: ([[3, 1, 2], [5, 3, 4], [1, 5, 6], [6, -1, -1], [2, 7, 8], [0, -1, -1], [8, -1, -1], [7, -1, -1], [4, -1, -1]], [[6, 4], [5, 8], [7, 7], [5, 99]])
Expected Output: [[6, 5, 2, 4], [5, 3, 1, 8], [7], []]
Explanation: Several queries reuse the same preprocessed tree. The missing value query returns an empty path.
Input: ([], [[1, 2], [1, 1]])
Expected Output: [[], []]
Explanation: All queries on an empty tree return empty paths.
Input: ([[10, -1, -1]], [[10, 10], [10, 5]])
Expected Output: [[10], []]
Explanation: The existing single-node query succeeds, while the missing-value query fails.
Input: ([[1, 1, -1], [2, 2, -1], [3, -1, -1]], [[1, 3], [3, 2]])
Expected Output: [[1, 2, 3], [3, 2]]
Explanation: This skewed tree includes ancestor-descendant query cases.
Solution
def solution(nodes, queries):
if not queries:
return []
n = len(nodes)
if n == 0:
return [[] for _ in queries]
log = 1
while (1 << log) <= n:
log += 1
parent = [-1] * n
depth = [0] * n
visited = [False] * n
value_to_index = {}
stack = [0]
while stack:
index = stack.pop()
if index < 0 or index >= n or visited[index]:
continue
visited[index] = True
value, left, right = nodes[index]
value_to_index[value] = index
if right != -1 and 0 <= right < n:
parent[right] = index
depth[right] = depth[index] + 1
stack.append(right)
if left != -1 and 0 <= left < n:
parent[left] = index
depth[left] = depth[index] + 1
stack.append(left)
up = [[-1] * n for _ in range(log)]
up[0] = parent[:]
for k in range(1, log):
for index in range(n):
mid = up[k - 1][index]
up[k][index] = -1 if mid == -1 else up[k - 1][mid]
def lift(index, distance):
bit = 0
while distance > 0 and index != -1:
if distance & 1:
index = up[bit][index]
distance >>= 1
bit += 1
return index
def lca(a, b):
if depth[a] < depth[b]:
a, b = b, a
a = lift(a, depth[a] - depth[b])
if a == b:
return a
for k in range(log - 1, -1, -1):
if up[k][a] != up[k][b]:
a = up[k][a]
b = up[k][b]
return parent[a]
def build_path(a, b):
c = lca(a, b)
if c == -1:
return []
left_part = []
x = a
while x != c:
left_part.append(nodes[x][0])
x = parent[x]
left_part.append(nodes[c][0])
right_part = []
x = b
while x != c:
right_part.append(nodes[x][0])
x = parent[x]
return left_part + right_part[::-1]
answers = []
for query in queries:
u, v = query[0], query[1]
if u not in value_to_index or v not in value_to_index:
answers.append([])
else:
answers.append(build_path(value_to_index[u], value_to_index[v]))
return answersTime complexity: Preprocessing is O(n log n). Each query is O(log n + L), where L is the length of the returned path.. Space complexity: O(n log n) for the binary-lifting table, plus O(n) for parent, depth, and value-index maps..
Hints
- Precompute each node parent and depth. Then binary lifting can find LCAs in O(log n) time.
- Even with fast LCA, outputting the path still costs time proportional to the number of nodes in that path.
Part 4: Robust Path Queries with Duplicate Values and Deep Trees
Constraints
- 0 <= len(nodes) <= 100000
- Node values may be duplicated.
- If nodes is non-empty, node 0 is the root.
- Each child index is either -1 or a valid node index.
- Only nodes reachable from root index 0 are considered present in the tree.
- The implementation should avoid recursion.
Examples
Input: ([[1, 1, 2], [2, 3, -1], [2, -1, 4], [3, -1, -1], [3, -1, -1]], 3, 4)
Expected Output: [3, 2, 1, 2, 3]
Explanation: Values 2 and 3 are duplicated, but node indices make the endpoints unambiguous.
Input: ([[1, 1, 2], [2, 3, -1], [2, -1, 4], [3, -1, -1], [3, -1, -1]], 2, 2)
Expected Output: [2]
Explanation: When both endpoint indices are the same, return that single node value.
Input: ([[1, 1, 2], [2, 3, -1], [2, -1, 4], [3, -1, -1], [3, -1, -1]], 0, 4)
Expected Output: [1, 2, 3]
Explanation: Node 0 is an ancestor of node 4.
Input: ([[1, 1, 2], [2, 3, -1], [2, -1, 4], [3, -1, -1], [3, -1, -1]], -1, 4)
Expected Output: []
Explanation: Index -1 is not a valid endpoint.
Input: ([[0, 1, -1], [1, 2, -1], [2, 3, -1], [3, 4, -1], [4, 5, -1], [5, -1, -1]], 5, 0)
Expected Output: [5, 4, 3, 2, 1, 0]
Explanation: A skewed tree should be handled iteratively without recursion.
Solution
def solution(nodes, u, v):
n = len(nodes)
if n == 0 or u < 0 or v < 0 or u >= n or v >= n:
return []
parent = {0: -1}
visited = set()
stack = [0]
while stack:
index = stack.pop()
if index in visited:
continue
visited.add(index)
value, left, right = nodes[index]
if right != -1 and 0 <= right < n and right not in parent:
parent[right] = index
stack.append(right)
if left != -1 and 0 <= left < n and left not in parent:
parent[left] = index
stack.append(left)
if u not in visited or v not in visited:
return []
if u == v:
return [nodes[u][0]]
ancestors = set()
x = u
while x != -1:
ancestors.add(x)
x = parent.get(x, -1)
path_from_v_up = []
x = v
lca = -1
while x != -1:
if x in ancestors:
lca = x
break
path_from_v_up.append(x)
x = parent.get(x, -1)
if lca == -1:
return []
path_indices = []
x = u
while x != lca:
path_indices.append(x)
x = parent[x]
path_indices.append(lca)
path_indices.extend(reversed(path_from_v_up))
return [nodes[index][0] for index in path_indices]Time complexity: O(n) per call, where n is the number of nodes, plus O(L) to produce the returned path of length L.. Space complexity: O(n) for parent pointers, visited nodes, and ancestor tracking..
Hints
- Use an explicit stack to traverse from the root and build parent pointers for reachable nodes.
- After parent pointers are available, find the first common ancestor by walking upward from one endpoint.