Skip to content

Design Iterators and Flatteners

An iterator turns a traversal inside out. Instead of a recursion that runs to completion and hands back a list, you must pause after every element and resume when asked. The state that recursion kept implicitly on the call stack now has to live in your object, explicitly.

That inversion is the whole skill:

Take the recursion, replace the call stack with an explicit stack you own, and expose one step of it as next()next().

The second theme is laziness. Flattening everything up front is usually allowed and often simpler — but it costs O(n)O(n) memory and does work the caller may never need. Interviewers ask for the lazy version because it is the one that works on a stream, or on a structure too large to materialise.

What you’ll learn

  • Converting an in-order recursion into an explicit-stack iterator.
  • Why hasNexthasNext should do the work and nextnext should stay simple.
  • Lazy flattening: unwrapping only as far as the next element requires.
  • Wrapping an existing iterator to add peekpeek, and the one-element buffer that needs.
  • Three real LeetCode problems solved in the browser: 173, 341, 284.

The cue

Pattern 1 — recursion to explicit stack

The BST in-order walk descends left, visits, then goes right. As an iterator, the “descend left” part becomes a helper and the stack holds the ancestors still owed a visit:

bst_iterator.py
class BSTIterator:
    def __init__(self, root):
        self.stack = []
        self._push_left(root)          # prime it with the leftmost spine
 
    def _push_left(self, node):
        while node:
            self.stack.append(node)
            node = node.left
 
    def next(self):
        node = self.stack.pop()        # the next smallest
        self._push_left(node.right)    # its right subtree comes after it
        return node.val
 
    def hasNext(self):
        return len(self.stack) > 0
bst_iterator.py
class BSTIterator:
    def __init__(self, root):
        self.stack = []
        self._push_left(root)          # prime it with the leftmost spine
 
    def _push_left(self, node):
        while node:
            self.stack.append(node)
            node = node.left
 
    def next(self):
        node = self.stack.pop()        # the next smallest
        self._push_left(node.right)    # its right subtree comes after it
        return node.val
 
    def hasNext(self):
        return len(self.stack) > 0

Space O(h)O(h), not O(n)O(n) — the stack only ever holds one root-to-node path. That is precisely what the problem asks for, and it is why pre-flattening the whole tree into a list (also valid, and O(n)O(n) space) is the answer they want you to improve on.

next()next() is O(1)O(1) amortised: a single call may push a whole left spine, but each node is pushed once and popped once across the full traversal, so nn calls cost O(n)O(n) total. That is the same accounting as the two-stack queue.

Pattern 2 — lazy flattening

LC 341 gives a nested list where each element is either an integer or another nested list, arbitrarily deep. The lazy design keeps a stack of unprocessed items and unwraps only when asked:

nested_iterator.py
class NestedIterator:
    def __init__(self, nestedList):
        self.stack = list(reversed(nestedList))   # reversed so pop() takes the front
 
    def next(self):
        return self.stack.pop().getInteger()      # hasNext guaranteed it is an int
 
    def hasNext(self):
        while self.stack:
            top = self.stack[-1]
            if top.isInteger():
                return True                       # ready -- do NOT consume it
            self.stack.pop()                      # unwrap ONE level
            self.stack.extend(reversed(top.getList()))
        return False
nested_iterator.py
class NestedIterator:
    def __init__(self, nestedList):
        self.stack = list(reversed(nestedList))   # reversed so pop() takes the front
 
    def next(self):
        return self.stack.pop().getInteger()      # hasNext guaranteed it is an int
 
    def hasNext(self):
        while self.stack:
            top = self.stack[-1]
            if top.isInteger():
                return True                       # ready -- do NOT consume it
            self.stack.pop()                      # unwrap ONE level
            self.stack.extend(reversed(top.getList()))
        return False

The whilewhile loop handles arbitrary depth: [[[[1]]]][[[[1]]]] unwraps one level per iteration until an integer surfaces. And empty lists are skipped naturally — they push nothing, so the loop simply continues. That is why [[]][[]] correctly reports hasNext() == FalsehasNext() == False with no special case.

Pattern 3 — wrapping with a buffer

LC 284 asks you to add peek()peek() to an iterator that only offers next()next() and hasNext()hasNext(). Since you cannot un-consume an element, you buffer one:

peeking_iterator.py
class PeekingIterator:
    def __init__(self, iterator):
        self.it = iterator
        self.buffer = None                     # the one element we pulled early
 
    def peek(self):
        if self.buffer is None:
            self.buffer = self.it.next()       # pull and hold
        return self.buffer
 
    def next(self):
        if self.buffer is not None:
            value, self.buffer = self.buffer, None
            return value
        return self.it.next()
 
    def hasNext(self):
        return self.buffer is not None or self.it.hasNext()
peeking_iterator.py
class PeekingIterator:
    def __init__(self, iterator):
        self.it = iterator
        self.buffer = None                     # the one element we pulled early
 
    def peek(self):
        if self.buffer is None:
            self.buffer = self.it.next()       # pull and hold
        return self.buffer
 
    def next(self):
        if self.buffer is not None:
            value, self.buffer = self.buffer, None
            return value
        return self.it.next()
 
    def hasNext(self):
        return self.buffer is not None or self.it.hasNext()

Every method must now consider both states — buffer filled or empty — and hasNexthasNext in particular must report TrueTrue when the underlying iterator is exhausted but a buffered element remains.

DesignnextnextSpace
Pre-flatten to a listO(1)O(1)O(n)O(n)
BST iterator (stack)O(1)O(1) amortisedO(h)O(h)
Lazy nested iteratorO(1)O(1) amortisedO(depth+breadth)O(\text{depth} + \text{breadth})
Peeking wrapperO(1)O(1)O(1)O(1)

The variant map

VariantThe state you keepCanonical problem
In-order tree iterationStack of the left spine173
Nested list flatteningStack of unprocessed items, reversed341
Add peekpeekA one-element buffer284
2D flatteningRow and column indices251 (Premium)
Run-length decodingIndex + remaining count in the current run900
Zigzag / interleaved iterationA queue of sub-iterators281 (Premium)

Practice — real LeetCode problems

LC 173 — Binary Search Tree Iterator · Medium

Problem. Implement an iterator over the in-order traversal of a BST, with next()next() returning the next smallest number and hasNext()hasNext() reporting whether one remains. Aim for O(1)O(1) average nextnext and O(h)O(h) memory.

Constraints. 1 <= number of nodes <= 10^51 <= number of nodes <= 10^5, 0 <= Node.val <= 10^60 <= Node.val <= 10^6, and nextnext is only called when hasNexthasNext is true.

Examples. For [7,3,15,null,null,9,20][7,3,15,null,null,9,20]: next()next() gives 33, next()next() gives 77, hasNext()hasNext() gives TrueTrue, next()next() gives 99, next()next() gives 1515, hasNext()hasNext() gives TrueTrue, next()next() gives 2020, hasNext()hasNext() gives FalseFalse

Editorial — approach, complexity, follow-ups

This is the iterative in-order traversal from BST Patterns, with its loop body exposed as next()next(). The stack holds exactly the ancestors whose values have not yet been emitted.

Time O(1)O(1) amortised per nextnext — each node is pushed once and popped once, so nn calls cost O(n)O(n) in total. A single call can be O(h)O(h) when it pushes a long spine. Space O(h)O(h).

Start by naming the simple solution: flatten the whole tree into a list in the constructor and serve from an index. It is O(1)O(1) worst-case nextnext and perfectly correct — but O(n)O(n) space, which is what the stated O(h)O(h) memory target rules out. Offering it first and then improving reads well.

The invariant to state: the stack contains the path of nodes whose left subtrees are fully consumed but which have not themselves been visited. Popping one yields the smallest remaining; pushing its right child’s left spine restores the invariant.

Follow-ups you should expect:

  • “Add prev()prev() for a bidirectional iterator (LC 1586)?” The single stack no longer suffices, since it has forgotten what it popped. Either keep a second stack of visited nodes, or fall back to pre-flattening with an index — and here that trade is genuinely worth it.
  • “What if the tree is modified during iteration?” Classic invalidation problem; you would need version counters or a snapshot.
  • “Iterate in reverse order?” Mirror it — push the right spine and descend left in nextnext.
  • O(1)O(1) worst case for nextnext?” Not with O(h)O(h) space; you would need threaded trees or Morris traversal, which mutates the tree.

LC 341 — Flatten Nested List Iterator · Medium

Problem. You are given a nested list of integers, where each element is either an integer or a list whose elements may also be integers or lists. Implement an iterator that flattens it. The NestedIntegerNestedInteger interface provides isInteger()isInteger(), getInteger()getInteger() and getList()getList().

Constraints. 1 <= nestedList.length <= 5001 <= nestedList.length <= 500, integer values in [-10^6, 10^6][-10^6, 10^6].

Examples. [[1,1],2,[1,1]][[1,1],2,[1,1]] flattens to [1,1,2,1,1][1,1,2,1,1] · [1,[4,[6]]][1,[4,[6]]] flattens to [1,4,6][1,4,6] · [[]][[]] flattens to [][]

Editorial — approach, complexity, follow-ups

Keep a stack of items not yet emitted. hasNexthasNext unwraps lists lazily until an integer sits on top; nextnext then simply takes it.

Time O(1)O(1) amortised per nextnext — each NestedIntegerNestedInteger is pushed and popped at most once across the whole traversal. Space O(total items)O(\text{total items}) worst case, but only O(depth+breadth along the current frontier)O(\text{depth} + \text{breadth along the current frontier}) in practice, versus O(n)O(n) for eager flattening.

Three things carry the correctness:

  • reversedreversed on every push. Stacks are LIFO but iteration is front-to-back.
  • hasNexthasNext does not consume. It returns TrueTrue with the integer still on the stack. Two consecutive hasNexthasNext calls must be safe.
  • The whilewhile loop, not an ifif. [1,[4,[6]]][1,[4,[6]]] needs two unwraps to reach 66, and [[]][[]] needs one unwrap that yields nothing at all. A single ifif handles neither.

[[]][[]] returning [][] and [[], [1]][[], [1]] returning [1][1] are the empty-list cases, and they work with no special handling: unwrapping an empty list pushes nothing, the loop continues, and the stack either empties or reveals the next item.

The eager alternative — recursively flatten everything in the constructor into a plain list — is much shorter and perfectly acceptable if the interviewer allows it. Say it, then explain that lazy evaluation avoids O(n)O(n) upfront memory and work the caller may never ask for.

Follow-ups you should expect: “Do it eagerly instead” — have the 3-line recursive flatten ready. “What if the structure were infinite or streamed?” — laziness becomes mandatory; eager flattening cannot terminate. “Add peekpeek?” — compose with the LC 284 buffer. “Flatten a 2D vector (LC 251)?” — simpler: two indices, advancing past empty rows.

LC 284 — Peeking Iterator · Medium

Problem. Given an iterator supporting next()next() and hasNext()hasNext(), design a wrapper that also supports peek()peek() — returning the next element without advancing.

Constraints. 1 <= nums.length <= 10001 <= nums.length <= 1000, all calls valid.

Examples. Over [1,2,3][1,2,3]: next()next() gives 11, peek()peek() gives 22, next()next() gives 22, next()next() gives 33, hasNext()hasNext() gives FalseFalse

Editorial — approach, complexity, follow-ups

You cannot rewind an iterator, so peekpeek must consume from the source and stash the result. Every method then has to account for the buffer.

Time O(1)O(1) for all three. Space O(1)O(1).

The case that catches people is the second half of the test: after PeekingIterator(Iterator([7]))PeekingIterator(Iterator([7])) and one peek()peek(), the underlying iterator is exhausted, yet the wrapper still has an element to serve. So hasNext()hasNext() must be buffer is not None or self.it.hasNext()buffer is not None or self.it.hasNext() — delegating straight through returns FalseFalse and loses an element.

peek()peek() twice in a row returning the same value is also part of the contract, which the buffered design gives for free.

Follow-ups you should expect: “Peek kk elements ahead?” — buffer a dequedeque of up to kk pulled elements. “Add prevprev?” — you must retain history, so keep a list of everything emitted. “Wrap a Python generator?” — the same design; itertools.chain([peeked], gen)itertools.chain([peeked], gen) is another idiomatic route worth mentioning. “What if nextnext on the source can raise instead of returning?” — catch it in peekpeek and record exhaustion.

LeetCode problem set

#ProblemDifficultyThe twist
173Binary Search Tree IteratorMediumStack of the left spine — O(h)O(h) memory, not O(n)O(n)
341Flatten Nested List IteratorMediumLazy unwrapping in hasNexthasNext; push reversed
284Peeking IteratorMediumOne-element buffer; hasNexthasNext must count it
900RLE IteratorMediumBulk consumption spanning several runs; return -1-1 when exhausted
251Flatten 2D VectorMedium · PremiumTwo indices, skipping empty rows
1586Binary Search Tree Iterator IIMedium · PremiumAdd prev()prev() — one stack is no longer enough

Interview follow-ups

They askWhat they’re checkingThe answer
“Why not pre-flatten?”Trade-off awarenessIt works and is simpler, but costs O(n)O(n) memory and does work the caller may not need
“Complexity of nextnext?”PrecisionO(1)O(1) amortised; a single call can be O(h)O(h) while pushing a spine
“Is hasNexthasNext idempotent?”The contractIt must be — it may advance internal state but must not consume the element it reports
“Where should the work live?”Design judgementIn hasNexthasNext, so nextnext is a trivial hand-off and the lookahead logic exists once
“Why push reversed?”Attention to detailA stack pops from the end but iteration must yield the front first
“What if values can be NoneNone?”RobustnessThe NoneNone-as-empty sentinel breaks; use a boolean flag or a unique sentinel
“Add prevprev?”Knowing when to change designHistory is needed — second stack, or pre-flatten and index

Edge-case checklist

  • Empty sourcehasNext()hasNext() must be FalseFalse immediately and not raise.
  • Deeply nested empty lists[[]][[]], [[[]]][[[]]]; must flatten to nothing.
  • Empty list among real values[[], [1]][[], [1]]; skipped without consuming.
  • Arbitrary nesting depth[1,[4,[6]]][1,[4,[6]]]; needs a whilewhile, not an ifif.
  • hasNexthasNext called repeatedly — must be idempotent.
  • peekpeek called repeatedly — must return the same value each time.
  • peekpeek at the last element — source exhausted but the buffer holds a value; hasNexthasNext must still be TrueTrue.
  • nextnext immediately after peekpeek — must return the buffered element, not skip it.
  • Single-node tree / single-element source — the smallest case for every design.

Recap

  • An iterator is a paused traversal: replace the recursion’s call stack with an explicit stack you control, and expose one step as next()next().
  • Put the work in hasNexthasNext and keep it idempotent — it may advance internal state but must never consume the element it reports.
  • Lazy flattening: a stack of unprocessed items, pushed reversed, unwrapped one level at a time in a whilewhile loop. Empty lists then need no special case.
  • The BST iterator’s stack is O(h)O(h), not O(n)O(n) — that memory bound is precisely what the problem is asking you to achieve over pre-flattening.
  • nextnext is O(1)O(1) amortised, not worst case. Say which.
  • To add peekpeek, buffer one element — and remember hasNexthasNext must count the buffer even when the source is exhausted.
  • When the interface needs prevprev or random access, pre-flattening becomes the better design. Laziness is a means, not an end.

Next: Design with RandomizationO(1)O(1) random selection, weighted picks, and unbiased shuffles.

If this helped you, consider buying me a coffee ☕

Buy me a coffee

Was this page helpful?

Let us know how we did