I20 · Part 2 · Intermediate
Design and simulation problems
Turn a paragraph of requirements into a small machine: state, operations, costs, and the sentences that stay true.
14On this page
- The method: operations, targets, structures, invariants
- Iterators: hand out one value at a time
- Peeking iterator: one cached value
- Zigzag iterator: a queue of cursors
- Tic-Tac-Toe: count the line, don't scan it
- Snake: a queue for order and a Set for lookup
- Snapshot Array: store changes, not copies
- Browser history: an array with a pointer, or two stacks
- Text editor: two stacks around the cursor
- News feed: merge k sorted streams with a heap
- Logs and counters: logger, moving average, underground
- Simulating by events: a parking lot
- Check your design against a naive twin
- Recognizing it in an unseen problem
The method: operations, targets, structures, invariants
A design question hands you a class and a list of method names. There is no clever algorithm hiding inside. What is graded is whether you can turn a paragraph of English into a small, correct machine: which data it keeps, what each method does to that data, and what stays true between calls. The design problems chapter covers the classic caches, counters and stores, and its opening teaches the habit of writing the operation table first. This chapter uses the same habit on a different family: iterators, games and simulations, versioned data, and small services with logs and feeds. Every problem here is solved the same way, in four steps.
| Step | What you do | Why it matters |
|---|---|---|
| 1. Clarify the operations | Say each method back with its inputs, its return value and the cases the statement is silent on: an empty input, a repeated call, a move after the game ended. | Most wrong answers in this family solve a slightly different problem than the one asked. |
| 2. Write the table with targets | One row per method: the cost a naive version pays, and the cost you will aim for. | The gap between the two columns is the whole problem. If there is no gap, the naive version is the answer. |
| 3. Choose a structure per operation | For each row, name the structure that gives the target cost. Then see whether one structure can serve several rows. | Hash lookup, order of arrival, "newest first" and "smallest first" each have a standard owner. You are matching, not inventing. |
| 4. State the invariants | Write down what must be true after every call: "the set holds exactly the cells of the queue", "the pointer is a valid index". | Every method is then a short proof that it keeps the invariants. Bugs show up as a violated sentence, not a mystery. |
The invariant step is the one candidates skip, and it is the one that turns a pile of fields into a design. An invariant is a sentence about the fields that is true when the constructor finishes and is true again when every method returns, whatever order the calls came in. Two such sentences appear again and again below: "these two structures describe the same set of things" (a queue and a set of the same cells) and "this counter equals what a full recount would give" (a running sum equal to the sum of the window). If you can say them out loud, you can also check them in code, and a section near the end does exactly that.
The table below shows steps 2 and 3 for most of the problems in this chapter. The naive column is not a deliberately weak baseline: it is what you would write first, and it is also what you will use later as a reference to test the fast version.
| Problem | Naive version costs | Target | Structure that gets there |
|---|---|---|---|
| Nested list iterator | O(N) memory to flatten everything up front | O(depth) memory, values produced on demand | A stack of (list, index) frames |
| Tic-Tac-Toe | O(n) scan of row, column and diagonals per move | O(1) per move | One signed counter per line |
| Snake | O(length) scan of the body for a collision | O(1) per move | A queue for the order, a Set for membership |
| Snapshot array | O(n) copy per snapshot | O(1) set and snap, O(log v) get | Per index, a list of (snapshot id, value) and a binary search |
| Browser history | Rebuilding the list on every visit | O(1) back and forward | An array with a pointer, or two stacks |
| News feed | Sort every post of every followed user | O(f + K log f) for the K newest of f sources | A heap merging the sorted streams |
| Logger, moving average, route averages | Storing and rescanning every event | O(1) per call | A Map of next-allowed times, a ring buffer with a sum, a Map of (sum, count) |
Say it like this → "Before I write anything: the
methods are these four, with these return values. The naive version costs
this much per call, and the requirement is this much, so I am looking for
a structure that removes the scan. I will keep a queue for the order and a
set for the lookup, and the invariant is that after every move they hold the
same cells."
Iterators: hand out one value at a time
An iterator is an object that produces a sequence one value at a time and
remembers where it is. The interview version asks for two methods:
hasNext(), which says whether another value exists, and
next(), which returns it and moves on. JavaScript's built-in
protocol is the same idea with a different spelling: an object with a
next() method that returns { value, done }, and
an iterable is any object with a [Symbol.iterator]()
method that returns one. for...of, spread and destructuring
all consume that protocol.
The easy answer to "iterate over this structure" is to copy every value into an array in the constructor and walk the array. It is correct, and it usually loses the round, for two reasons. It spends O(N) memory and time before the first value is asked for, and it cannot work on a source that is huge or has no end. The better answer is lazy: keep only enough state to find the next value, and do the work when it is asked for. The design question then becomes: what is the smallest state that remembers my place?
Flatten a nested list with a stack of frames
The input is a list whose items are either integers or lists of the same
kind, to any depth: [[1, 1], 2, [1, 1]]. The iterator must
return 1, 1, 2, 1, 1. A place in this structure is a path:
"third item of the outer list, second item of the list inside it". A path
is exactly what a stack holds, so keep a stack with one frame per
list you are currently inside. A frame is the list and the index of its
next unread item.
Where should the work happen? Look at what each method must answer.
hasNext() must be able to say "no" when the remaining items
are all empty lists, as in [[], [[]]], so it cannot just
check whether the top frame has items left. It has to dig down until it
finds a real integer or runs out of frames. So let hasNext()
do the digging and leave the stack pointing at the next integer; then
next() only has to call it and read the value. This also
makes hasNext() safe to call twice in a row, because the
second call finds the stack already pointing at an integer and changes
nothing.
class NestedIterator {
constructor(nestedList) {
this.stack = [{ list: nestedList, index: 0 }]; // one frame per list we are inside
}
hasNext() {
while (this.stack.length > 0) {
const top = this.stack[this.stack.length - 1];
if (top.index === top.list.length) {
this.stack.pop(); // this list is used up: go back to its parent
} else if (Array.isArray(top.list[top.index])) {
this.stack.push({ list: top.list[top.index], index: 0 }); // step into the inner list
top.index++; // the parent resumes after it
} else {
return true; // the top frame points at a plain value
}
}
return false;
}
next() {
if (!this.hasNext()) throw new Error("no more values");
const top = this.stack[this.stack.length - 1];
return top.list[top.index++];
}
}
Dry run: [[1, 1], 2, [1, 1]]
Each frame is written as index/length, outermost first.
| Call | What the loop does | Stack afterwards | Result |
|---|---|---|---|
| start | constructor pushes the outer list | 0/3 | |
| hasNext() | item 0 is a list: push it, move the parent on | 1/3, 0/2 | true |
| next() | reads the 1 under the top frame | 1/3, 1/2 | 1 |
| next() | hasNext sees an integer at once | 1/3, 2/2 | 1 |
| next() | hasNext pops the used-up frame, then finds the integer 2 in the outer list | 2/3 | 2 |
| next() | item 2 is a list: push it, then read | 3/3, 1/2 | 1 |
| next() | reads the last integer | 3/3, 2/2 | 1 |
| hasNext() | pops the inner frame, then the outer one | empty | false |
The stack never holds more frames than the nesting depth, so memory is
O(depth), and the nested list itself is not copied. Over a whole
iteration each list is pushed once and popped once, and each integer is
read once, so the total work is O(N + L) for N integers and L lists. One
single hasNext() call can cost O(L) when it has to step over
a long run of empty lists, which is why the honest bound is amortized.
⚠ Four ways this loses the round
1. Doing the digging in
next() only. Then
hasNext() says true for [[]], and
next() has nothing to return. 2. Forgetting to advance the
parent when you step into a child. The parent frame then points at
the list again after the child is done, and the iterator loops forever.
3. Testing if (item) instead of
Array.isArray(item). The integer 0 is falsy, and so is
anything you decide to treat as "no value". 4. Flattening in the
constructor and calling it lazy. The result is right and the memory
bound is not.
The same idea as a generator
JavaScript can write the whole thing without a hand-made stack. A
generator function (function*) returns an iterator
whose body runs only when next() is called, and pauses at each
yield. The language keeps the "where was I" state for you.
Inside a generator, yield* hands control to another iterable
until it is finished, which is exactly what flattening needs:
function* flattenLazy(list) {
for (const item of list) {
if (Array.isArray(item)) yield* flattenLazy(item); // hand over to the inner generator
else yield item;
}
}
[...flattenLazy([[1, 1], 2, [1, 1]])] gives
[1, 1, 2, 1, 1]. Nothing is computed ahead: asking for two
values runs the body only as far as the second yield, which
is why the same shape works on a source that never ends. The price is a
trade you should be able to name. The state lives in the call stack: in
Node 24 we nested a list ten thousand levels deep and the generator
stopped with a RangeError, while the explicit stack read the
innermost value without trouble. Each value also passes through one
generator per level of nesting on its way out, where the explicit stack
reads it directly. A generator is also single-use: once it has been spread, a
second spread gives an empty array. When an interviewer asks for
hasNext() and next(), you can still use either
version, and a small adapter turns the stack version into a real JavaScript
iterable so that for...of works on it:
function asJsIterator(iterator) {
return {
[Symbol.iterator]() { return this; },
next() {
return iterator.hasNext() ? { value: iterator.next(), done: false } : { value: undefined, done: true };
},
};
}
Peeking iterator: one cached value
A peeking iterator wraps any iterator and adds
peek(), which returns the next value without consuming it.
The wrapped iterator only goes forward, so to look at a value and still
deliver it later you must remember it. One slot is enough: the
invariant is "if the slot is full it holds the next value the wrapper
must return; if it is empty, the next value is still inside the wrapped
iterator".
class PeekingIterator {
constructor(iterator) {
this.iterator = iterator;
this.cached = null; // null, or a box { value } holding the peeked value
}
hasNext() {
return this.cached !== null || this.iterator.hasNext();
}
peek() {
if (this.cached === null) {
if (!this.iterator.hasNext()) throw new Error("no more values");
this.cached = { value: this.iterator.next() }; // pull once, remember it
}
return this.cached.value;
}
next() {
if (this.cached !== null) {
const { value } = this.cached;
this.cached = null; // the lookahead slot is empty again
return value;
}
return this.iterator.next();
}
}
Dry run: wrapping an iterator over [4, 5, 6]
| Call | Slot before | What happens | Slot after | Result |
|---|---|---|---|---|
| peek() | empty | pulls 4 from the inner iterator | 4 | 4 |
| peek() | 4 | nothing is pulled | 4 | 4 |
| next() | 4 | returns the slot and empties it | empty | 4 |
| next() | empty | delegates to the inner iterator | empty | 5 |
| peek() | empty | pulls 6 | 6 | 6 |
| next() | 6 | returns the slot | empty | 6 |
| hasNext() | empty | inner iterator is finished | empty | false |
⚠ The slot must be a box, not the value
The tempting version stores the peeked value directly and tests
if (!this.cached) to ask "is the slot full?". That confuses
"no value" with a value that is falsy. We ran it over
[0, 7, 8]: the first peek() returned 0, the
second returned 7 (it pulled again because 0 looked empty), and
next() then skipped the 0 altogether. Wrapping the value as
{ value } makes "is there something cached?" a question about
the box, whatever the value is. The same care is needed for
undefined and null. Also remember that
hasNext() must look at the slot first: when a value has been
peeked, the wrapped iterator may already be exhausted while the wrapper
still has a value to give.
The pattern is one slot of lookahead. It is what a parser does when it looks at the next token before deciding what to do, and what a merge does when it compares the fronts of two inputs. If a problem needs to look several values ahead, the slot becomes a small queue.
Zigzag iterator: a queue of cursors
Given k lists of different lengths, return their values by taking one from
each list in turn: for [1, 2, 3], [4, 5, 6, 7]
and [8, 9] the order is 1, 4, 8, 2, 5, 9, 3, 6,
7. For two lists you can alternate with a flag. For k lists, whose
turn it is and which lists are already finished both change as you go. The
clean model is a queue of cursors. A cursor is a list and the index
of its next value. Take the cursor at the front, read one value, and if it
still has values, send it to the back of the line.
The queue is the first structure in this chapter that wants O(1) removal
from the front, so here is a small one made of linked nodes, with a
length and a peek. (The
stacks and queues chapter builds a
queue from two stacks instead, whose dequeue is O(1) amortized.) The
Snake game and the bounded logger below use it too.
class Queue {
constructor() {
this.head = null; // the oldest node: dequeue takes from here
this.tail = null; // the newest node: enqueue adds here
this.length = 0;
}
enqueue(value) {
const node = { value, next: null };
if (this.tail === null) this.head = node;
else this.tail.next = node;
this.tail = node;
this.length++;
}
dequeue() {
if (this.head === null) throw new Error("the queue is empty");
const { value } = this.head;
this.head = this.head.next;
if (this.head === null) this.tail = null; // it was the last node
this.length--;
return value;
}
peek() {
return this.head === null ? undefined : this.head.value;
}
}
class ZigzagIterator {
constructor(lists) {
this.queue = new Queue(); // the lists that still have values, in turn order
for (const list of lists) {
if (list.length > 0) this.queue.enqueue({ list, index: 0 });
}
}
hasNext() {
return this.queue.length > 0;
}
next() {
if (!this.hasNext()) throw new Error("no more values");
const cursor = this.queue.dequeue();
const value = cursor.list[cursor.index++];
if (cursor.index < cursor.list.length) this.queue.enqueue(cursor); // back of the line
return value;
}
}
Dry run: [1, 2, 3], [4, 5, 6, 7], [8, 9]
A cursor is written L1@0: the list that starts with 1, at
index 0. The queue is shown front first.
| next() returns | Queue afterwards |
|---|---|
| (start) | L1@0, L4@0, L8@0 |
| 1 | L4@0, L8@0, L1@1 |
| 4 | L8@0, L1@1, L4@1 |
| 8 | L1@1, L4@1, L8@1 |
| 2 | L4@1, L8@1, L1@2 |
| 5 | L8@1, L1@2, L4@2 |
| 9 | L1@2, L4@2 (the list of 8 and 9 is finished and is not re-queued) |
| 3 | L4@2 |
| 6 | L4@3 |
| 7 | (empty) |
Each call is O(1). The invariant is "the queue holds exactly the lists
that still have values, in the order of their turns", and that is why
finished lists need no special case: they are simply not put back. Memory
is O(k). If the statement says the lists arrive as iterators instead of
arrays, the cursor becomes a PeekingIterator, and the check
"does this list have more?" becomes hasNext().
Tic-Tac-Toe: count the line, don't scan it
Design a game on an n by n board. move(row, col, player)
places a mark for player 1 or player 2 and returns 0 if nobody has won,
or the number of the player who just completed a full row, a full column
or a full diagonal. The statement promises that every move is on an empty
cell and that no move comes after a win.
The first version stores the board and, after each move, scans the row, the column and, when the cell is on one, the two diagonals. That is O(n) per move. It is right, and it repeats work: if the row already held two marks of this player and one more arrives, you walked all three cells just to learn what a count of 2 could have told you. The question to ask is what the win check needs to know about a line. Only whether all n of its cells belong to one player. So do not keep the cells; keep one number per line.
Give player 1 the value +1 and player 2 the value -1, and let every line
(n rows, n columns and the two diagonals) hold the sum of the values of
its marks. A line has n cells, so its sum can only reach +n if all n cells
are player 1's, and only reach -n if all n are player 2's. Any mix pulls
the total back toward zero. A move touches at most four counters: its row,
its column, the main diagonal when row === col, and the
anti-diagonal when row + col === n - 1. After updating them,
check whether any of those counters has reached ±n.
class TicTacToe {
constructor(n) {
this.n = n;
this.rows = new Array(n).fill(0); // +1 per cell of player 1, -1 per cell of player 2
this.cols = new Array(n).fill(0);
this.diagonal = 0; // cells with row === col
this.antiDiagonal = 0; // cells with row + col === n - 1
}
move(row, col, player) {
const delta = player === 1 ? 1 : -1;
this.rows[row] += delta;
this.cols[col] += delta;
if (row === col) this.diagonal += delta;
if (row + col === this.n - 1) this.antiDiagonal += delta; // the centre of an odd board hits both
const complete = (total) => Math.abs(total) === this.n;
if (complete(this.rows[row]) || complete(this.cols[col]) || complete(this.diagonal) || complete(this.antiDiagonal)) {
return player;
}
return 0;
}
}
Dry run: n = 3, player 1 takes the main diagonal
| Move | rows | cols | diagonal | anti-diagonal | Returns |
|---|---|---|---|---|---|
| (0,0) by 1 | [1, 0, 0] | [1, 0, 0] | 1 | 0 | 0 |
| (0,1) by 2 | [0, 0, 0] | [1, -1, 0] | 1 | 0 | 0 |
| (1,1) by 1 | [0, 1, 0] | [1, 0, 0] | 2 | 1 | 0 |
| (0,2) by 2 | [-1, 1, 0] | [1, 0, -1] | 2 | 0 | 0 |
| (2,2) by 1 | [-1, 1, 1] | [1, 0, 0] | 3 | 0 | 1 |
Read the second row: player 2's mark in row 0 cancels player 1's, so the row counter returns to 0, and a mixed line can never reach 3. The centre cell (1,1) added to both the diagonal and the anti-diagonal, because it lies on both. The last move pushes the diagonal to 3, and player 1 wins. Each move is O(1) and the memory is O(n), against the O(n²) of the board itself. This works because each cell is used at most once. If the statement allowed overwriting a cell or repeating a move, a counter would count the same cell twice, and you would have to keep the board or a Set of used cells.
⚠ The centre and the 1-by-1 board
On an odd board the centre is on both diagonals; if you write
else if instead of two separate ifs, the second
diagonal misses it. On a 1 by 1 board the single move wins at once, and
the code above handles that with no special case, because every counter
reaches 1 on the first move. When you test, include a board where the
winning line is the anti-diagonal, since the main diagonal is the one
people check first.
Snake: a queue for order and a Set for lookup
new SnakeGame(width, height, food) starts a snake of length 1
at the top-left cell, (0, 0). food is a list of
[row, col] pairs; only the first uneaten one is on the board,
and the next appears when it is eaten. move(direction) takes
"U", "D", "L" or "R"
and returns the score, which is the number of foods eaten, or -1 if the
game is over. The game ends when the head leaves the board or runs into
the snake's body.
Write the operation table. Each move must do three things: add a new head, remove the old tail unless food was eaten, and check whether the new head hits the body. The body is a sequence with one end that grows and the other end that shrinks, which is a queue: the tail is the front, the head is the back. A queue is bad at one thing, though: "is this cell in the queue?" costs O(length) to answer. A hash set is the opposite. So keep both, and make the invariant explicit: the set holds exactly the cells of the queue. Every move changes both in the same breath.
Cells are stored as one number, row * width + col, so the set
compares numbers rather than arrays (a Set of
[row, col] arrays would never match, because two arrays with
the same contents are different objects). The one subtle step is the
collision test. When the snake does not eat, its tail moves forward in the
same turn, so the cell the tail is leaving is free for the head. When the
snake eats, the tail stays put and that cell is still taken. The code
decides this before changing anything, so a move that ends the
game leaves the structures untouched:
class SnakeGame {
constructor(width, height, food) {
this.width = width;
this.height = height;
this.food = food; // [row, col] pairs, served one at a time in this order
this.eaten = 0;
this.body = new Queue(); // cell keys, tail at the front and head at the back
this.occupied = new Set(); // the same keys, for O(1) collision checks
this.row = 0;
this.col = 0;
this.body.enqueue(0);
this.occupied.add(0);
}
move(direction) {
let row = this.row;
let col = this.col;
if (direction === "U") row--;
else if (direction === "D") row++;
else if (direction === "L") col--;
else col++;
if (row < 0 || row >= this.height || col < 0 || col >= this.width) return -1;
const key = row * this.width + col; // one number per cell
const next = this.food[this.eaten];
const eating = next !== undefined && next[0] === row && next[1] === col;
const tail = this.body.peek();
if (this.occupied.has(key) && (eating || key !== tail)) return -1; // the tail cell is free if the tail moves
if (eating) {
this.eaten++; // grow: the tail stays where it is
} else {
this.body.dequeue();
this.occupied.delete(tail);
}
this.body.enqueue(key);
this.occupied.add(key);
this.row = row;
this.col = col;
return this.eaten;
}
}
Dry run: width 3, height 2, food [[1, 2], [0, 1]]
The body is written tail first, head last.
| Move | New head | Food? | Body afterwards | Returns |
|---|---|---|---|---|
| (start) | (0,0) | |||
| R | (0,1) | no | (0,1) | 0 |
| D | (1,1) | no | (1,1) | 0 |
| R | (1,2) | yes, the first food | (1,1) (1,2) | 1 |
| U | (0,2) | no | (1,2) (0,2) | 1 |
| L | (0,1) | yes, the second food | (1,2) (0,2) (0,1) | 2 |
| U | (-1,1) | no | unchanged | -1 (off the board) |
Each move is O(1) on average and the memory is O(length), at most
width * height cells. The 2 by 2 board below shows the subtle
case. With food at (0,1), (1,1) and (1,0), the moves R, D, L eat all three
and the body becomes (0,0), (0,1), (1,1), (1,0), with the tail on (0,0).
A fourth move, U, takes the head onto (0,0), the cell the tail is about to
leave, and the game goes on with the score still 3. We ran the version
that checks the body before removing the tail on the same four moves, and
it returned 1, 2, 3, -1.
⚠ Where Snake loses the round
1. Checking collision before removing the tail, as just shown: the
snake dies by running into a cell it was leaving. 2. Removing the tail
when it eats, so the snake never grows. 3. Keeping the cells in a
Set of arrays, so a collision is never found. 4.
Updating the structures and then discovering the game is over, which
leaves the queue and the set disagreeing; if the statement allows more
calls after -1, that matters. 5. Using shift() on an
array for the queue and calling the move O(1). The language does not
promise that cost, so say "a queue" and write one whose dequeue is plainly
O(1), or name the assumption.
Snapshot Array: store changes, not copies
new SnapshotArray(length) holds length values, all
starting at 0. set(index, value) changes one entry.
snap() freezes the whole array and returns the snapshot's id,
which is 0 for the first call, 1 for the second, and so on.
get(index, snapId) returns what array[index] was
when snapshot snapId was taken. The obvious way to freeze an
array is to copy it, which costs O(length) time and memory per
snap(): an array of 50,000 entries snapshotted 50,000 times
would hold 2.5 billion numbers, nearly all of them copies of values that
did not change.
What did change between snapshots? Only the entries that were
set. So record changes, not states. For each index keep a list
of [snapId, value] pairs, added in increasing order of
snapshot id, because snapshots are taken in order. A set
appends a pair tagged with the id of the snapshot that will capture it, the
one not yet taken, which is the current counter. A second
set to the same index before the next snap()
overwrites that last pair, so each index stores at most one pair per
snapshot.
get(index, snapId) then asks: of this index's pairs, which is
the last one tagged snapId or lower? The tags are sorted, so
that is a binary search for the first pair after snapId
and a step back by one. If that first-after position is 0, the index had
not been set by then, and the answer is the initial 0. This is the same
versioned lookup as the time-based store in the
design problems chapter, with
snapshot ids standing in for timestamps; the search loop is the
"first index with a value greater than the target" form from the
binary search chapter.
class SnapshotArray {
constructor(length) {
this.history = Array.from({ length }, () => []); // per index: [snapId, value] pairs, snapIds ascending
this.snapId = 0; // the id the NEXT snapshot will get
}
set(index, value) {
const versions = this.history[index];
const last = versions[versions.length - 1];
if (last !== undefined && last[0] === this.snapId) last[1] = value; // same snapshot: overwrite
else versions.push([this.snapId, value]);
}
snap() {
return this.snapId++; // returns the id that was just taken, then moves on
}
get(index, snapId) {
const versions = this.history[index];
let lo = 0;
let hi = versions.length;
while (lo < hi) { // find the first version taken after snapId
const mid = (lo + hi) >> 1;
if (versions[mid][0] > snapId) hi = mid;
else lo = mid + 1;
}
return lo === 0 ? 0 : versions[lo - 1][1]; // the version just before it; 0 if there is none
}
}
Dry run: history of index 0
| Call | snapId counter | history[0] | Returns |
|---|---|---|---|
| set(0, 5) | 0 | [[0, 5]] | |
| snap() | 0, then 1 | [[0, 5]] | 0 |
| set(0, 6) | 1 | [[0, 5], [1, 6]] | |
| set(0, 7) | 1 | [[0, 5], [1, 7]]: overwrote the last pair, same snapshot | |
| snap() | 1, then 2 | unchanged | 1 |
| set(0, 9) | 2 | [[0, 5], [1, 7], [2, 9]] | |
| snap() | 2, then 3 | unchanged | 2 |
| get(0, 0), get(0, 1), get(0, 2) | 5, 7, 9 | ||
| get(1, 2) | history[1] is empty | 0 |
The search itself, on [[0, 10], [2, 20], [5, 30], [9, 40]] for
get(..., 6):
| lo | hi | mid | tag at mid | Decision |
|---|---|---|---|---|
| 0 | 4 | 2 | 5 | 5 > 6 is false, so lo = 3 |
| 3 | 4 | 3 | 9 | 9 > 6, so hi = 3 |
| 3 | 3 | stop: first pair after 6 is position 3, so the answer is pair 2, value 30 |
set and snap are O(1), get is
O(log v) where v is the number of recorded changes at that index, and the
memory is O(length + total sets). The constructor allocates one empty list
per index; if the length is huge and few indices are ever set, a
Map from index to list allocates only for the indices touched.
⚠ Three off-by-one traps
1. Tagging a set with the id the last snap returned. A set made
after
snap() returned 0 belongs to snapshot 1, not 0. The
right tag is the id the next snapshot will get, which is the
counter itself; snap() hands out the old value of the counter
and then increments it. 2. Searching for the last
pair equal to snapId. Most snapshots do not appear in a
given index's list, since that index did not change then; you need the
last pair at or before it. 3. Reading
versions[lo - 1][1] when lo === 0. That is
versions[-1], which is undefined, and reading
[1] from it throws a TypeError instead of
returning the initial 0.
Browser history: an array with a pointer, or two stacks
new BrowserHistory(homepage) starts on one page.
visit(url) goes to a new page and erases every page that was
ahead of the current one. back(steps) and
forward(steps) move that many pages, stopping at the ends of
the history, and return the page they land on. This question is a good
warm-up for the method, because both standard answers are right and the
conversation is about which one you would choose.
One array and a pointer. The history is a line of pages and one of
them is on screen. Keep the pages in an array and the index of the current
page. back and forward only move the index, and
clamp it to the range 0 to length - 1.
visit cuts the array to current + 1 entries,
pushes the new page and moves the pointer onto it. Setting
pages.length to a smaller number removes the entries past it.
Two stacks. Keep the pages behind you on one stack, the pages ahead on another, and the current page in a variable. Going back moves the current page onto the forward stack and pops one from the back stack, and going forward does the reverse. A visit pushes the current page onto the back stack, makes the new page current and empties the forward stack.
class BrowserHistory {
constructor(homepage) {
this.pages = [homepage];
this.current = 0; // index of the page on screen
}
visit(url) {
this.pages.length = this.current + 1; // forget every page ahead of us
this.pages.push(url);
this.current++;
}
back(steps) {
this.current = Math.max(0, this.current - steps);
return this.pages[this.current];
}
forward(steps) {
this.current = Math.min(this.pages.length - 1, this.current + steps);
return this.pages[this.current];
}
}
class BrowserHistoryStacks {
constructor(homepage) {
this.backStack = []; // pages behind us, the nearest on top
this.forwardStack = []; // pages ahead of us, the nearest on top
this.page = homepage;
}
visit(url) {
this.backStack.push(this.page);
this.page = url;
this.forwardStack = []; // a new visit erases the forward pages
}
back(steps) {
while (steps > 0 && this.backStack.length > 0) {
this.forwardStack.push(this.page);
this.page = this.backStack.pop();
steps--;
}
return this.page;
}
forward(steps) {
while (steps > 0 && this.forwardStack.length > 0) {
this.backStack.push(this.page);
this.page = this.forwardStack.pop();
steps--;
}
return this.page;
}
}
Dry run: homepage "a", then visit b, visit c, back 1, visit d, forward 2, back 5
| Call | pages | current | Returns |
|---|---|---|---|
| visit("b") | a, b | 1 | |
| visit("c") | a, b, c | 2 | |
| back(1) | a, b, c | 1 | b |
| visit("d") | a, b, d (c was ahead of us, so it is erased) | 2 | |
| forward(2) | a, b, d | 2 (already at the end) | d |
| back(5) | a, b, d | 0 (clamped) | a |
| Array and pointer | Two stacks | |
|---|---|---|
| visit | O(1) amortized: each page is erased at most once, and a visit created it | O(1) |
| back, forward | O(1), whatever the number of steps | O(steps), bounded by the stack size |
| Random access, such as "go to the third page back" | an index | pop that many |
| Where it is the natural fit | the history is a list and you jump | every move is one step and you want undo and redo semantics |
The two-stack form is the same machine as undo and redo: the back stack is the undo stack, the forward stack is redo, and doing a new action clears redo. The next section applies that picture to a text editor.
Text editor: two stacks around the cursor
A text editor with a cursor supports addText(text), which
inserts at the cursor and leaves the cursor after the new text;
deleteText(k), which deletes up to k characters to the
left of the cursor and returns how many it deleted;
cursorLeft(k) and cursorRight(k), which move the
cursor by up to k places, and return the last (up to) 10 characters to the
left of the cursor. A single string with an index looks natural, but
a string cannot be changed in place: every insertion or deletion builds a
new string, and in the usual cost model that work grows with the length of
the text. With long text and many operations that is the cost the
question is probing.
Everything an editor does happens at the cursor. So split the text there: keep the characters before the cursor on one stack, and the characters after it on another, each with the character nearest the cursor on top. Typing pushes onto the left stack. Deleting pops it. Moving the cursor left pops a character from the left stack and pushes it onto the right stack, because that character is now after the cursor, and moving right is the reverse. The full text is the left stack, then the right stack read from top to bottom.
class TextEditor {
constructor() {
this.left = []; // characters before the cursor, the nearest on top
this.right = []; // characters after the cursor, the nearest on top (so the text is reversed)
}
addText(text) {
for (const ch of text) this.left.push(ch);
}
deleteText(k) {
let removed = 0;
while (removed < k && this.left.length > 0) {
this.left.pop();
removed++;
}
return removed;
}
cursorLeft(k) {
while (k > 0 && this.left.length > 0) {
this.right.push(this.left.pop()); // one character crosses the cursor
k--;
}
return this.#beforeCursor();
}
cursorRight(k) {
while (k > 0 && this.right.length > 0) {
this.left.push(this.right.pop());
k--;
}
return this.#beforeCursor();
}
#beforeCursor() {
return this.left.slice(-10).join(""); // at most the 10 characters just before the cursor
}
}
Dry run: the cursor is shown as | between the two halves
| Call | Text, with the cursor | Returns |
|---|---|---|
| addText("hello") | hello| | |
| cursorLeft(2) | hel|lo | "hel" |
| addText("XY") | helXY|lo | |
| deleteText(3) | he|lo | 3 |
| cursorRight(9) | helo| | "helo" (it moved only 2 places, as far as the text goes) |
| cursorLeft(1) | hel|o | "hel" |
addText costs O(length of the text), deleteText(k)
and the two cursor moves cost O(k), and the returned string costs at most
O(10). No operation depends on the total length of the document: an edit
costs the size of the edit, and the only other cost is the distance the
cursor travels in a move.
A gap buffer is the same machine in one array: the text before the
cursor sits at the start, the text after it sits at the end, and the
unused space, the gap, sits between them at the cursor. Moving the cursor
shifts characters across the gap, exactly like moving them between the two
stacks, and typing fills the gap from the left. Say that in the interview
as an option; the two-stack form is shorter to write and has the same
costs.
⚠ Four details to settle out loud
1. Deleting more than exists. Delete at most
left.length characters and return how many were actually
removed, not k. 2. The return value of a cursor move is the text
to the left of the cursor, capped at 10, not the character just
moved over. 3. The order of the right stack. Because the nearest
character is on top, the right stack holds the text backwards; joining it
without reversing gives the wrong string. 4. What a "character" is.
for (const ch of text) walks code points, so an emoji is one
character; indexing a string by position walks UTF-16 units and would split
it. Pick one and say so.
News feed: merge k sorted streams with a heap
Design a small social feed. postTweet(userId, tweetId) adds a
post. follow(a, b) and unfollow(a, b) change
who a user reads. getNewsFeed(userId) returns the ids of the
ten most recent posts among the user's own and those of everyone they
follow, newest first. Do the operation table: posting, following and
unfollowing are each one change to a map or a list, so O(1) on average. The one
hard operation is the feed.
The naive feed collects every post of every followed user, sorts the lot by time, and takes ten. It costs O(P log P) for P posts in total, and it sorts thousands of posts to return ten. Look at what you already have. Each user's posts were appended in the order they were made, so each user's list is already sorted by time. The feed is a merge of several sorted lists, and you only need the first ten items of the merged result. That is the k-way merge from the heaps chapter, run from the newest end.
Put the newest post of each source in a max-heap by time. The top of the
heap is the newest post anywhere. Pop it, add it to the feed, and push the
next-newest post from the same user. Stop after ten posts or when the
heap is empty. Each heap entry is a small cursor, a list and an index, so
nothing is copied. The code below uses the comparator-based
Heap class from the heaps chapter (new Heap(compare,
items), push, pop, size);
give it the heap's order as the comparator and it does the rest.
One decision is easy to get wrong: what to order by. Tweet ids are labels and need not rise with time, and a real clock can repeat a value for two posts. Keep a counter inside the object, increase it on every post, and store its value with the post. Then "newer" is a plain number comparison with no ties.
class NewsFeed {
constructor() {
this.clock = 0; // a global counter: it never repeats, unlike a timestamp
this.posts = new Map(); // userId -> posts, oldest first
this.following = new Map(); // userId -> Set of followed userIds
}
postTweet(userId, tweetId) {
if (!this.posts.has(userId)) this.posts.set(userId, []);
this.posts.get(userId).push({ time: this.clock++, id: tweetId });
}
follow(followerId, followeeId) {
if (!this.following.has(followerId)) this.following.set(followerId, new Set());
this.following.get(followerId).add(followeeId);
}
unfollow(followerId, followeeId) {
this.following.get(followerId)?.delete(followeeId);
}
getNewsFeed(userId, limit = 10) {
const sources = new Set(this.following.get(userId) ?? []);
sources.add(userId); // you always see your own posts
const newest = [];
for (const source of sources) {
const list = this.posts.get(source);
if (list !== undefined) newest.push({ list, index: list.length - 1 }); // each stream starts at its newest post
}
const heap = new Heap((a, b) => b.list[b.index].time - a.list[a.index].time, newest); // newest time first
const feed = [];
while (feed.length < limit && heap.size() > 0) {
const top = heap.pop();
feed.push(top.list[top.index].id);
if (top.index > 0) heap.push({ list: top.list, index: top.index - 1 }); // that stream's next-newest post
}
return feed;
}
}
Dry run: the feed of user 1, who follows users 2 and 3
Posts, in the order they were made: user 1 posts 11 (time 0), user 2 posts
21 (time 1), user 1 posts 12 (time 2), user 3 posts 31 (time 3), user 2
posts 22 (time 4). The feed has limit 4. A heap entry is written
user@time.
| Step | Heap (newest first) | Feed so far |
|---|---|---|
| start: the newest post of users 2, 3 and 1 | 2@4, 3@3, 1@2 | [] |
| pop 2@4, push user 2's older post | 3@3, 1@2, 2@1 | [22] |
| pop 3@3, user 3 has no older post | 1@2, 2@1 | [22, 31] |
| pop 1@2, push user 1's older post | 2@1, 1@0 | [22, 31, 12] |
| pop 2@1 | 1@0 | [22, 31, 12, 21]: the limit is reached |
The feed is [22, 31, 12, 21]. User 1's first post, 11, was
never touched, and neither were any older posts: the heap did only as much
work as the answers needed. With f sources (the followed users plus
the user), building the starting list and heapifying it costs O(f), and
each of the K results is one pop and at most one push, O(log f) each.
That is O(f + K log f) per feed, against O(P log P) for the naive sort,
where P can be far larger than K. After unfollow(1, 2) the
same call returns [31, 12, 11], because user 2 is no longer a
source.
⚠ Where the feed goes wrong
1. Forgetting the user's own posts. Add the user to the source set
every time, rather than storing a self-follow that
unfollow(u, u) could remove. 2. Duplicate sources.
Following the same user twice must not show their posts twice; the
Set takes care of it. 3. Sources with no posts. A user
who never posted has no list, and pushing a cursor with index -1 breaks the
comparator. 4. Pushing every post of every source into the heap. That is
the naive sort in disguise. Push one cursor per source, and push the
next cursor of a source only when its last one is popped.
This merges at read time: the cost is paid when someone asks for a feed. The alternative that a large system might choose is to copy each new post into every follower's own list when it is written, so a read is a lookup and a write is expensive. Name both and say which operation you are optimizing; for the question as asked, the heap merge is the expected answer.
Logs and counters: logger, moving average, underground
Three short problems share one skill: keep just enough about the past to answer the next call. The design problems chapter already has the hit counter and the sliding-window rate limiter. These three are smaller, and each hides a different trap.
Logger rate limiter: print a message at most once per window
shouldPrint(timestamp, message) returns true if
the message may be printed now, and false if the same message
was printed less than 10 seconds ago. Timestamps arrive in non-decreasing
order. The simplest honest structure is a map from message to the first
timestamp at which it may print again. A call that prints sets that to
timestamp + 10; a call that is refused changes nothing.
class Logger {
constructor(window = 10) {
this.window = window;
this.nextAllowed = new Map(); // message -> the first timestamp it may print again
}
shouldPrint(timestamp, message) {
const allowed = this.nextAllowed.get(message);
if (allowed !== undefined && timestamp < allowed) return false; // a refusal changes nothing
this.nextAllowed.set(message, timestamp + this.window);
return true;
}
}
| Call | Stored deadline for that message | Returns |
|---|---|---|
| (1, "x") | x: 11 | true |
| (2, "y") | y: 12 | true |
| (3, "x") | x: still 11 | false |
| (8, "y") | y: still 12 | false |
| (11, "x") | x: 21 (11 is not less than 11) | true |
| (12, "y") | y: 22 | true |
| (12, "x") | x: still 21 | false |
Each call is O(1) on average. The trap is the refusal: if a refused call
also stored timestamp + 10, the refusal at time 3 would have
moved x's deadline to 13, and the call at time 11 would have been refused
too. That is a different rule (a message must be quiet for 10 seconds
before it prints again), so ask which one is meant. The second trap is
memory. The map keeps every message ever seen, forever. If the messages are
unbounded, keep only the ones that are still blocked: a queue of
(time, message) in print order, and a set of the blocked
messages. Before each call, remove from the front of the queue every
entry whose block has ended.
class BoundedLogger {
constructor(window = 10) {
this.window = window;
this.printed = new Queue(); // { time, message } in the order printed
this.recent = new Set(); // the messages that are still blocked
}
shouldPrint(timestamp, message) {
while (this.printed.length > 0 && this.printed.peek().time + this.window <= timestamp) {
this.recent.delete(this.printed.dequeue().message); // its block has ended: forget it
}
if (this.recent.has(message)) return false;
this.recent.add(message);
this.printed.enqueue({ time: timestamp, message });
return true;
}
}
At time 11 the entry for x printed at time 1 has ended (1 + 10 is not more than 11), so it leaves the set and x prints; at time 100 everything has expired and the set holds one message. Each entry is added once and removed once, so a call is O(1) amortized, and the memory is the number of messages printed within the last window. This version needs the timestamps to be non-decreasing, because it expires from the front of the queue.
Moving average with a ring buffer
next(value) receives the next number of a stream and returns
the average of the last size numbers (or of all of them, until
there are that many). Averaging the window on every call costs O(size).
Keep the sum instead: when a value arrives, add it, and subtract
the value that falls out. You need to know which value falls out, and it is
the oldest one, so store the window in a fixed array used as a
ring: the slot for the n-th value is n % size, which is
where the oldest value sits. The ring starts filled with zeros, so
subtracting what is in a slot that was never written subtracts nothing.
class MovingAverage {
constructor(size) {
this.window = new Array(size).fill(0); // a ring: the oldest value is overwritten first
this.count = 0; // how many values have arrived in total
this.sum = 0; // the sum of what is in the ring
}
next(value) {
const slot = this.count % this.window.length;
this.sum += value - this.window[slot]; // add the newcomer, drop the value it replaces
this.window[slot] = value;
this.count++;
return this.sum / Math.min(this.count, this.window.length);
}
}
| next(value) | Ring | sum | Values counted | Returns |
|---|---|---|---|---|
| 1 | [1, 0, 0] | 1 | 1 | 1 |
| 10 | [1, 10, 0] | 11 | 2 | 5.5 |
| 3 | [1, 10, 3] | 14 | 3 | 4.666666666666667 |
| 5 | [5, 10, 3] | 18 (14 + 5 - 1) | 3 | 6 |
| 2 | [5, 2, 3] | 10 (18 + 2 - 10) | 3 | 3.3333333333333335 |
Each call is O(1) and the memory is O(size). The invariant is "
sum equals the sum of the numbers in the ring". It holds
exactly for integers whose running sum stays below 253. For
fractions it can drift. We ran this class with size 3 on 0.1, 0.2, 0.3,
0.4: the last call returned 0.30000000000000004, while averaging the
window 0.2, 0.3, 0.4 directly gave 0.3. A large value that leaves the
window can do worse: with size 2 and the stream 1e16, 1, 2, the third
call returned 1, though the window 1, 2 averages 1.5, because adding 1 to
1e16 changes nothing and the 1 was lost. If exactness matters for
fractions, recompute the window sum or compare with a tolerance.
Underground system: store the sum and the count, not the average
checkIn(id, station, time) and checkOut(id, station,
time) record a card's trip; getAverageTime(from, to)
returns the average duration over every completed trip that started at
from and ended at to. A card has at most one open
trip at a time. Two structures are needed and each answers one question.
The open trips, a map from card id to where and when it checked in, answer
"where did this card start?". The route totals answer "what is the
average?", and the answer there is not to store the average:
an average of averages is wrong, and an average alone cannot be updated
without knowing how many trips it covers. Store the sum of durations and the number of trips per
route, add to both on every check-out, and divide only when asked.
class UndergroundSystem {
constructor() {
this.checkedIn = new Map(); // card id -> { station, time } of the open trip
this.trips = new Map(); // route key -> { sum, count }
}
checkIn(id, station, time) {
this.checkedIn.set(id, { station, time });
}
checkOut(id, station, time) {
const { station: from, time: start } = this.checkedIn.get(id);
this.checkedIn.delete(id);
const key = JSON.stringify([from, station]); // a route is an ordered pair
const total = this.trips.get(key) ?? { sum: 0, count: 0 };
total.sum += time - start;
total.count++;
this.trips.set(key, total);
}
getAverageTime(startStation, endStation) {
const total = this.trips.get(JSON.stringify([startStation, endStation]));
return total.sum / total.count; // divide only when asked
}
}
| Call | checkedIn | Route totals |
|---|---|---|
| checkIn(45, "Park", 3) | 45: Park at 3 | |
| checkIn(32, "Dock", 8) | 45, 32 | |
| checkIn(27, "Park", 10) | 45, 32, 27 | |
| checkOut(45, "Mill", 15) | 32, 27 | Park to Mill: sum 12, count 1 |
| checkOut(27, "Mill", 20) | 32 | Park to Mill: sum 22, count 2 |
| checkOut(32, "Quay", 22) | empty | Dock to Quay: sum 14, count 1 |
| getAverageTime("Dock", "Quay") | 14 / 1 = 14 | |
| getAverageTime("Park", "Mill") | 22 / 2 = 11 |
Every operation is O(1) on average, and the memory is the number of open
trips plus the number of distinct routes. The route key deserves care. The
tempting key is the two names joined with a comma, and it breaks when a
name contains the separator: ("a,b", "c") and
("a", "b,c") both become "a,b,c". Serializing the
pair with JSON.stringify keeps them apart, and a nested
Map would too. An array used directly as a Map
key does not work either, since two arrays with equal contents are two
different keys. Also remember that a route is directional: Park to Mill and
Mill to Park are different keys.
Simulating by events: a parking lot
Some design questions describe a small world that changes by events: a car
arrives, a car leaves. The method does not change. List the events, say
what each does to the state, and make the state small enough that every
event leaves it consistent. A parking lot with numbered slots is a short
example: park() gives the arriving car the lowest-numbered
free slot and a ticket, or reports that the lot is full, and
leave(ticket) frees the slot that ticket holds.
"Lowest free slot" needs a structure that finds the minimum, and the slots
keep coming back in any order, so a min-heap of free slot numbers fits: park
pops it, leave pushes the slot back. A map from ticket to slot says where
each car is. The invariant is that every slot is in exactly one of the two
places, so parked.size + free.size() === capacity after every
event.
class ParkingLot {
constructor(slots) {
this.capacity = slots;
this.free = new Heap((a, b) => a - b, Array.from({ length: slots }, (_, i) => i + 1)); // free slot numbers, lowest first
this.parked = new Map(); // ticket -> slot
this.nextTicket = 1;
}
park() {
if (this.free.size() === 0) return null;
const slot = this.free.pop();
const ticket = this.nextTicket++;
this.parked.set(ticket, slot);
return { ticket, slot };
}
leave(ticket) {
const slot = this.parked.get(ticket);
if (slot === undefined) return false;
this.parked.delete(ticket);
this.free.push(slot); // the slot is free again
return true;
}
}
| Event | Free slots afterwards | Returns |
|---|---|---|
| park(), park(), park() | none | tickets 1, 2, 3 in slots 1, 2, 3 |
| park() | none | null: the lot is full |
| leave(2) | 2 | true |
| park() | none | ticket 4 in slot 2: the lowest free slot |
| leave(2) | none | false: ticket 2 was already used |
| leave(1) | 1 | true |
| park() | none | ticket 5 in slot 1 |
Park and leave are O(log s) for s slots. The line that protects the
invariant is the check on the ticket: if leave pushed a slot
without checking that the ticket was still open, the same slot could sit in
the heap twice and be given to two cars. If events carry times and must be
processed in time order, the same heap idea, ordered by time instead of by
slot number, drives the whole simulation: pop the earliest event, apply it,
push the events it causes.
Check your design against a naive twin
A design class has no single output to eyeball, so test it the way you derived it. For every class here, write the naive version too: the one that stores everything and rescans, rebuilds or recounts. It is too slow to submit and easy to get right. Then drive both with the same random sequence of calls, compare every return value, and after every call check the invariant you stated. A mismatch tells you the exact call that broke the class, and a violated invariant tells you which sentence stopped being true.
function seededRandom(seed) {
let state = seed;
return (n) => {
state = (state * 16807) % 2147483647; // a small deterministic generator: the same seed replays the same run
return state % n;
};
}
function compareToTwin({ makePair, randomCall, invariant, runs, steps }) {
let calls = 0;
for (let run = 1; run <= runs; run++) {
const random = seededRandom(run);
const { fast, naive } = makePair(random);
for (let step = 0; step < steps; step++) {
const { method, args } = randomCall(random);
const got = fast[method](...args);
const want = naive[method](...args);
if (JSON.stringify(got) !== JSON.stringify(want)) {
throw new Error("run " + run + ", step " + step + ": " + method + "(" + args + ") gave " + JSON.stringify(got) + ", the twin says " + JSON.stringify(want));
}
if (invariant) invariant(fast);
calls++;
}
}
return calls;
}
The harness takes a function that makes the fast class and its twin, a function that picks a random call, and an invariant. Here it checks the array-and-pointer history against a twin that rebuilds the list on every visit:
class NaiveBrowserHistory {
constructor(homepage) {
this.pages = [homepage];
this.current = 0;
}
visit(url) {
this.pages = [...this.pages.slice(0, this.current + 1), url]; // rebuild the whole list: slow and obviously right
this.current = this.pages.length - 1;
}
back(steps) {
for (let i = 0; i < steps; i++) if (this.current > 0) this.current--;
return this.pages[this.current];
}
forward(steps) {
for (let i = 0; i < steps; i++) if (this.current < this.pages.length - 1) this.current++;
return this.pages[this.current];
}
}
const calls = compareToTwin({
runs: 500,
steps: 60,
makePair: () => ({ fast: new BrowserHistory("home"), naive: new NaiveBrowserHistory("home") }),
randomCall: (random) => {
const choice = random(3);
if (choice === 0) return { method: "visit", args: ["page" + random(100)] };
return { method: choice === 1 ? "back" : "forward", args: [random(4)] };
},
invariant: (history) => {
if (history.current < 0 || history.current >= history.pages.length) throw new Error("the pointer left the history");
},
});
That run makes 30,000 calls and compares every return value. To see that the
harness can fail, we broke the class two ways. Changing the lower clamp of
back from 0 to 1 failed at run 1, step 0, with
back(1) returning undefined where the twin said
"home". Cutting pages.length to
current instead of current + 1 was caught by the
invariant. The classes in this chapter were each checked this way before
their code was copied here. Which invariant to assert depends on the class:
| Class | Invariant to check after every call |
|---|---|
| Nested iterator | calling hasNext() twice gives the same answer; the values come out in the order of a recursive flatten |
| Snake | the set has the same size as the queue; every queued cell is in the set; the length is 1 plus the foods eaten |
| Snapshot array | each index's snapshot ids are strictly increasing |
| Text editor | the left stack plus the reversed right stack spell the same text as a plain string |
| Bounded logger | the set and the queue hold the same number of messages |
| Parking lot | parked plus free equals the capacity, with no slot in both |
In an interview you will not run a harness, but the habit transfers. After writing the class, walk one short sequence by hand and say the invariant after each call, and name the edge cases you would test: an empty input, one element, a repeated call, a call at the boundary, a call after the end.
Recognizing it in an unseen problem
- The prompt is a class with named methods and no input array to process, and the costs per method are stated or implied. Write the operation table before any code.
- "Implement an iterator",
hasNext,next,peek: a one-value lookahead slot, a stack of frames for nesting, or a queue of cursors for interleaving. Do the work inhasNextwhen it has to find the next real value. - A game board with a win or collision check each turn: do not rescan the board. Ask what the check really needs, then keep a counter per line, or a set beside the ordered body.
- A sequence with one growing end and one shrinking end, plus a membership question: a queue and a set, kept in step.
- "As of snapshot or version t": record changes with their version tag, and binary search for the last tag at or before t.
- Undo, redo, back, forward, cursor: two stacks around a middle point, or an array and a pointer when jumps are allowed.
- "The k most recent across several sources": each source is already sorted, so a heap of cursors merges them and stops after k.
- "Average, count or rate over a window or a group": store sums and counts instead of averages; for a sliding window, a ring buffer and a running sum.
- "Lowest-numbered free", "next to expire", "earliest event": a heap, together with a map that says what each entry is.
- Pitfalls: doing the digging in
nextonly, a falsy value taken for "empty", a key built by joining strings, a refusal that changes state, a pointer that leaves the array, a heap entry pushed twice, and a second structure that falls out of step with the first.
Before you move on
- Write the four-step method from memory, and give the invariant of a design you have just built as one sentence.
- Explain why
hasNext()of the nested-list iterator does the work, and whyhasNext()twice in a row is safe. - Say why the peeked value is stored in a box, and what goes wrong with
[0, 7, 8]when it is not. - Run the zigzag iterator by hand on three lists of different lengths and say where a finished list goes.
- Explain why a counter of +n or -n means one player owns the whole line, and what changes if a cell could be played twice.
- Write Snake's
moveand say why the cell the tail is leaving does not count as a collision unless the snake eats, and why the test runs before anything is changed. - Build Snapshot Array and say why a second
setbefore asnapoverwrites the last pair and whygetneeds the last pair at or before the id. - Compare the pointer and two-stack browser histories by the cost of
back(steps), and say which one is the undo-redo machine. - Explain how the text editor's two stacks move one character across the cursor, and why the right stack holds the text backwards.
- Merge k sorted post lists with a heap of cursors, and give the cost of the feed as O(f + K log f) with the reason.
- Say what the logger does on a refusal and what changes if a refusal extends the block, and how to keep its memory bounded.
- Say why the moving average and the underground system store a sum and a count, and name a case where a running sum drifts.
- Describe a naive twin and an invariant check for any one class in this chapter.
Opens in the editor: write it, run it, and check it against real tests.
All 318 for this level →5 questions, pass at 4. The check runs in your browser and the result stays on this device, so it is a self-check and not a certificate.
Finished reading?
0 of 48 chapters read in this series