Question: Path between two nodes of an implicit Fibonacci tree
Companies: Databricks
Difficulty: Hard
Problem statement
A Fibonacci tree of order k is a binary tree defined recursively:
- Orders
0and1are a single node. - For
k >= 2, the root's left subtree is a Fibonacci tree of orderk - 2and its right subtree is a Fibonacci tree of orderk - 1.
The nodes of a tree of order n are labeled 0, 1, 2, ... in preorder (root, then the left subtree, then the right subtree).
Implement:
def find_path(order: int, start: int, dest: int) -> str
Return the shortest sequence of moves from node start to node dest, where "U" moves to the parent, "L" to the left child and "R" to the right child. Return "" when start == dest, and raise an error if a label is not in the tree.
Constraints
-
0 <= order <= 200. The tree has an exponential number of nodes, so you cannot build it. -
startanddestare labels of the tree of that order.
Example 1. Order 3 has 5 nodes: 0 is the root, 1 is its left child (a leaf), 2 is its right child, and 3, 4 are the left and right children of 2.
-
find_path(3, 1, 4)returns"URR". -
find_path(3, 0, 3)returns"RL".
Example 2. Order 4 has 9 nodes; the left subtree holds labels 1..3 and the right subtree holds 4..8.
-
find_path(4, 2, 8)returns"UURRR".
Follow-ups may ask for the list of node labels on the path, the distance only, or the same question with inorder labels.
Approach
This is LeetCode 2096 ("directions from one node to another") on a tree you are not allowed to build. A Fibonacci tree of order n has about phi^n nodes, so for n = 80 there are more nodes than atoms you can afford to allocate. The labels are preorder numbers, and preorder numbers plus subtree sizes tell you everything about where a node sits.
Brute force. Build the tree recursively, assign preorder labels, store parent pointers, find both nodes, and walk up from each to the root to find the lowest common ancestor (LCA). That is O(size of tree) time and memory, which is exponential in n. It is a good way to check your understanding of the shape on small n, and a good thing to say first, but it fails as soon as n grows past about 30.
Key fact: subtree sizes follow a Fibonacci-like recurrence.
size(0) = size(1) = 1
size(k) = 1 + size(k - 2) + size(k - 1)
In a preorder labeling of a subtree of order k whose root has label base:
- the root is
base; - the left subtree (order
k - 2) holds labelsbase + 1 .. base + size(k - 2); - the right subtree (order
k - 1) starts atbase + 1 + size(k - 2).
So to find a label x, start at the root with base = 0 and order n. If x == base, stop. If x falls in the left range, record L, move to base + 1, order k - 2. Otherwise record R, move to base + 1 + size(k - 2), order k - 1. Each step lowers the order by at least 1, so the path from the root is at most n moves long.
Optimal method.
- Precompute
size(0..n)once (O(n)). - Compute the root-to-node move string for
startand fordest. - The LCA is the end of their longest common prefix of moves (same reasoning as 2096: two root paths agree exactly until they split).
- The answer is
"U"once for every move left in thestartpath after the prefix, followed by the rest of thedestpath.
If you are asked for the node labels on the path instead of moves, record the label at each step of the descent and stitch: start path reversed up to the LCA, then the dest path below it.
Invariants.
- During the descent,
base <= x < base + size(order); the target is always inside the current subtree. -
orderdrops by 1 or 2 per step, so depth is at mostn. - Labels are never stored for anything off the two root paths.
Edge cases to state up front.
-
start == destreturns the empty string. - One node is an ancestor of the other: the answer is all
Us or has noUs. - Labels outside
[0, size(n))raiseValueError. - Orders 0 and 1 are a single node (label
0). - Sizes grow exponentially; Python integers are unbounded, but in Java or C++ say that
size(n)overflows 64 bits nearn = 90, so either capnor use big integers.
Code
from typing import List
class FibonacciTree:
"""Implicit Fibonacci tree of a given order with preorder labels from 0.
Order 0 and order 1 are single nodes. For k >= 2 the tree of order k has
left subtree of order k - 2 and right subtree of order k - 1.
"""
def __init__(self, order: int) -> None:
if order < 0:
raise ValueError("order must be >= 0")
self.order = order
self.size: List[int] = [1] * max(2, order + 1)
for k in range(2, order + 1):
self.size[k] = 1 + self.size[k - 2] + self.size[k - 1]
def node_count(self) -> int:
return self.size[self.order]
def _descend(self, label: int) -> tuple[str, List[int]]:
"""Return (moves from root, labels visited from root) for `label`."""
if not 0 <= label < self.node_count():
raise ValueError(f"label {label} is not in the tree")
moves: List[str] = []
labels: List[int] = [0]
base, k = 0, self.order
while label != base:
# Only orders >= 2 have children; the range check guarantees it.
left_size = self.size[k - 2]
if label <= base + left_size:
moves.append("L")
base, k = base + 1, k - 2
else:
moves.append("R")
base, k = base + 1 + left_size, k - 1
labels.append(base)
return "".join(moves), labels
def directions(self, start: int, dest: int) -> str:
"""Moves from start to dest: 'U' = to parent, 'L'/'R' = to child."""
a, _ = self._descend(start)
b, _ = self._descend(dest)
p = 0
while p < len(a) and p < len(b) and a[p] == b[p]:
p += 1
return "U" * (len(a) - p) + b[p:]
def path_labels(self, start: int, dest: int) -> List[int]:
"""Node labels on the path from start to dest, both included."""
a, la = self._descend(start)
b, lb = self._descend(dest)
p = 0
while p < len(a) and p < len(b) and a[p] == b[p]:
p += 1
# la[p] == lb[p] is the LCA.
return la[p:][::-1] + lb[p + 1:]
def distance(self, start: int, dest: int) -> int:
return len(self.directions(start, dest))
def find_path(order: int, start: int, dest: int) -> str:
return FibonacciTree(order).directions(start, dest)
if __name__ == "__main__":
# Order 3: 0 root; 1 = left leaf; 2 = right child with children 3 and 4.
assert FibonacciTree(3).node_count() == 5
assert find_path(3, 1, 4) == "URR"
assert find_path(3, 3, 4) == "UR"
assert find_path(3, 0, 3) == "RL"
assert find_path(3, 4, 0) == "UU"
assert find_path(3, 2, 2) == ""
# Order 4: left subtree 1..3, right subtree 4..8.
t4 = FibonacciTree(4)
assert t4.node_count() == 9
assert t4.directions(2, 8) == "UURRR"
assert t4.path_labels(2, 8) == [2, 1, 0, 4, 6, 8]
assert t4.path_labels(8, 8) == [8]
assert t4.distance(5, 7) == 3
assert find_path(0, 0, 0) == "" and find_path(1, 0, 0) == ""
# Large order: never builds the tree.
big = FibonacciTree(200)
last = big.node_count() - 1
assert big.directions(0, last) == "R" * 199
assert big.directions(1, last) == "U" + "R" * 199
try:
find_path(3, 0, 5)
raise AssertionError("expected ValueError")
except ValueError:
pass
print("all tests passed")
Complexity
- Precompute sizes: O(n) time, O(n) space (big integers aside; each
size(k)has O(k) bits). -
_descend: at mostnsteps, so O(n) time and O(n) space for the move list. -
directions,path_labels,distance: two descents plus a prefix scan, O(n) time and O(n) space. - The brute force is O(phi^n) time and memory, which is the point of the question.
Test cases
- Order 3 (labels: 0 root, 1 left leaf, 2 right child, 3 and 4 its children):
-
find_path(3, 1, 4)->"URR" -
find_path(3, 3, 4)->"UR"(siblings) -
find_path(3, 0, 3)->"RL"(start is the ancestor; noU) -
find_path(3, 4, 0)->"UU"(dest is the ancestor; onlyU) -
find_path(3, 2, 2)->""
-
- Order 4 (left subtree 1..3, right subtree 4..8):
-
directions(2, 8)->"UURRR" -
path_labels(2, 8)->[2, 1, 0, 4, 6, 8] -
distance(5, 7)->3
-
- Orders 0 and 1:
find_path(0, 0, 0)->"". - Order 200: root to the last label ->
"R" * 199, computed instantly without building anything. - Label out of range, e.g.
find_path(3, 0, 5)->ValueError.
Follow-ups
-
Return the node labels, not moves. Record
baseat each descent step and stitch the two root paths at the LCA (path_labelsabove). Same O(n). -
Return only the distance.
len(a) + len(b) - 2 * p. You can skip building strings and compare the two descents in lockstep, stopping the shared walk at the first split, which keeps O(1) extra space beyond the size table. -
Inorder or postorder labels. Only the range arithmetic changes. Inorder: the left subtree is
base .. base + size(k-2) - 1, the root isbase + size(k-2), the right subtree follows. Postorder: left first, then right, then the root is the last labelbase + size(k) - 1. The LCA and stitching logic are unchanged. -
Many queries on the same order. Keep the size table and answer each query in O(n). If queries hit the same nodes repeatedly, cache root paths per label. For
qqueries the total is O(n + q * n). -
Generalize the shape (for example left = order
k - 1, right = orderk - 2, or a k-ary "Fibonacci" tree). Any recursive tree whose subtree sizes depend only on the order works the same way: precompute sizes per order and descend by comparing the label to child ranges.
Interviewer expectations
Mid-level (L4)
- Draws a small tree, labels it in preorder, and sees that a subtree's labels are a contiguous range.
- Derives the size recurrence and the root-to-node descent.
- Reuses the 2096 idea: common prefix of root paths, then
Us plus the rest of the dest path. Handlesstart == dest.
Senior (L5)
- Explains up front why building the tree fails (exponential size) and why depth is O(n).
- Gives clean O(n) code with input validation, ancestor cases, and orders 0 and 1 handled.
- Answers the label-path and distance follow-ups without restructuring the code.
Staff (L6+)
- Separates the shape (size table, child ranges) from the traversal order, so inorder or postorder is a small change to one function.
- Raises integer overflow in fixed-width languages and how to bound it.
- Talks about batched queries, caching, and how the same technique applies to any implicit recursive structure (segment-tree indexing, rope offsets).