← Back to DevBytes

Interview Guide: B-Trees Problems and Solutions

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):

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:

B-Tree Node Structure and Properties

We will work with a B-Tree of minimum degree t. Each node stores:

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:

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:

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

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.

πŸš€ Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started β€” $23.99/mo
← Back to all articles