Design document editor with undo/redo and batching
Company: Figma
Role: Software Engineer
Category: Coding & Algorithms
Difficulty: Medium
Interview Round: Technical Screen
Quick Answer: The question evaluates design of mutable text-editing layers and undo/redo semantics, including transactional batching, performance optimization for very large operation histories, and handling of overlapping range edits and cursor bookkeeping within the Coding & Algorithms domain.
Part 1: Basic Document Editing with Undo
Constraints
- 0 <= len(commands) <= 10000
- 0 <= document length after any command <= 100000
- For delete and replace, 0 <= start <= end <= current document length
- For insert, 0 <= index <= current document length
- undo on an empty history is a no-op
Examples
Input: ([] ,)
Expected Output: []
Explanation: No commands produce no output.
Input: ([['apply','insert','0','hello'],['get'],['apply','insert','5',' world'],['get'],['undo'],['get'],['undo'],['get'],['undo'],['get']],)
Expected Output: ['hello', 'hello world', 'hello', '', '']
Explanation: Two inserts are undone in reverse order. The final extra undo has no effect.
Input: ([['apply','insert','0','abcdef'],['apply','delete','2','4'],['get'],['undo'],['get'],['apply','replace','1','5','Z'],['get'],['undo'],['get']],)
Expected Output: ['abef', 'abcdef', 'aZf', 'abcdef']
Explanation: The delete removes cd. The replace changes bcde to Z, and undo restores the overwritten text.
Input: ([['undo'],['get']],)
Expected Output: ['']
Explanation: Undo with an empty history is a no-op.
Solution
def solution(commands):
text = ''
undo_stack = []
outputs = []
def apply_op(op):
nonlocal text
kind = op[0]
if kind == 'insert':
idx = op[1]
s = op[2]
text = text[:idx] + s + text[idx:]
return ('delete', idx, idx + len(s))
if kind == 'delete':
start = op[1]
end = op[2]
removed = text[start:end]
text = text[:start] + text[end:]
return ('insert', start, removed)
start = op[1]
end = op[2]
s = op[3]
old = text[start:end]
text = text[:start] + s + text[end:]
return ('replace', start, start + len(s), old)
def parse_op(fields):
kind = fields[0]
if kind == 'insert':
return ('insert', int(fields[1]), fields[2])
if kind == 'delete':
return ('delete', int(fields[1]), int(fields[2]))
return ('replace', int(fields[1]), int(fields[2]), fields[3])
for cmd in commands:
action = cmd[0]
if action == 'apply':
undo_stack.append(apply_op(parse_op(cmd[1:])))
elif action == 'undo':
if undo_stack:
apply_op(undo_stack.pop())
elif action == 'get':
outputs.append(text)
return outputsExplanation
Time complexity: O(C * L). Space complexity: O(L + H).
Hints
- For every applied operation, store the inverse operation needed to undo it.
- For a replace, the inverse must remember the exact text that was overwritten.
Part 2: Transactional Batches with Atomic Undo
Constraints
- 0 <= len(commands) <= 10000
- 0 <= document length after any command <= 100000
- Batch nesting is not used
- For every edit, the provided range or index is valid for the current document state
- undo on an empty history is a no-op
Examples
Input: ([['apply','insert','0','abcdef'],['begin'],['apply','replace','1','4','X'],['apply','insert','2','Y'],['commit'],['get'],['undo'],['get'],['undo'],['get']],)
Expected Output: ['aXYef', 'abcdef', '']
Explanation: The committed batch changes abcdef to aXYef. One undo restores the whole batch, and the next undo removes the initial insert.
Input: ([['apply','insert','0','A'],['begin'],['apply','insert','1','B'],['apply','insert','2','C'],['commit'],['get'],['undo'],['get'],['apply','insert','1','D'],['get'],['undo'],['get']],)
Expected Output: ['ABC', 'A', 'AD', 'A']
Explanation: The BC inserts are undone together, while the later D insert is a separate undoable unit.
Input: ([['begin'],['commit'],['undo'],['get']],)
Expected Output: ['']
Explanation: An empty batch creates no history entry, so undo has no effect.
Input: ([['apply','insert','0','abcdef'],['begin'],['apply','delete','1','5'],['apply','insert','1','XYZ'],['apply','replace','2','4','Q'],['commit'],['get'],['undo'],['get']],)
Expected Output: ['aXQf', 'abcdef']
Explanation: Overlapping edits inside the batch are still undone correctly by replaying inverses in reverse.
Solution
def solution(commands):
text = ''
history = []
active_batch = None
outputs = []
def apply_op(op):
nonlocal text
kind = op[0]
if kind == 'insert':
idx = op[1]
s = op[2]
text = text[:idx] + s + text[idx:]
return ('delete', idx, idx + len(s))
if kind == 'delete':
start = op[1]
end = op[2]
removed = text[start:end]
text = text[:start] + text[end:]
return ('insert', start, removed)
start = op[1]
end = op[2]
s = op[3]
old = text[start:end]
text = text[:start] + s + text[end:]
return ('replace', start, start + len(s), old)
def parse_op(fields):
kind = fields[0]
if kind == 'insert':
return ('insert', int(fields[1]), fields[2])
if kind == 'delete':
return ('delete', int(fields[1]), int(fields[2]))
return ('replace', int(fields[1]), int(fields[2]), fields[3])
for cmd in commands:
action = cmd[0]
if action == 'begin':
active_batch = []
elif action == 'commit':
if active_batch is not None and active_batch:
history.append(active_batch)
active_batch = None
elif action == 'apply':
inverse = apply_op(parse_op(cmd[1:]))
if active_batch is None:
history.append([inverse])
else:
active_batch.append(inverse)
elif action == 'undo':
if active_batch is None and history:
unit = history.pop()
for inverse in reversed(unit):
apply_op(inverse)
elif action == 'get':
outputs.append(text)
return outputsExplanation
Time complexity: O(C * L) worst case, where C is the number of commands and L is the maximum document length. Each insert/delete/replace/undo rebuilds the string via slicing, costing up to O(L); undoing a committed batch costs the total size of its inverse edits.. Space complexity: O(L + H), where L is the document length and H is the total size of inverse-operation data (indices plus captured old/removed substrings) retained across all history entries..
Hints
- A history entry can be either one inverse operation or a list of inverse operations.
- To undo a batch, apply its inverse operations in reverse order.
Part 3: Compress a Large Batch into One Undo Patch
Constraints
- 0 <= len(initial_text) <= 100000
- 0 <= len(operations) <= 10000
- 0 <= document length after any operation <= 200000
- For every edit, the provided range or index is valid for the current document state
- The compressed undo metadata should depend on the size of the net changed region, not directly on the number of operations
Examples
Input: ('abcdef', [['replace','1','4','X'],['insert','2','Y']])
Expected Output: ['aXYef', '1', '4', 'XY', 'bcd', 'abcdef']
Explanation: The net effect is replacing bcd with XY.
Input: ('hello', [])
Expected Output: ['hello', '5', '5', '', '', 'hello']
Explanation: No edits produce an empty patch at the end of the document.
Input: ('abc', [['insert','1','X'],['delete','1','2']])
Expected Output: ['abc', '3', '3', '', '', 'abc']
Explanation: The insert and delete cancel out, so the net patch is empty.
Input: ('abc', [['delete','0','3'],['insert','0','XYZ']])
Expected Output: ['XYZ', '0', '3', 'XYZ', 'abc', 'abc']
Explanation: The entire original document is replaced.
Input: ('', [['insert','0','hi']])
Expected Output: ['hi', '0', '0', 'hi', '', '']
Explanation: Insertion into an empty document is represented as replacing an empty range.
Solution
def solution(initial_text, operations):
chars = list(initial_text)
for op in operations:
kind = op[0]
if kind == 'insert':
idx = int(op[1])
chars[idx:idx] = list(op[2])
elif kind == 'delete':
start = int(op[1])
end = int(op[2])
del chars[start:end]
else:
start = int(op[1])
end = int(op[2])
chars[start:end] = list(op[3])
final_text = ''.join(chars)
n = len(initial_text)
m = len(final_text)
prefix = 0
while prefix < n and prefix < m and initial_text[prefix] == final_text[prefix]:
prefix += 1
suffix = 0
while suffix < n - prefix and suffix < m - prefix and initial_text[n - 1 - suffix] == final_text[m - 1 - suffix]:
suffix += 1
start = prefix
end = n - suffix
replacement = final_text[start:m - suffix]
original_segment = initial_text[start:end]
restored_text = final_text[:start] + original_segment + final_text[start + len(replacement):]
return [final_text, str(start), str(end), replacement, original_segment, restored_text]Explanation
Time complexity: O(Q * L + N), where Q is the number of operations and L the max document length (each insert/delete/replace is a list splice costing up to O(L)); compression adds O(N) for the prefix/suffix scans, N = len(initial_text)+len(final_text).. Space complexity: O(L + D): O(L) for the char list and result strings of length up to the final document size L, plus O(D) for the stored changed region (replacement + original_segment), which is independent of the number of edits Q..
Hints
- After computing the final text, skip the equal prefix and equal suffix shared by the initial and final strings.
- The inverse of one replace patch only needs the original middle segment and the length of the replacement.
Part 4: Undo and Redo for Single Edits and Batches
Constraints
- 0 <= len(commands) <= 10000
- 0 <= document length after any command <= 100000
- For every edit, the provided range or index is valid for the current document state
- undo and redo on empty stacks are no-ops
- Any user apply clears the redo stack, including an apply inside a new batch
Examples
Input: ([['apply','insert','0','abc'],['get'],['undo'],['get'],['redo'],['get'],['redo'],['get']],)
Expected Output: ['abc', '', 'abc', 'abc']
Explanation: The second redo has no effect because there is no remaining redo history.
Input: ([['apply','insert','0','abcdef'],['begin'],['apply','replace','1','4','X'],['apply','insert','2','Y'],['commit'],['get'],['undo'],['get'],['redo'],['get'],['undo'],['get']],)
Expected Output: ['aXYef', 'abcdef', 'aXYef', 'abcdef']
Explanation: The committed batch is undone and redone atomically.
Input: ([['apply','insert','0','abc'],['apply','insert','3','d'],['get'],['undo'],['get'],['apply','insert','3','X'],['get'],['redo'],['get'],['undo'],['get']],)
Expected Output: ['abcd', 'abc', 'abcX', 'abcX', 'abc']
Explanation: After undoing d, the new X edit clears redo, so redo cannot bring d back.
Input: ([['undo'],['redo'],['get'],['apply','insert','0','A'],['undo'],['undo'],['redo'],['get']],)
Expected Output: ['', 'A']
Explanation: Empty undo and redo are no-ops. An extra undo after history is empty does not clear redo.
Solution
def solution(commands):
text = ''
history = []
redo_stack = []
active_forward = None
active_inverse = None
outputs = []
def apply_op(op):
nonlocal text
kind = op[0]
if kind == 'insert':
idx = op[1]
s = op[2]
text = text[:idx] + s + text[idx:]
return ('delete', idx, idx + len(s))
if kind == 'delete':
start = op[1]
end = op[2]
removed = text[start:end]
text = text[:start] + text[end:]
return ('insert', start, removed)
start = op[1]
end = op[2]
s = op[3]
old = text[start:end]
text = text[:start] + s + text[end:]
return ('replace', start, start + len(s), old)
def parse_op(fields):
kind = fields[0]
if kind == 'insert':
return ('insert', int(fields[1]), fields[2])
if kind == 'delete':
return ('delete', int(fields[1]), int(fields[2]))
return ('replace', int(fields[1]), int(fields[2]), fields[3])
for cmd in commands:
action = cmd[0]
if action == 'begin':
active_forward = []
active_inverse = []
elif action == 'commit':
if active_forward is not None and active_forward:
history.append((active_forward, active_inverse))
active_forward = None
active_inverse = None
elif action == 'apply':
redo_stack.clear()
op = parse_op(cmd[1:])
inverse = apply_op(op)
if active_forward is None:
history.append(([op], [inverse]))
else:
active_forward.append(op)
active_inverse.append(inverse)
elif action == 'undo':
if active_forward is None and history:
unit = history.pop()
forward_ops, inverse_ops = unit
for inverse in reversed(inverse_ops):
apply_op(inverse)
redo_stack.append(unit)
elif action == 'redo':
if active_forward is None and redo_stack:
unit = redo_stack.pop()
forward_ops, inverse_ops = unit
for op in forward_ops:
apply_op(op)
history.append(unit)
elif action == 'get':
outputs.append(text)
return outputsExplanation
Time complexity: O(C * L) worst case, where C is the number of commands and L is the maximum document length. Each insert/delete/replace rebuilds the string via slicing (O(L)); undo/redo of a batch costs the sum of its operation sizes, still bounded by O(L) per op.. Space complexity: O(L + H), where L is the current document length and H is the total size of forward/inverse operations retained across the history and redo stacks (each stored op holds index data plus any captured/removed text slice)..
Hints
- Store each history unit with both its forward operations and inverse operations.
- Undo applies inverse operations in reverse order; redo applies forward operations in original order.