What Are B-Trees?
A B-Tree is a self-balancing tree data structure designed to maintain sorted data and allow operations like search, insertion, and deletion in logarithmic time. Unlike a binary search tree, each node in a B-Tree can hold many keys and have more than two children. It generalizes the binary tree concept, making it ideal for systems that read and write large blocks of data, such as databases and file systems.
Key properties of a B-Tree of minimum degree t (where t β₯ 2):
- Every node contains at most 2t - 1 keys and, if internal, at most 2t children.
- Every node (except the root) contains at least t - 1 keys and at least t children if it is an internal node.
- The root may have as few as 1 key and 2 children if it is internal, or be a leaf with 0 keys when the tree is empty.
- All leaves appear at the same depth, ensuring the tree remains perfectly balanced.
- Keys inside a node are stored in increasing order, and for any internal node, the keys separate the ranges of keys in its subtrees.
Why B-Trees Matter in Interviews
π Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →B-Trees are the backbone of database indexing (e.g., B+Tree variants in MySQL, PostgreSQL) and file systems (like HFS+, NTFS, and Btrfs). Interviewers use BβTree problems to test:
- Recursive thinking and pointer manipulation β operations like insertion and deletion require splitting, merging, and redistributing keys across multiple levels.
- Understanding of disk-based models β even if code runs in memory, explaining the motivation (minimizing disk I/O) shows system design awareness.
- Edge-case handling β root splits, underflow situations, and maintaining invariants after deletions.
- Algorithm adaptation β many classic BST problems (k-th smallest, range queries) must be adapted to multi-key nodes.
B-Tree Node Structure and Properties
We will work with a B-Tree of minimum degree t. Each node stores:
leaf: boolean indicating whether it is a leaf.keys: a list of keys (integers for simplicity) in sorted order.children: a list of child nodes (empty for leaves).n: the current number of keys (can be derived fromlen(keys)).
class BTreeNode:
def __init__(self, t, leaf=False):
self.t = t # minimum degree
self.leaf = leaf
self.keys = []
self.children = []
def __repr__(self):
return f"Node(leaf={self.leaf}, keys={self.keys})"
The B-Tree itself holds a reference to the root and the minimum degree t. We will build operations as methods of a BTree class.
Core Operations and Implementation
Search
Searching a B-Tree is similar to searching a BST but we must find the correct child pointer within a node. Since keys are sorted, we can use a linear scan or binary search to locate the first key greater than or equal to the search key, then decide whether to stop or recurse into the corresponding child.
def search(self, key):
"""Return (node, index) if key found, else None."""
return self._search_node(self.root, key)
def _search_node(self, node, key):
i = 0
while i < len(node.keys) and key > node.keys[i]:
i += 1
if i < len(node.keys) and key == node.keys[i]:
return (node, i)
if node.leaf:
return None
return self._search_node(node.children[i], key)
Time complexity: O(t logt n) for the linear scan inside a node, or O(log t Β· logt n) if using binary search. Since t is constant in practice, this is effectively O(log n) disk reads.
Insertion
Insertion follows a single-pass, top-down approach to avoid multiple splits on the way back up. The key is always inserted into a leaf, but we preemptively split any full node we encounter while traversing down. A full node has 2t - 1 keys. The split operation takes the median key and moves it to the parent, splitting the node into two halves.
We implement two helpers:
_split_child(parent, index)β splits the full childparent.children[index]._insert_nonfull(node, key)β insertskeyinto a node that is guaranteed not full.
def insert(self, key):
root = self.root
# If root is full, split it and create new root
if len(root.keys) == 2 * self.t - 1:
new_root = BTreeNode(self.t, leaf=False)
new_root.children.append(root)
self._split_child(new_root, 0)
self.root = new_root
self._insert_nonfull(new_root, key)
else:
self._insert_nonfull(root, key)
def _insert_nonfull(self, node, key):
i = len(node.keys) - 1
if node.leaf:
# Insert key into sorted position in leaf
node.keys.append(None) # placeholder to increase size
while i >= 0 and key < node.keys[i]:
node.keys[i + 1] = node.keys[i]
i -= 1
node.keys[i + 1] = key
else:
# Find child to descend into
while i >= 0 and key < node.keys[i]:
i -= 1
i += 1
# If child is full, split first
if len(node.children[i].keys) == 2 * self.t - 1:
self._split_child(node, i)
if key > node.keys[i]:
i += 1
self._insert_nonfull(node.children[i], key)
def _split_child(self, parent, idx):
t = self.t
full_child = parent.children[idx]
new_node = BTreeNode(t, leaf=full_child.leaf)
# Median key (t-1 index) from full_child goes up to parent
median_idx = t - 1
median_key = full_child.keys[median_idx]
# Distribute keys and children
new_node.keys = full_child.keys[median_idx + 1:]
full_child.keys = full_child.keys[:median_idx]
if not full_child.leaf:
new_node.children = full_child.children[median_idx + 1:]
full_child.children = full_child.children[:median_idx + 1]
# Insert median into parent
parent.keys.insert(idx, median_key)
parent.children.insert(idx + 1, new_node)
Insertion preserves all B-Tree invariants and runs in O(t logt n) time, touching at most one path from root to leaf.
Deletion
Deletion in a B-Tree is more complex because after removing a key we must ensure no node (except the root) has fewer than t - 1 keys. The algorithm recursively descends, and before entering a child that could become deficient, it either borrows a key from a sibling or merges siblings. The implementation below follows the classic approach: handle leaf deletion, internal node deletion by replacing with predecessor/successor, and recursive underflow management.
def delete(self, key):
if not self.root:
return
self._delete_from_node(self.root, key)
# If root has 0 keys after deletion, shrink tree
if len(self.root.keys) == 0 and not self.root.leaf:
self.root = self.root.children[0]
def _delete_from_node(self, node, key):
t = self.t
i = 0
while i < len(node.keys) and key > node.keys[i]:
i += 1
# Case 1: key found in current node
if i < len(node.keys) and node.keys[i] == key:
if node.leaf:
# Simple removal from leaf
node.keys.pop(i)
else:
# Replace with predecessor (largest key in left child)
if len(node.children[i].keys) >= t:
pred_key = self._get_predecessor(node, i)
node.keys[i] = pred_key
self._delete_from_node(node.children[i], pred_key)
# Or successor (smallest key in right child)
elif len(node.children[i + 1].keys) >= t:
succ_key = self._get_successor(node, i)
node.keys[i] = succ_key
self._delete_from_node(node.children[i + 1], succ_key)
else:
# Merge both children and delete from merged node
self._merge_children(node, i)
self._delete_from_node(node.children[i], key)
return
# Case 2: key not in node, must descend
if node.leaf:
return # key not found
# Ensure child i (where key would be) has at least t keys
if len(node.children[i].keys) < t:
self._fix_child_deficiency(node, i)
self._delete_from_node(node.children[i], key)
def _get_predecessor(self, node, idx):
current = node.children[idx]
while not current.leaf:
current = current.children[-1]
return current.keys[-1]
def _get_successor(self, node, idx):
current = node.children[idx + 1]
while not current.leaf:
current = current.children[0]
return current.keys[0]
def _fix_child_deficiency(self, node, idx):
t = self.t
child = node.children[idx]
# Borrow from left sibling if possible
if idx > 0 and len(node.children[idx - 1].keys) >= t:
left_sibling = node.children[idx - 1]
# Move a key from parent down to child, and borrow from left sibling
child.keys.insert(0, node.keys[idx - 1])
node.keys[idx - 1] = left_sibling.keys.pop(-1)
if not child.leaf:
child.children.insert(0, left_sibling.children.pop(-1))
# Borrow from right sibling if possible
elif idx < len(node.children) - 1 and len(node.children[idx + 1].keys) >= t:
right_sibling = node.children[idx + 1]
child.keys.append(node.keys[idx])
node.keys[idx] = right_sibling.keys.pop(0)
if not child.leaf:
child.children.append(right_sibling.children.pop(0))
else:
# Merge with sibling
if idx > 0:
self._merge_children(node, idx - 1)
else:
self._merge_children(node, idx)
def _merge_children(self, node, idx):
t = self.t
left = node.children[idx]
right = node.children[idx + 1]
# Pull separating key from parent into left child
left.keys.append(node.keys.pop(idx))
left.keys.extend(right.keys)
if not left.leaf:
left.children.extend(right.children)
node.children.pop(idx + 1)
Deletion maintains the invariants and also runs in O(t logt n) time. The helper _fix_child_deficiency guarantees that before descending, the target child has at least t keys, preventing underflow.
Common Interview Problems and Solutions
Problem 1: Validate a B-Tree
Given a tree (root node with children and keys), verify that it satisfies all B-Tree properties for a given minimum degree t. You must check:
- Node key counts (root: 1 β€ keys β€ 2t-1, others: t-1 β€ keys β€ 2t-1).
- Keys within each node are sorted.
- For internal nodes, keys are consistent with children's ranges.
- All leaves are at the same depth.
def is_valid_btree(root, t):
if not root:
return True # empty tree is valid
def check_node(node, low, high, depth, leaf_depth):
# Check key count constraints
if node is root:
if len(node.keys) < 1 or len(node.keys) > 2 * t - 1:
return False
else:
if len(node.keys) < t - 1 or len(node.keys) > 2 * t - 1:
return False
# Check keys are sorted and within allowed range (low, high)
for i, key in enumerate(node.keys):
if i > 0 and key <= node.keys[i - 1]:
return False
if not (low < key < high):
return False
low_for_child = node.keys[i - 1] if i > 0 else low
high_for_child = key
# Leaf depth check
if node.leaf:
if leaf_depth[0] == -1:
leaf_depth[0] = depth
elif leaf_depth[0] != depth:
return False
return True
# Recursively validate children
for i, child in enumerate(node.children):
child_low = node.keys[i - 1] if i > 0 else low
child_high = node.keys[i] if i < len(node.keys) else high
if not check_node(child, child_low, child_high, depth + 1, leaf_depth):
return False
return True
leaf_depth_ref = [-1] # mutable to track first leaf depth
return check_node(root, float('-inf'), float('inf'), 0, leaf_depth_ref)
Problem 2: In-Order Traversal and k-th Smallest Element
In-order traversal visits every key in sorted order. Use recursion: for a node, traverse children and keys in interleaved fashion. To find the k-th smallest, simply traverse and decrement a counter, stopping when it hits zero.
def inorder(node, result):
if not node:
return
for i in range(len(node.keys)):
if not node.leaf:
inorder(node.children[i], result)
result.append(node.keys[i])
if not node.leaf:
inorder(node.children[len(node.keys)], result)
def kth_smallest(root, k):
"""Return k-th smallest key (1-indexed)."""
stack = []
def collect(node):
for i in range(len(node.keys)):
if not node.leaf:
collect(node.children[i])
stack.append(node.keys[i])
if not node.leaf:
collect(node.children[len(node.keys)])
collect(root)
return stack[k - 1] if k <= len(stack) else None
Alternatively, an iterative approach with an explicit stack can yield a proper iterator (see Problem 5).
Problem 3: Serialize and Deserialize a B-Tree
Serialization converts the B-Tree to a string for storage or transmission. A pre-order traversal with children counts works well. We encode each node as: number of keys, followed by keys, then for internal nodes the recursive serialization of each child.
def serialize(root):
"""Serialize to list of integers (keys) with markers."""
if not root:
return []
out = []
def dfs(node):
out.append(len(node.keys)) # number of keys
out.extend(node.keys)
if not node.leaf:
for child in node.children:
dfs(child)
dfs(root)
return out
def deserialize(data, t):
"""Deserialize list back to B-Tree."""
if not data:
return BTreeNode(t, leaf=True)
it = iter(data)
def dfs():
num_keys = next(it)
keys = [next(it) for _ in range(num_keys)]
node = BTreeNode(t, leaf=True)
node.keys = keys
# If node has children, it must be internal (children count = num_keys + 1)
if num_keys > 0: # but root can have children even with 1 key?
# Actually we need to know if it's internal: we can store leaf flag or infer from structure.
# Simpler: store leaf flag in serialization.
pass
# ... (full implementation omitted for brevity, but typical approach adds leaf flag)
For interviews, discuss including a boolean is_leaf marker for each node during serialization to avoid ambiguity.
Problem 4: Range Query (Keys in [L, R])
Perform a range query to collect all keys between low and high. Use a recursive traversal that prunes subtrees using the nodeβs keys as boundaries.
def range_query(node, low, high, result):
if not node:
return
i = 0
# Skip keys less than low
while i < len(node.keys) and node.keys[i] < low:
i += 1
# Process children before keys that could be in range
if not node.leaf:
for j in range(i):
range_query(node.children[j], low, high, result)
# Collect keys in range
while i < len(node.keys) and node.keys[i] <= high:
if node.keys[i] >= low:
result.append(node.keys[i])
if not node.leaf:
range_query(node.children[i], low, high, result)
i += 1
# Process remaining child after last key
if not node.leaf and i <= len(node.keys):
range_query(node.children[i], low, high, result)
Problem 5: B-Tree Iterator (In-Order)
Design an iterator that traverses the B-Tree in-order, yielding keys one by one. Use an explicit stack of (node, index) pairs to simulate recursion.
class BTreeIterator:
def __init__(self, root):
self.stack = []
self._push_left(root)
def _push_left(self, node):
while node:
self.stack.append((node, 0)) # 0 means before processing keys[0]
if not node.leaf:
node = node.children[0]
else:
break
def has_next(self):
return len(self.stack) > 0
def next(self):
while self.stack:
node, idx = self.stack.pop()
if idx < len(node.keys):
key = node.keys[idx]
# Push back node with incremented index
self.stack.append((node, idx + 1))
# After returning this key, we must push the child subtree before the next key
if not node.leaf:
self._push_left(node.children[idx + 1])
return key
else:
# Done with this node, move up
pass
raise StopIteration
The iterator correctly visits all keys in ascending order with O(height) memory.
Best Practices for B-Tree Coding in Interviews
- Clarify the degree/order upfront. Confirm whether the interviewer uses minimum degree t (keys per node: t-1 to 2t-1) or branching factor m. Align your node capacities accordingly.
- Handle the root specially. The root is the only node allowed to have fewer than t-1 keys. Many bugs stem from forgetting this exception.
- Use recursion but watch depth. B-Trees are shallow; recursion depth is safe. However, for very large t, iterative approaches may be preferred in production but are usually fine in interviews.
- Pre-split on insertion. Always split full nodes on the way down to avoid cascading splits. This is a hallmark of correct B-Tree insertion.
- Manage underflow explicitly in deletion. Before descending into a child that could become invalid, borrow or merge. Use well-named helper functions (
_fix_child_deficiency,_merge_children) to keep code clean. - Test edge cases: inserting into empty tree, causing root split, deleting from leaf, deleting root causing shrink, deleting a key that requires predecessor/successor replacement, and operations on the minimum/maximum keys.
- Discuss disk I/O implications. Even if coding in memory, mention that B-Trees minimize disk seeks because a node typically matches a disk page, and the tree height is low.
- Time and space complexity: Search, insertion, deletion all take O(t logt n) time and O(h) recursion stack space, where h = O(logt n).
- Keep code modular. Separate node logic, tree operations, and validation. It makes debugging easier and impresses interviewers with your engineering approach.
Conclusion
B-Trees may seem daunting at first because of their multi-key nodes and complex rebalancing rules. However, they are a natural extension of binary search trees and 2β3β4 trees, and mastering them unlocks a deeper understanding of storage systems and indexing. By breaking down operations into well-defined recursive steps, practicing the classic problems (validation, k-th smallest, serialization, range queries, and iterators), and adhering to the best practices outlined here, you can tackle any B-Tree interview question with confidence. Remember that clarity, correctness, and the ability to explain disk-oriented design are what set top candidates apart.