Question: Indexed sequence with checkpoints
Companies: OpenAI
Difficulty: Medium
Problem statement
Build a growable array that can take cheap snapshots. The spec arrives in three parts; finish and test each part before the next.
Part 1. append(value) -> index, pop() -> value, get(index), set(index, value) and len(). Indexes run from 0 to len - 1; anything else raises IndexError (no negative indexing).
Part 2. checkpoint() -> id returns ids 0, 1, 2, …. get_at(id, index) returns the value index had when that checkpoint was taken, and len_at(id) the length then. Writes made after checkpoint() returns are not part of it. An index that did not exist at the checkpoint raises IndexError; an unknown checkpoint raises KeyError. A checkpoint must not copy the array: assume 10^5 items and 10^4 checkpoints.
Part 3. rollback(id) makes the live array equal checkpoint id again. Checkpoint id stays valid; every later checkpoint is forgotten, and the next checkpoint() returns id + 1. Rollback must not scan the whole array.
class IndexedSequence:
def __len__(self) -> int: ...
def append(self, value) -> int: ...
def pop(self): ...
def get(self, index: int): ...
def set(self, index: int, value) -> None: ...
def checkpoint(self) -> int: ...
def get_at(self, checkpoint: int, index: int): ...
def len_at(self, checkpoint: int) -> int: ...
def rollback(self, checkpoint: int) -> None: ...
Example
s.append(10); s.append(20)
c0 = s.checkpoint() # 0
s.set(0, 11); s.append(30)
s.get_at(c0, 0) -> 10
s.get_at(c0, 2) -> IndexError (index 2 did not exist at c0)
s.rollback(c0)
len(s) -> 2, s.get(0) -> 10
s.checkpoint() -> 1
Approach
Three levels, each one changing what "fast" has to mean:
- A plain list does
append,pop,get,setin O(1). -
checkpoint()must be cheap even when the list holds a million items, andget_at(c, i)must read the value indexihad at checkpointc. -
rollback(c)makes the live list equal checkpointcagain and forgets every later checkpoint.
Brute force. checkpoint() copies the whole list. Reads at a checkpoint are O(1), but a checkpoint is O(n) time and memory. With n = 10^5 and 10^5 checkpoints that is 10^10 element copies: say this, then move on.
Version-tagged history per index. Keep a version counter that equals the number of checkpoints taken so far. Every index holds its own history: a list of (version, value) pairs in increasing version order. A write at the current version either appends a new pair, or overwrites the last pair if it already has the current version. Then:
-
checkpoint()returns the current version and increments it: O(1), no copying. - Checkpoint
csees, for each index, the newest pair withversion <= c. The versions in one index's history are sorted, so that is onebisect: O(log h), where h is that index's history length. - The length changes over time too (
append,pop), so it gets the same treatment: one history of(version, length)pairs.get_at(c, i)first checksi < len_at(c).
Overwriting within a version is what keeps memory at O(n + number of (index, checkpoint) pairs that actually changed), not O(writes). A million set(0, ...) calls between two checkpoints leave one pair.
Rollback with an undo log. Rolling back to c means dropping every pair with version greater than c, in every index history and in the length history. Scanning all n indexes is O(n). Instead, append a log entry every time a new pair is created (not on an overwrite): ("set", i) or ("len",). Versions only grow while you write, so the log is sorted by the version of the pair each entry created. Rollback pops log entries from the end while their version is greater than c, and pops the matching pair. Then set the version counter to c + 1.
Each pair is created once and popped at most once, so rollback is O(1) amortized per pair it removes. After the rollback the log is still sorted (everything newer than c is gone), so later writes and rollbacks keep working.
Definitions to say out loud before coding. These are the questions the interviewer is waiting for you to ask:
- Is the state at checkpoint
ctaken before or after writes made right aftercheckpoint()returned? After the call, writes belong to the next version, socsees only what happened before the call. - After
rollback(c), iscstill valid? Yes. Are later ids reused? Yes, the nextcheckpoint()returnsc + 1. - Can
get_atread an index that did not exist at that checkpoint? No:IndexError, even if the index exists now. - Unknown checkpoint id:
KeyError.
Code
from __future__ import annotations
from bisect import bisect_right
class IndexedSequence:
"""A growable array with cheap checkpoints.
Every write is tagged with the current version, the number of checkpoints
taken so far. Checkpoint c sees every write tagged <= c.
"""
def __init__(self) -> None:
self._version = 0
self._vals: list = [] # per index: values, oldest first
self._vers: list = [] # per index: version of each value
self._len_vers: list = [0] # versions at which the length changed
self._lens: list = [0] # the length from that version on
# Undo log of every pair created, in order: ("set", index) or
# ("len",). Lets rollback drop newer pairs without scanning.
self._log: list = []
# -- level 1 -----------------------------------------------------------
def __len__(self) -> int:
return self._lens[-1]
def append(self, value) -> int:
i = len(self)
if i == len(self._vals):
self._vals.append([])
self._vers.append([])
self._set_len(i + 1)
self._write(i, value)
return i
def pop(self):
n = len(self)
if n == 0:
raise IndexError("pop from empty sequence")
value = self._vals[n - 1][-1]
self._set_len(n - 1)
return value
def set(self, index: int, value) -> None:
self._check(index, len(self))
self._write(index, value)
def get(self, index: int):
self._check(index, len(self))
return self._vals[index][-1]
# -- level 2 -----------------------------------------------------------
def checkpoint(self) -> int:
c = self._version
self._version += 1
return c
def get_at(self, checkpoint: int, index: int):
self._check_checkpoint(checkpoint)
k = bisect_right(self._len_vers, checkpoint) - 1
self._check(index, self._lens[k])
j = bisect_right(self._vers[index], checkpoint) - 1
return self._vals[index][j]
def len_at(self, checkpoint: int) -> int:
self._check_checkpoint(checkpoint)
return self._lens[bisect_right(self._len_vers, checkpoint) - 1]
# -- level 3 -----------------------------------------------------------
def rollback(self, checkpoint: int) -> None:
"""Make the live state equal checkpoint `checkpoint`. Later checkpoints
are forgotten; `checkpoint` itself stays valid."""
self._check_checkpoint(checkpoint)
while self._log and self._log_version(self._log[-1]) > checkpoint:
entry = self._log.pop()
if entry[0] == "set":
self._vals[entry[1]].pop()
self._vers[entry[1]].pop()
else:
self._lens.pop()
self._len_vers.pop()
self._version = checkpoint + 1
# -- helpers -----------------------------------------------------------
def _write(self, index: int, value) -> None:
vers, vals = self._vers[index], self._vals[index]
if vers and vers[-1] == self._version:
vals[-1] = value # same version: overwrite, history unchanged
return
vers.append(self._version)
vals.append(value)
self._log.append(("set", index))
def _set_len(self, n: int) -> None:
if self._len_vers[-1] == self._version:
self._lens[-1] = n
return
self._len_vers.append(self._version)
self._lens.append(n)
self._log.append(("len",))
def _log_version(self, entry) -> int:
if entry[0] == "set":
return self._vers[entry[1]][-1]
return self._len_vers[-1]
def _check(self, index: int, length: int) -> None:
if not 0 <= index < length:
raise IndexError(index)
def _check_checkpoint(self, checkpoint: int) -> None:
if not 0 <= checkpoint < self._version:
raise KeyError(checkpoint)
Two details that are easy to get wrong:
-
popkeeps the popped value in the history. An older checkpoint can still read it. Only the length history changes. If the same slot is appended again in a later version, it gets a new pair; in the same version, the pair is overwritten. -
The log entry finds its own pair. A
("set", i)entry is always the newest pair of indexiat the moment it is popped, because pairs foriwere created in log order. So the entry does not need to store the version.
Complexity
Let n be the current length, w the number of writes, and p the number of (index, version) pairs that exist (p ≤ w, and usually far smaller).
| Operation | Time | Notes |
|---|---|---|
append, pop, get, set, len
|
O(1) | Amortized for append
|
checkpoint |
O(1) | No copying |
get_at, len_at
|
O(log h) | h = pairs for that index; at most the number of checkpoints |
rollback(c) |
O(pairs removed) | O(1) amortized per pair over the whole run |
| Memory | O(n + p) | The log holds one entry per pair |
Worked numbers: 10^5 items, 10^4 checkpoints, 100 sets between checkpoints. Brute force copies 10^5 × 10^4 = 10^9 slots (about 8 GB of pointers). Version tagging stores at most 100 × 10^4 = 10^6 pairs plus the 10^5 originals, about 1.1 × 10^6 pairs, and get_at bisects a history that averages ten pairs per touched index.
Tests
from sequence import IndexedSequence
def raises(exc, fn, *args):
try:
fn(*args)
except exc:
return True
return False
def test_append_get_set():
s = IndexedSequence()
assert s.append("a") == 0
assert s.append("b") == 1
s.set(0, "z")
assert [s.get(0), s.get(1)] == ["z", "b"]
assert len(s) == 2
def test_out_of_range():
s = IndexedSequence()
s.append(1)
assert raises(IndexError, s.get, 1)
assert raises(IndexError, s.set, -1, 5)
assert s.pop() == 1
assert raises(IndexError, s.pop)
from sequence import IndexedSequence
def raises(exc, fn, *args):
try:
fn(*args)
except exc:
return True
return False
def test_checkpoint_reads_old_values():
s = IndexedSequence()
s.append(10)
s.append(20)
c0 = s.checkpoint()
s.set(0, 11)
s.append(30)
c1 = s.checkpoint()
s.set(0, 12)
assert (c0, c1) == (0, 1)
assert s.get_at(c0, 0) == 10
assert s.get_at(c1, 0) == 11
assert s.get(0) == 12
assert s.len_at(c0) == 2 and s.len_at(c1) == 3
assert raises(IndexError, s.get_at, c0, 2) # index 2 did not exist yet
assert raises(KeyError, s.get_at, 5, 0) # no such checkpoint
def test_many_writes_between_checkpoints():
s = IndexedSequence()
s.append(0)
for v in range(1000):
s.set(0, v)
c = s.checkpoint()
s.set(0, -1)
assert s.get_at(c, 0) == 999 and s.get(0) == -1
def test_pop_then_append_keeps_old_checkpoint():
s = IndexedSequence()
s.append("x")
c = s.checkpoint()
s.pop()
s.append("y")
assert s.get_at(c, 0) == "x" and s.get(0) == "y"
from sequence import IndexedSequence
def raises(exc, fn, *args):
try:
fn(*args)
except exc:
return True
return False
def test_rollback_restores_values_and_length():
s = IndexedSequence()
s.append(1)
s.append(2)
c0 = s.checkpoint()
s.set(1, 20)
s.append(3)
c1 = s.checkpoint()
s.append(4)
s.rollback(c0)
assert len(s) == 2 and s.get(1) == 2
assert raises(KeyError, s.get_at, c1, 0) # later checkpoints are gone
assert s.get_at(c0, 1) == 2 # c0 itself still works
c = s.checkpoint()
assert c == c0 + 1 # ids are reused after rollback
s.append(99)
assert s.get(2) == 99 and s.get_at(c, 1) == 2
def test_rollback_twice_and_write_in_between():
s = IndexedSequence()
s.append("a")
c0 = s.checkpoint()
s.set(0, "b")
s.rollback(c0)
s.set(0, "c")
s.rollback(c0)
assert s.get(0) == "a"
def test_against_a_brute_force_model():
import random
rng = random.Random(7)
s = IndexedSequence()
live = []
snaps = [] # full copies, list index = checkpoint id
for _ in range(3000):
op = rng.random()
if op < 0.3 or not live:
v = rng.randint(0, 99)
s.append(v)
live.append(v)
elif op < 0.55:
i = rng.randrange(len(live))
v = rng.randint(0, 99)
s.set(i, v)
live[i] = v
elif op < 0.65:
assert s.pop() == live.pop()
elif op < 0.8:
assert s.checkpoint() == len(snaps)
snaps.append(list(live))
elif op < 0.85 and snaps:
c = rng.randrange(len(snaps))
s.rollback(c)
del snaps[c + 1:]
live = list(snaps[c])
elif snaps:
c = rng.randrange(len(snaps))
assert s.len_at(c) == len(snaps[c])
if snaps[c]:
i = rng.randrange(len(snaps[c]))
assert s.get_at(c, i) == snaps[c][i]
assert len(s) == len(live)
assert all(s.get(i) == live[i] for i in range(len(live)))
The last test is the one to write in the interview if you have five spare minutes: the brute force (full copies) is the oracle, and 3,000 random operations check every level at once.
Follow-ups
-
"Release checkpoints nobody needs."
release(c)means no one will readcagain. Pairs only exist to answer reads at some live checkpoint, so for each index you can merge a pair into the next one when no live checkpoint falls between their versions. Do it lazily: keep the live checkpoints in a sorted set, and compact one index's history when you next write to it. Rollback targets must stay live, sorollbackto a released checkpoint raises. -
"Rollback, then branch." Today a rollback destroys the newer checkpoints. To keep them (like Git branches), stop truncating and make the version graph a tree: each checkpoint has a parent, and a read at
cwalks the path fromcto the root. That is a persistent data structure; a persistent segment tree or a fat-node array with path copying gives O(log n) reads and writes per version. - "Insert and delete in the middle." Index-tagged histories break, because an insert shifts every later index. Use a persistent balanced tree (a rope or an implicit treap with path copying). Each write copies O(log n) nodes, a checkpoint is a pointer to the root, and rollback is "make that root current".
-
"Make it durable." The undo log is almost a write-ahead log already. Write each pair to disk as
(version, index, value)before applying it, and writecheckpointandrollbackrecords too. Recovery replays the log; a periodic snapshot of the live list (plus the length history) bounds replay time. - "Concurrent readers." Readers at an old checkpoint never conflict with writers at the current version, which is the point of MVCC. With one writer and a lock-free append-only history per index, readers need no lock as long as list appends are atomic, which holds in CPython but should not be relied on across languages.
Interviewer expectations
Mid-level (L4)
- Level 1 correct with bounds checks; a working level 2, even if checkpoints start as full copies.
- Moves to per-index history with binary search when the interviewer asks about memory, and handles "index did not exist at that checkpoint".
- Tests each level before moving on.
Senior (L5)
- Goes straight to version tagging with overwrite-within-version and explains why memory is O(changed pairs), not O(writes).
- Asks the semantics questions up front (what
csees, id reuse, rollback validity) and encodes the answers in tests. - Implements rollback without scanning every index (an undo log or equivalent) and states the amortized cost.
Staff (L6+)
- Writes the brute-force model test and uses it to find their own bugs.
- Connects the design to MVCC and snapshot isolation, and discusses releasing checkpoints, branching histories and persistent trees with their trade-offs.
- Explains durability (the undo log as a WAL plus snapshots) and what changes once inserts in the middle are allowed.