Trees and Binary Search Trees Questions
Hierarchical structures: binary trees, binary search trees, balanced trees, and tries. Covers traversal orders (in/pre/post-order, level-order), insertion and deletion invariants, and using tree properties to achieve logarithmic search. A core mid-difficulty interview area and the basis for many indexing and lookup systems.
Design a concurrent singly linked list for a high-throughput in-memory queue used by ingestion workers. Discuss lock-based vs lock-free approaches, per-node vs coarse-grained locking, memory reclamation (hazard pointers/epoch), and how you would test for correctness under concurrency.
Sample Answer
Requirements & constraints:
- In-memory high-throughput FIFO queue used by many ingestion workers (multiple producers, multiple consumers), low latency, high ops/sec, safe memory reclamation, bounded/unbounded options.
High-level choice:
- I’d prefer a lock-free Michael-Scott (MS) queue variant for max throughput and low contention; fall back to fine-grained locking only if hardware/GC constraints make lock-free unsafe.
Lock-based vs lock-free:
- Lock-based (coarse-grained mutex around head/tail or single global lock): simple, correct, but serializes operations and becomes a bottleneck under high concurrency.
- Fine-grained locking (per-end locks or per-node hand-over-hand): reduces contention, easier memory model than lock-free, but still can suffer from lock overhead and deadlock risk if misused.
- Lock-free (CAS-based MS queue): provides excellent throughput and progress guarantees (usually lock-free, not wait-free). More complex but avoids convoying; tolerates skewed workloads.
Per-node vs coarse-grained locking:
- Coarse lock: one mutex for entire queue — simplest; acceptable for low concurrency.
- Per-end locks: separate enqueue/dequeue locks (two-lock queue) gives much better parallelism with simple semantics.
- Per-node locking: heavy complexity; rarely needed vs lock-free alternative.
Memory reclamation:
- In languages without GC (C/C++), must avoid ABA and use safe reclamation:
- Hazard Pointers: threads publish pointers they access; retired nodes reclaimed when no hazard pointer references them. Deterministic but requires careful pool sizing.
- Epoch-based reclamation (EBR): threads enter/exit epochs; nodes reclaimed once all threads advanced. Lower per-op overhead; slightly higher reclamation latency.
- For MS-queue choose EBR for throughput or hazard pointers if real-time reclamation bounds are required. Also use tagged pointers or version counters to prevent ABA on pointer CAS.
Implementation notes:
- Use atomic pointers with pointer+counter (double-word CAS if available) for ABA mitigation.
- Enqueue: CAS tail->next; then CAS tail update.
- Dequeue: read head->next; CAS head; on success return payload and retire old head.
- Provide optional batching or cursor pooling for ingestion workloads.
Correctness testing under concurrency:
- Unit tests for single-thread semantics.
- Stress tests: many producers/consumers, random sleep/yield, randomized op mixes, long-running runs to expose races.
- Deterministic replay: record interleavings where possible using thread schedulers (e.g., Chess, rr, or custom scheduler) to reproduce bugs.
- Formal tools: model-check small-state versions with TLA+/PlusCal or CBMC.
- Memory-reclamation tests: allocate sentinel IDs in nodes, ensure reclaimed nodes are not reused while another thread still "observes" them; run with ASAN/TSAN/UBSAN to detect memory and data races.
- Performance tests: measure throughput/latency with varying thread counts, compare lock-free vs two-lock variants, observe scalability curves.
Trade-offs summary:
- Lock-free MS queue + EBR is my default for high-throughput ingestion: best scalability, moderate complexity, well-understood patterns.
- If implementation environment has GC (JVM/Go), lock-free structures are simpler since reclamation is handled; still use atomic CAS and consider padding/false-sharing mitigation.
- If team velocity/maintainability is priority and concurrency modest, use two-lock queue.
Design and implement a compressed prefix tree (radix / PATRICIA trie) in Python that supports insert(word), search(word), and starts_with(prefix). Focus on memory efficiency and speed when storing millions of short strings (e.g., 10M keys). Discuss trade-offs versus a hash table for prefix queries and how to reduce memory overhead in Python.
Sample Answer
To build a memory-efficient compressed prefix tree (radix/PATRICIA trie), we store edges as string labels (not single chars) and children in compact dicts; mark terminal nodes. I'll show a simple, performant Python implementation with focus on minimal per-node overhead and techniques to scale to ~10M short keys.
Approach:
- Each node stores: edge label -> child mapping, and is_word flag.
- On insert/search, walk matching prefixes of edge labels; split edges when partial match occurs (radix behavior).
- Use Python dict for children (can be optimized later with arrays/ints or external stores).
class Node:
__slots__ = ("children","is_word")
def __init__(self):
self.children = {} # label -> Node
self.is_word = False
class RadixTrie:
def __init__(self):
self.root = Node()
def insert(self, word):
node = self.root
i = 0
while i < len(word):
for label, child in list(node.children.items()):
# find common prefix between remaining word and edge label
common = 0
maxlen = min(len(label), len(word)-i)
while common < maxlen and label[common] == word[i+common]:
common += 1
if common == 0:
continue
if common == len(label):
node = child
i += common
break
# split edge
remaining_label = label[common:]
new_child = Node()
new_child.children[remaining_label] = child
new_child.is_word = False
node.children[label[:common]] = new_child
del node.children[label]
node = new_child
i += common
break
else:
# no edge with common prefix; add remainder as new edge
node.children[word[i:]] = Node()
node = node.children[word[i:]]
i = len(word)
node.is_word = True
def search(self, word):
node = self.root
i = 0
while i < len(word):
for label, child in node.children.items():
if word.startswith(label, i):
i += len(label)
node = child
break
# partial matches imply not present
if label.startswith(word[i]) and word[i:].startswith(label[:1]):
# but if partial prefix mismatch -> not found
return False
else:
return False
return node.is_word
def starts_with(self, prefix):
node = self.root
i = 0
while i < len(prefix):
for label, child in node.children.items():
common = 0
maxlen = min(len(label), len(prefix)-i)
while common < maxlen and label[common] == prefix[i+common]:
common += 1
if common == 0:
continue
if common == len(label):
node = child
i += common
break
if common == len(prefix)-i:
return True
return False
else:
return False
return True
Key concepts:
- Edge-label compression reduces node count and pointer overhead vs char-trie.
- slots reduces per-node dict overhead.
- Using shared interned strings (via sys.intern) reduces duplicate label memory.
Complexity:
- Insert/search: O(k) where k = length of word, but constant factors from string compares.
- Space: much lower than char-trie; still higher than a raw hash table per key but supports prefix queries.
Trade-offs vs hash table:
- Hash table: O(1) exact-match, minimal code, lower per-key metadata; cannot do efficient prefix queries without extra indexing (e.g., storing all prefixes, which multiplies memory).
- Radix trie: Built-in prefix search, ordered traversal, shared prefixes reduce storage when many common prefixes exist. Higher per-node overhead and more complex code.
Memory optimizations for Python at scale (~10M keys):
- Use slots to cut instance dicts.
- Intern repeated labels with sys.intern.
- Store child mappings in lightweight structures: use arrays with index compression or a custom C extension; or persist trie in a compact on-disk format (e.g., marisa-trie, DAWG) or use existing libs (marisa-trie, pygtrie).
- Batch insertions and compactify: merge single-child chains into longer labels.
- Consider representing nodes as tuples and storing in arrays (numpy/struct) or using mmap'd serialized tries.
Edge cases:
- Empty string keys, overlapping splits, unicode handling (normalize inputs).
This design balances memory and fast prefix operations; for absolute minimal RAM at 10M keys use specialized compact tries (marisa, DAWG) or move structure off-heap (C extension, RocksDB-like prefix indexing).
Given a binary tree, find the subtree with maximum sum of node values. Implement a Python function that returns the root of the maximum-sum subtree and the sum. Use a single-pass recursion and explain how you avoid recomputing subtree sums.
Sample Answer
To solve this, perform a post-order traversal that returns the sum of the subtree rooted at each node. Each node computes its subtree sum once from the sums of left and right children (no recomputation). Track the max sum and corresponding node in a nonlocal/global variable during the single recursion.
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
def find_max_sum_subtree(root):
"""
Returns (max_root, max_sum) where max_root is the root node of the subtree
with the maximum sum and max_sum is that sum.
Single-pass post-order recursion: O(n) time, O(h) recursion space.
"""
if root is None:
return (None, 0)
max_sum = float('-inf')
max_root = None
def postorder(node):
nonlocal max_sum, max_root
if node is None:
return 0
# compute left and right subtree sums once each
left_sum = postorder(node.left)
right_sum = postorder(node.right)
total = node.val + left_sum + right_sum
# update global max if needed
if total > max_sum:
max_sum = total
max_root = node
return total
postorder(root)
return (max_root, max_sum)
Key points:
- Approach: post-order ensures child sums are computed before parent; each node's subtree sum is computed exactly once.
- Avoid recomputation: function returns subtree sum upward; no repeated traversal or re-aggregation.
- Time complexity: O(n) visiting each node once.
- Space complexity: O(h) recursion stack (h = tree height), worst-case O(n).
- Edge cases: all-negative values (works because max_sum initialized to -inf), empty tree returns (None, 0) per implementation (adjustable to (None, -inf) if preferred).
- Alternative: iterative post-order with stack or compute using memoization — but the single recursive pass is simplest and optimal.
Given a binary tree, implement a level-order traversal function that returns node values grouped by depth (list of lists) using BFS and a queue. Implement in Python and explain how to handle very wide levels (millions of nodes) in terms of memory.
Sample Answer
Approach: use BFS with a queue (collections.deque). For each level, record current queue length, pop that many nodes, collect their values and push children. For very wide levels (millions of nodes) avoid holding all values in memory—emit levels as generators, stream batches to disk or downstream (e.g., write to S3, append to a DB, or process in fixed-size chunks).
from collections import deque
from typing import Optional, List, Iterator
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 level_order(root: Optional[TreeNode]) -> List[List[int]]:
if not root:
return []
q = deque([root])
result = []
while q:
level_size = len(q)
level_vals = []
for _ in range(level_size):
node = q.popleft()
level_vals.append(node.val)
if node.left: q.append(node.left)
if node.right: q.append(node.right)
result.append(level_vals)
return result
# Memory-friendly generator variant that yields each level (streaming)
def level_order_stream(root: Optional[TreeNode]) -> Iterator[List[int]]:
if not root:
return
q = deque([root])
while q:
level_size = len(q)
level_vals = []
for _ in range(level_size):
node = q.popleft()
level_vals.append(node.val)
if node.left: q.append(node.left)
if node.right: q.append(node.right)
yield level_vals
Key points:
- Time: O(n), Space: O(w) where w = max width (queue). The result version also stores all levels.
- For very wide levels: prefer level_order_stream and process each yielded level in batches (e.g., chunk into fixed-size lists, write to external storage, or push to a streaming system like Kafka). If necessary, avoid creating a full level_vals list: process nodes one-by-one as you pop them (e.g., serialize value immediately), keeping only minimal per-node memory.
Explain how tries (prefix trees) extend the tree concept. For a dataset of textual keys (file paths, topic names), describe memory/compression strategies (radix trees, finite-state transducers), and implement a simple trie insert and prefix-search in Python. Discuss trade-offs for very large alphabets.
Sample Answer
Tries (prefix trees) extend the tree concept by storing keys as paths: each edge represents a character (or token) and each node represents a prefix. This makes common prefixes shared, giving O(L) lookup/insert for key length L independent of number of keys.
Memory / compression strategies:
- Radix tree (compressed trie): collapse chains of single-child nodes into one edge labeled by a substring — reduces nodes and pointer overhead for sparse branches (good for file paths).
- Double-array trie / LOUDS: compact array-based representations for memory locality and fast traversal.
- Finite-State Transducers (FSTs): deterministic automata that map input strings to outputs (e.g., IDs) with strong compression by merging equivalent suffixes; excellent for static dictionaries (used in search engines).
- Path/edge encoding & front-coding: store only differing suffixes for sorted lists (useful in on-disk storage).
- Use integer-coded alphabets and bit-packed children or hash maps per node for large alphabets.
Simple Python trie (insert + prefix search):
class TrieNode:
def __init__(self):
self.children = {} # dict: char -> TrieNode
self.is_word = False
class Trie:
def __init__(self):
self.root = TrieNode()
def insert(self, word):
node = self.root
for ch in word:
node = node.children.setdefault(ch, TrieNode())
node.is_word = True
def starts_with(self, prefix):
# return all words with given prefix (DFS)
node = self.root
for ch in prefix:
if ch not in node.children:
return []
node = node.children[ch]
results = []
def dfs(n, path):
if n.is_word:
results.append(prefix + path)
for c, child in n.children.items():
dfs(child, path + c)
dfs(node, "")
return results
Complexity:
- Insert/lookup: O(L) time, O(sum of nodes * overhead) space.
- Compressed tries (radix/FST) reduce node count but add complexity for partial-match logic and updates. For very large alphabets, per-node maps are expensive; alternatives:
- use arrays when alphabet small (faster, denser)
- use sparse maps or sorted vectors + binary search when memory constrained
- encode tokens (e.g., path components) instead of characters to reduce depth
Trade-offs: choose compression for static read-heavy datasets (FSTs/radix). For write-heavy or dynamic data, prefer simpler tries with hashed children or sharding by prefix.
Unlock Full Question Bank
Get access to all Trees and Binary Search Trees interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.