Design document layer with undo/redo
Company: Figma
Role: Software Engineer
Category: Coding & Algorithms
Difficulty: Medium
Interview Round: Technical Screen
Quick Answer: This question evaluates a candidate's understanding of reversible state mutation and undo/redo semantics, covering batching, inverse operation representation, operation compression, failure handling, and choice of data structures for edit history.
Constraints
- 0 <= len(commands) <= 5000
- The sum of lengths of all inserted strings is at most 100000
- Commands are well-formed tuples of the supported shapes; indexes may be invalid and must be handled according to the rules
Examples
Input: ([('apply', 'insert', 0, 'abc'), ('apply', 'insert', 3, 'def'), ('get',), ('undo',), ('get',), ('redo',), ('get',)],)
Expected Output: ('abcdef', ['abcdef', 'abc', 'abcdef'])
Explanation: The two inserts are separate committed units. Undo removes only the second insert, and redo restores it.
Input: ([('beginBatch',), ('apply', 'insert', 0, 'hello'), ('apply', 'insert', 5, ' world'), ('commitBatch',), ('get',), ('undo',), ('get',), ('redo',), ('get',)],)
Expected Output: ('hello world', ['hello world', '', 'hello world'])
Explanation: Both inserts are committed together as one batch. Undo removes the whole batch, and redo reapplies it.
Input: ([('apply', 'insert', 0, 'abc'), ('beginBatch',), ('apply', 'insert', 3, 'd'), ('apply', 'delete', 10, 1), ('get',), ('undo',), ('get',)],)
Expected Output: ('', ['abc', ''])
Explanation: The invalid delete aborts the batch and rolls back the pending insert of 'd'. The earlier committed insert of 'abc' can still be undone.
Input: ([('apply', 'insert', 0, 'x'), ('beginBatch',), ('undo',), ('apply', 'insert', 1, 'y'), ('beginBatch',), ('redo',), ('get',), ('commitBatch',), ('undo',), ('get',), ('redo',), ('get',)],)
Expected Output: ('xy', ['xy', 'x', 'xy'])
Explanation: Undo and redo are ignored while a batch is open, and a nested beginBatch is ignored. After commit, the batch behaves as one undoable unit.
Input: ([('apply', 'insert', 0, 'ab'), ('apply', 'insert', 2, 'c'), ('undo',), ('apply', 'delete', 5, 1), ('redo',), ('get',), ('undo',), ('apply', 'insert', 2, 'd'), ('redo',), ('get',)],)
Expected Output: ('abd', ['abc', 'abd'])
Explanation: The invalid standalone delete does not clear redo, so 'c' can still be redone. After undoing again, the new committed insert of 'd' clears the redo stack.
Input: ([('apply', 'insert', 0, 'hello world'), ('beginBatch',), ('apply', 'delete', 5, 1), ('apply', 'delete', 5, 5), ('commitBatch',), ('get',), ('undo',), ('get',), ('redo',), ('get',)],)
Expected Output: ('hello', ['hello', 'hello world', 'hello'])
Explanation: The batch deletes the space and then 'world'. Undo restores both deletions together, and redo removes them again.
Input: ([('commitBatch',), ('beginBatch',), ('commitBatch',), ('undo',), ('get',)],)
Expected Output: ('', [''])
Explanation: commitBatch with no open batch is ignored, and committing an empty batch adds no history. Undo then has nothing to do.
Input: ([],)
Expected Output: ('', [])
Explanation: With no commands, the document stays empty and no snapshots are recorded.
Solution
def solution(commands):
document = ''
snapshots = []
undo_stack = []
redo_stack = []
batch_open = False
batch_ops = []
batch_inverses = []
def execute(op):
nonlocal document
kind, index, text = op
if kind == 'insert':
document = document[:index] + text + document[index:]
else: # delete
document = document[:index] + document[index + len(text):]
def build_edit(kind, index, value):
if kind == 'insert':
if not isinstance(index, int) or index < 0 or index > len(document):
return None
if not isinstance(value, str):
return None
text = value
return ('insert', index, text), ('delete', index, text)
if kind == 'delete':
if not isinstance(index, int) or not isinstance(value, int):
return None
length = value
if index < 0 or length < 0 or index + length > len(document):
return None
deleted = document[index:index + length]
return ('delete', index, deleted), ('insert', index, deleted)
return None
def abort_batch():
nonlocal batch_open, batch_ops, batch_inverses
for inverse in reversed(batch_inverses):
execute(inverse)
batch_open = False
batch_ops = []
batch_inverses = []
for command in commands:
if not command:
continue
action = command[0]
if action == 'apply':
if len(command) != 4:
if batch_open:
abort_batch()
continue
built = build_edit(command[1], command[2], command[3])
if built is None:
if batch_open:
abort_batch()
continue
forward, inverse = built
execute(forward)
if batch_open:
batch_ops.append(forward)
batch_inverses.append(inverse)
else:
undo_stack.append(([forward], [inverse]))
redo_stack.clear()
elif action == 'beginBatch':
if not batch_open:
batch_open = True
batch_ops = []
batch_inverses = []
elif action == 'commitBatch':
if batch_open:
if batch_ops:
undo_stack.append((batch_ops[:], batch_inverses[:]))
redo_stack.clear()
batch_open = False
batch_ops = []
batch_inverses = []
elif action == 'undo':
if batch_open:
continue
if undo_stack:
ops, inverses = undo_stack.pop()
for inverse in reversed(inverses):
execute(inverse)
redo_stack.append((ops, inverses))
elif action == 'redo':
if batch_open:
continue
if redo_stack:
ops, inverses = redo_stack.pop()
for op in ops:
execute(op)
undo_stack.append((ops, inverses))
elif action == 'get':
snapshots.append(document)
return document, snapshots
Explanation
Time complexity: O(E * L) worst-case, where E is the number of edit ops actually executed (including undo/redo/abort replay) and L is the document length, since each `execute` rebuilds the string via slicing.. Space complexity: O(H), where H is the total text captured across undo history, redo history, and the open batch (inverses store deleted/inserted substrings, not whole-document snapshots)..
Hints
- Use two stacks: one for committed units that can be undone, and one for units that can be redone.
- Instead of storing whole document snapshots, store inverse primitive operations. To undo a batch, replay its inverse operations in reverse order.