๐ŸŽ“ A1Learner
๐Ÿ”

DSA A BST Iterator

A BST iterator is an object that returns the values of a BST in sorted order, one per call. A stack inside it holds the path to the smallest value not yet returned.

The problem

A BST iterator is an object that hands out the values of a binary search tree in sorted order, one per call. The method next returns the smallest value not yet returned. The method has_next reports whether any value remains.

Calls can mix in any order. A caller may check has_next between any two next calls, or not at all. The operation script below is the test every design in this article must pass.

The operation script used in this article

class TreeNode:
    def __init__(self, value, left=None, right=None):
        self.value = value
        self.left = left
        self.right = right

tree = TreeNode(8,
                TreeNode(3, TreeNode(1), TreeNode(6, TreeNode(4))),
                TreeNode(10, None, TreeNode(14)))

script = ["has_next", "next", "next", "has_next", "next", "next",
          "next", "next", "next", "has_next"]
expected = [True, 1, 3, True, 4, 6, 8, 10, 14, False]
for step, want in zip(script, expected):
    print("call", step, "->", want)

The tree holds seven values, from 1 to 14. The script drains it fully. The final check answers False, because nothing remains after 14.

Intuition: the values come out in inorder

Sorted order on a BST is inorder order. An inorder walk visits the left subtree, then the node, then the right subtree. The BST rule makes that walk read values from smallest to largest.

An iterator is that walk, paused between values. Each next call resumes the walk for exactly one value. The design problem is how to store a paused walk.

The order the calls must follow

class TreeNode:
    def __init__(self, value, left=None, right=None):
        self.value = value
        self.left = left
        self.right = right

tree = TreeNode(8,
                TreeNode(3, TreeNode(1), TreeNode(6, TreeNode(4))),
                TreeNode(10, None, TreeNode(14)))

order = []
def inorder(node):
    if node is None:
        return
    inorder(node.left)
    order.append(node.value)
    inorder(node.right)
inorder(tree)
print("inorder:", order)
print("inorder is sorted:", order == sorted(order))
print("calls would hand out:", order[0], "then", order[1], "then", order[2])

The sorted check is the evidence. The calls hand out the inorder list from its start. Any storage scheme that reproduces this list, one value at a time, satisfies the spec.

Brute force: the naive design collects everything first

The naive design runs the full inorder walk inside its constructor. It stores every value in a list. A separate index counts how many values have been handed out.

Each call is then trivial. Next reads one list slot and moves the index. Has_next compares the index with the list length.

The list design on the operation script

class TreeNode:
    def __init__(self, value, left=None, right=None):
        self.value = value
        self.left = left
        self.right = right

class ListIterator:
    def __init__(self, root):
        self.values = []
        self.index = 0
        def inorder(node):
            if node is None:
                return
            inorder(node.left)
            self.values.append(node.value)
            inorder(node.right)
        inorder(root)

    def has_next(self):
        return self.index < len(self.values)

    def next(self):
        value = self.values[self.index]
        self.index += 1
        return value

tree = TreeNode(8,
                TreeNode(3, TreeNode(1), TreeNode(6, TreeNode(4))),
                TreeNode(10, None, TreeNode(14)))

iterator = ListIterator(tree)
print("values collected before the first call:", len(iterator.values))
results = []
for step in ["has_next", "next", "next", "has_next", "next", "next",
             "next", "next", "next", "has_next"]:
    if step == "has_next":
        results.append(iterator.has_next())
    else:
        results.append(iterator.next())
print("script results:", results)
print("results match the spec:", results == [True, 1, 3, True, 4, 6, 8, 10, 14, False])

The script passes in full. The cost sits in the first line of the output. All seven values are collected and stored before the first call, even if the caller only ever takes two.

The optimal solution: a stack holds the path to the next value

A stack inside the iterator holds the path from the root down to the smallest value not yet returned, and each next call pops that value and pushes the path into its right subtree. That one sentence is the whole design.

A stack is a list used at one end, where append adds an item and pop removes the most recent one. The top of this stack is always the next value waiting. Has_next is then one length check.

The stack design on the same operation script

class TreeNode:
    def __init__(self, value, left=None, right=None):
        self.value = value
        self.left = left
        self.right = right

class BSTIterator:
    def __init__(self, root):
        self.stack = []
        self.pushes = 0
        self.pops = 0
        self.push_path(root)

    def push_path(self, node):
        while node is not None:
            self.stack.append(node)
            self.pushes += 1
            node = node.left

    def has_next(self):
        return len(self.stack) > 0

    def next(self):
        node = self.stack.pop()
        self.pops += 1
        self.push_path(node.right)
        return node.value

tree = TreeNode(8,
                TreeNode(3, TreeNode(1), TreeNode(6, TreeNode(4))),
                TreeNode(10, None, TreeNode(14)))

iterator = BSTIterator(tree)
results = []
for step in ["has_next", "next", "next", "has_next", "next", "next",
             "next", "next", "next", "has_next"]:
    if step == "has_next":
        results.append(iterator.has_next())
    else:
        results.append(iterator.next())
print("script results:", results)
print("results match the spec:", results == [True, 1, 3, True, 4, 6, 8, 10, 14, False])
print("total pushes:", iterator.pushes, "total pops:", iterator.pops)

The script passes with the same results as the naive design. The counters close the proof. Seven nodes produce exactly seven pushes and seven pops across the whole drain.

Walkthrough: the stack through a full drain

The walkthrough drains the tree and prints the stack after every call. Stack values print bottom first. The top of the stack, printed last, is always the next value waiting.

Stack contents after each next call

class TreeNode:
    def __init__(self, value, left=None, right=None):
        self.value = value
        self.left = left
        self.right = right

class BSTIterator:
    def __init__(self, root):
        self.stack = []
        self.pushes = 0
        self.pops = 0
        self.push_path(root)

    def push_path(self, node):
        while node is not None:
            self.stack.append(node)
            self.pushes += 1
            node = node.left

    def has_next(self):
        return len(self.stack) > 0

    def next(self):
        node = self.stack.pop()
        self.pops += 1
        self.push_path(node.right)
        return node.value

tree = TreeNode(8,
                TreeNode(3, TreeNode(1), TreeNode(6, TreeNode(4))),
                TreeNode(10, None, TreeNode(14)))

iterator = BSTIterator(tree)
print("after creation, stack:", [node.value for node in iterator.stack])
while iterator.has_next():
    value = iterator.next()
    print("next returned", value, "- stack now:",
          [node.value for node in iterator.stack])
print("has_next at the end:", iterator.has_next())

Creation pushes 8, 3 and 1, the path to the smallest value. Returning 3 pushes the path through 6 down to 4. Returning 8 pushes 10, whose right child 14 follows later.

The stack empties exactly when the last value leaves. Has_next then answers False. The paused walk was stored in that one stack the whole time.

Complexity

The naive design takes O(n) time and O(n) space before its first call, for n nodes. The stack design takes O(h) time before its first call, for height h, because it pushes only one root-to-leaf path.

Amortised cost is the total cost of many calls spread evenly over those calls. Each node enters the stack once and leaves once, so a full drain costs O(n) across n calls, which is O(1) amortised per call. The stack holds at most one root-to-leaf path, so its space is O(h).

Stored values against stack size at three sizes

class TreeNode:
    def __init__(self, value, left=None, right=None):
        self.value = value
        self.left = left
        self.right = right

class BSTIterator:
    def __init__(self, root):
        self.stack = []
        self.pushes = 0
        self.pops = 0
        self.push_path(root)

    def push_path(self, node):
        while node is not None:
            self.stack.append(node)
            self.pushes += 1
            node = node.left

    def has_next(self):
        return len(self.stack) > 0

    def next(self):
        node = self.stack.pop()
        self.pops += 1
        self.push_path(node.right)
        return node.value

def complete_tree(values):
    if not values:
        return None
    mid = len(values) // 2
    return TreeNode(values[mid], complete_tree(values[:mid]),
                    complete_tree(values[mid + 1:]))

def max_stack(root):
    iterator = BSTIterator(root)
    biggest = len(iterator.stack)
    while iterator.has_next():
        iterator.next()
        if len(iterator.stack) > biggest:
            biggest = len(iterator.stack)
    return biggest, iterator.pushes

for n in [7, 15, 31]:
    tree = complete_tree(list(range(1, n + 1)))
    biggest, pushes = max_stack(tree)
    print("nodes", n, "- naive list stored:", n,
          " stack design max stack:", biggest, " total pushes:", pushes)

The counts prove both claims. The list grows with the node count itself. The largest stack grows only when the tree gains a level, while total pushes still equal the node count.

Edge cases and pitfalls

An empty tree makes an iterator that is finished at birth. Its stack is empty and has_next answers False at once. A single node answers one value and then finishes.

Repeated has_next calls are harmless. They change no state, as the run below shows. The next call after three checks still returns the smallest value.

Edge cases, run

class TreeNode:
    def __init__(self, value, left=None, right=None):
        self.value = value
        self.left = left
        self.right = right

class BSTIterator:
    def __init__(self, root):
        self.stack = []
        self.pushes = 0
        self.pops = 0
        self.push_path(root)

    def push_path(self, node):
        while node is not None:
            self.stack.append(node)
            self.pushes += 1
            node = node.left

    def has_next(self):
        return len(self.stack) > 0

    def next(self):
        node = self.stack.pop()
        self.pops += 1
        self.push_path(node.right)
        return node.value

empty = BSTIterator(None)
print("empty tree has_next:", empty.has_next(), "stack size:", len(empty.stack))
single = BSTIterator(TreeNode(9))
print("single node has_next:", single.has_next(), "next:", single.next(),
      "has_next after:", single.has_next())

tree = TreeNode(8,
                TreeNode(3, TreeNode(1), TreeNode(6, TreeNode(4))),
                TreeNode(10, None, TreeNode(14)))

iterator = BSTIterator(tree)
print("has_next three times:", iterator.has_next(), iterator.has_next(), iterator.has_next())
print("next still returns the smallest:", iterator.next())
left_chain = TreeNode(5, TreeNode(4, TreeNode(3, TreeNode(2, TreeNode(1)))))
chain_iterator = BSTIterator(left_chain)
print("left chain stack at creation:", [node.value for node in chain_iterator.stack])
try:
    empty.next()
    print("next on empty: no error")
except IndexError:
    print("next on empty raises: IndexError")

Two pitfalls close the article. A left chain pushes its whole length at creation, because its one root-to-leaf path is the whole tree, so stack space is O(h) and a chain makes h equal to n. And next on a finished iterator raises IndexError, so callers guard every next call with has_next, exactly as the spec's script does.

Exercise

An iterator drains a BST of 10 nodes in full. How many pushes does it make in total?