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 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
hasNexthasNextshould do the work andnextnextshould 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:
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) > 0class 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) > 0Space , not — 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 space) is the answer they want you to improve on.
next()next() is 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 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:
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 Falseclass 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 FalseThe 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:
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()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.
| Design | nextnext | Space |
|---|---|---|
| Pre-flatten to a list | ||
| BST iterator (stack) | amortised | |
| Lazy nested iterator | amortised | |
| Peeking wrapper |
The variant map
| Variant | The state you keep | Canonical problem |
|---|---|---|
| In-order tree iteration | Stack of the left spine | 173 |
| Nested list flattening | Stack of unprocessed items, reversed | 341 |
Add peekpeek | A one-element buffer | 284 |
| 2D flattening | Row and column indices | 251 (Premium) |
| Run-length decoding | Index + remaining count in the current run | 900 |
| Zigzag / interleaved iteration | A queue of sub-iterators | 281 (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 average nextnext and 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 amortised per nextnext — each node is pushed once and popped once,
so nn calls cost in total. A single call can be when it pushes a
long spine. Space .
Start by naming the simple solution: flatten the whole tree into a list in the
constructor and serve from an index. It is worst-case nextnext and
perfectly correct — but space, which is what the stated 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. - ” worst case for
nextnext?” Not with 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 amortised per nextnext — each NestedIntegerNestedInteger is pushed and popped
at most once across the whole traversal. Space worst
case, but only in
practice, versus for eager flattening.
Three things carry the correctness:
reversedreversedon every push. Stacks are LIFO but iteration is front-to-back.hasNexthasNextdoes not consume. It returnsTrueTruewith the integer still on the stack. Two consecutivehasNexthasNextcalls must be safe.- The
whilewhileloop, not anifif.[1,[4,[6]]][1,[4,[6]]]needs two unwraps to reach66, and[[]][[]]needs one unwrap that yields nothing at all. A singleififhandles 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 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 for all three. Space .
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
| # | Problem | Difficulty | The twist |
|---|---|---|---|
| 173 | Binary Search Tree Iterator | Medium | Stack of the left spine — memory, not |
| 341 | Flatten Nested List Iterator | Medium | Lazy unwrapping in hasNexthasNext; push reversed |
| 284 | Peeking Iterator | Medium | One-element buffer; hasNexthasNext must count it |
| 900 | RLE Iterator | Medium | Bulk consumption spanning several runs; return -1-1 when exhausted |
| 251 | Flatten 2D Vector | Medium · Premium | Two indices, skipping empty rows |
| 1586 | Binary Search Tree Iterator II | Medium · Premium | Add prev()prev() — one stack is no longer enough |
Interview follow-ups
| They ask | What they’re checking | The answer |
|---|---|---|
| “Why not pre-flatten?” | Trade-off awareness | It works and is simpler, but costs memory and does work the caller may not need |
“Complexity of nextnext?” | Precision | amortised; a single call can be while pushing a spine |
“Is hasNexthasNext idempotent?” | The contract | It must be — it may advance internal state but must not consume the element it reports |
| “Where should the work live?” | Design judgement | In hasNexthasNext, so nextnext is a trivial hand-off and the lookahead logic exists once |
| “Why push reversed?” | Attention to detail | A stack pops from the end but iteration must yield the front first |
“What if values can be NoneNone?” | Robustness | The NoneNone-as-empty sentinel breaks; use a boolean flag or a unique sentinel |
“Add prevprev?” | Knowing when to change design | History is needed — second stack, or pre-flatten and index |
Edge-case checklist
- Empty source —
hasNext()hasNext()must beFalseFalseimmediately 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 awhilewhile, not anifif. hasNexthasNextcalled repeatedly — must be idempotent.peekpeekcalled repeatedly — must return the same value each time.peekpeekat the last element — source exhausted but the buffer holds a value;hasNexthasNextmust still beTrueTrue.nextnextimmediately afterpeekpeek— 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
hasNexthasNextand 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
whilewhileloop. Empty lists then need no special case. - The BST iterator’s stack is , not — that memory bound is precisely what the problem is asking you to achieve over pre-flattening.
nextnextis amortised, not worst case. Say which.- To add
peekpeek, buffer one element — and rememberhasNexthasNextmust count the buffer even when the source is exhausted. - When the interface needs
prevprevor random access, pre-flattening becomes the better design. Laziness is a means, not an end.
Next: Design with Randomization — random selection, weighted picks, and unbiased shuffles.
If this helped you, consider buying me a coffee ☕
Buy me a coffeeWas this page helpful?
Let us know how we did
