Coding exercise for a Software Engineer role
You receive the root of a binary tree (a general binary tree, not necessarily a BST) along with a value x. Determine the number of tree nodes for which node.val == x.
The prompt has two stages. The first stage is a basic traversal. The second is the substantive interview discussion: improving production performance through parallel work, handling concurrent mutations, and optimizing a single-threaded implementation.
from typing import Optional
class TreeNode:
def __init__(self, val: int, left: "Optional[TreeNode]" = None,
right: "Optional[TreeNode]" = None):
self.val = val
self.left = left
self.right = right
def count_value(root: Optional[TreeNode], x: int) -> int:
"""
Count nodes whose val is equal to x.
No BST ordering property may be assumed for this tree.
"""
pass
5
/ \
4 5
/ \ \
5 1 5
count_value(root, 5) -> 4
count_value(root, 1) -> 1
count_value(root, 8) -> 0
There are four nodes containing 5, exactly one containing 1, and no node whose value is 8.
Since this is not a BST, no branch can be skipped; each node must be examined once. A recursive traversal expresses that directly:
def count_value(root: Optional[TreeNode], x: int) -> int:
if root is None:
return 0
return (1 if root.val == x else 0) \
+ count_value(root.left, x) \
+ count_value(root.right, x)
Complexity:
n equal to the number of nodes, because each node is processed once.h is the height of the tree. This is O(log n) for a balanced tree and O(n) for a skewed one.For an extremely deep tree, such as a 10^5-node chain, recursion may overflow the call stack. Use an explicit stack instead:
def count_value(root: Optional[TreeNode], x: int) -> int:
if root is None:
return 0
count = 0
stack = [root]
while stack:
node = stack.pop()
if node.val == x:
count += 1
if node.left: stack.append(node.left)
if node.right: stack.append(node.right)
return count
Complexity: It remains O(n) time and O(h) auxiliary space, except that the active frontier resides on the heap rather than in the language call stack.
Do not spend too long on Part 1. Using 10 minutes on this portion leaves insufficient time for the follow-ups, which are the main evaluation target.
Once Part 1 is complete, three increasingly involved questions follow:
Each question explores a separate aspect of the design.
Approach. A binary tree lends itself to divide-and-conquer because its left and right branches can be processed independently. Evaluate both branches concurrently, then add their counts.
from concurrent.futures import ThreadPoolExecutor
# Keep one reusable pool; creating one for each invocation causes costly thread churn.
_POOL = ThreadPoolExecutor(max_workers=8)
def count_value_parallel(root, x, depth_cutoff=3):
if root is None:
return 0
if depth_cutoff <= 0:
# Use sequential work past this depth, since task-management overhead
# outweighs any benefit for small subtrees.
return count_value(root, x)
left_future = _POOL.submit(count_value_parallel, root.left, x, depth_cutoff - 1)
right_count = count_value_parallel(root.right, x, depth_cutoff - 1)
return (1 if root.val == x else 0) + left_future.result() + right_count
Important points to state explicitly:
2^depth_cutoff - 1 tasks may be active in the code above. When that number is greater than max_workers, workers may block in result() while queued tasks cannot start, producing the familiar thread-pool-recursion deadlock. As a guideline, keep 2^depth_cutoff <= max_workers, or select a work-stealing executor that safely supports this pattern.num_workers is reached. A chain-like, skewed tree gains no speedup, because one child branch always contains all remaining work.ThreadPoolExecutor offers little benefit. Java, C++, Go, and Rust provide genuine parallel execution here. In Python, consider multiprocessing or a fork/join facility, or clearly acknowledge this restriction.ForkJoinPool with RecursiveTask and Rust's rayon::join are canonical choices; their work stealing also avoids the pool-starvation issue. Mentioning them is useful.The earlier solution assumes the tree is not modified while counting. If another thread can insert, delete, or rebalance nodes, an unprotected DFS may observe inconsistent state: a released node, a pointer altered during a read, or a subtree attached twice.
The most straightforward correct design is to give counters a read lock and mutators a write lock.
class Tree:
def __init__(self, root):
self.root = root
# threading.Lock is only a mutex; use a library implementation
# (readerwriterlock) or build a small counting lock for true RW behavior.
self.rw = ReaderWriterLock()
def count(self, x):
with self.rw.read_lock():
return count_value(self.root, x)
def insert(self, val):
with self.rw.write_lock():
_insert_unlocked(self.root, val)
Attach a lock to each node. A counter locks its current node, locks the child before moving downward, and then unlocks the parent. Mutating operations follow the same discipline.
When reads are common and writes are infrequent or grouped, use an immutable persistent tree or a copy-on-write snapshot. A counter loads a snapshot root once and walks it without locks, while writers atomically publish a replacement root.
# Pseudocode
def count(root_ref, x):
root = root_ref.load() # atomically fetch the current snapshot once
return count_value(root, x) # the rest of the walk requires no locking
A strong response presents the progression: "I would begin with a whole-tree reader-writer lock because it is the simplest correct design. If read contention became limiting, I would consider hand-over-hand locking, though it is costly. For a read-heavy workload, which this counting query suggests, copy-on-write snapshots provide lock-free reads and would be my production choice."
The final question removes the multithreading option. Improve the single-threaded path instead. Strong responses include the following:
If count_value is requested repeatedly for different x values, make one O(n) preprocessing pass to construct a value -> count hash map. Every later lookup then costs O(1).
from collections import Counter
class ValueIndex:
def __init__(self, root):
self.counts = Counter()
self._dfs(root)
def _dfs(self, node):
if node is None:
return
self.counts[node.val] += 1
self._dfs(node.left)
self._dfs(node.right)
def query(self, x):
return self.counts[x]
k is the count of unique values.When x remains constant while occasional mutations occur, maintain subtree_count[node] for that target. A change only invalidates the route from the changed node to the root, requiring O(h) work instead of O(n).
This removes the recursive stack completely and uses O(1) extra space without threads. It has a slightly larger per-node constant cost, but it cannot overflow the stack on a pathologically deep tree.
When you control how the tree is stored, place nodes in a contiguous array using BFS order or an Euler tour. Scanning sequential memory avoids pointer chasing, maximizes cache-line use, and makes the equality test easy to SIMD-vectorize.
# A linearized tree can be represented as an array of values.
def count_linear(values, x):
return sum(1 for v in values if v == x)
# In C/C++, this can become a vectorized loop that checks 8-16 integers per instruction.
This can be faster than the parallel tree traversal in Follow-Up 1, since memory bandwidth and branch prediction, rather than available CPU cores, are often the limiting factors for such a simple query. Recognizing that trade-off is the intended insight.
When most requests search for values absent from the tree, a root-level Bloom filter can answer "definitely not present" in O(1), avoiding the traversal altogether. It is useful only when negative lookups are frequent.
The hiring-manager sequence highlights three main lessons:
State these three observations clearly, and you have addressed the hiring-manager-level question regardless of the exact implementation you chose.