Implement text editor with undo/redo
Company: Notion
Role: Software Engineer
Category: Coding & Algorithms
Difficulty: Medium
Interview Round: Technical Screen
Quick Answer: This question evaluates implementation-level skills in state management, reversible operation design, and efficient mutable-string handling within the Coding & Algorithms domain.
Constraints
- The document starts empty.
- delete clamps to the current length and never raises.
- undo on an empty undo history and redo on an empty redo history are no-ops.
- Any fresh insert/delete clears the redo history.
- Characters are Unicode codepoints (index by codepoint, not byte).
- Each mutating step should run in O(size of change), not O(document size) — use a mutable buffer + inverse operations, not full snapshots.
Examples
Input: ([['insert', 'Hello'], ['read'], ['insert', ' World'], ['read'], ['delete', 6], ['read'], ['undo'], ['read'], ['undo'], ['read'], ['redo'], ['read']],)
Expected Output: ['Hello', 'Hello World', 'Hello', 'Hello World', 'Hello', 'Hello World']
Explanation: Spec walkthrough: build 'Hello World', delete ' World' (6 chars), then undo restores ' World', undo again removes ' World', and redo re-deletes it. Inserts and deletes round-trip exactly through the inverse stacks.
Input: ([['insert', 'abc'], ['insert', 'def'], ['read'], ['undo'], ['undo'], ['read'], ['insert', 'X'], ['read'], ['redo'], ['read']],)
Expected Output: ['abcdef', '', 'X', 'X']
Explanation: After two undos the doc is empty; the fresh insert 'X' CLEARS the redo history, so the trailing redo is a no-op — 'X' stays. This is the abandoned-branch rule.
Input: ([['insert', 'hi'], ['delete', 100], ['read'], ['undo'], ['read']],)
Expected Output: ['', 'hi']
Explanation: Over-deletion: delete 100 from a 2-char doc clamps to removing 'hi' (no error). Its inverse records insert('hi'), so undo restores the document.
Input: ([['undo'], ['redo'], ['read']],)
Expected Output: ['']
Explanation: Undo and redo on empty histories are well-defined no-ops; the document stays empty.
Input: ([['read']],)
Expected Output: ['']
Explanation: A read on a brand-new document returns the empty string.
Input: ([['insert', 'café'], ['read'], ['delete', 1], ['read'], ['undo'], ['read']],)
Expected Output: ['café', 'cafe', 'café']
Explanation: Unicode handling: here the accented 'e' is a base 'e' followed by a combining accent codepoint, so deleting 1 codepoint strips the accent ('cafe'); undo re-inserts exactly that removed codepoint, restoring the original.
Input: ([['insert', 'abcd'], ['delete', 2], ['delete', 1], ['read'], ['undo'], ['read'], ['undo'], ['read'], ['undo'], ['read']],)
Expected Output: ['a', 'ab', 'abcd', '']
Explanation: Two deletes leave 'a'; undoing them re-inserts 'c' then 'cd' (each inverse holds the exact removed substring), and a final undo reverses the original insert back to empty.
Hints
- Command pattern + two stacks: one stack of inverse operations for undo, one for redo. The hard constraint is the O(change) time bound — let it drive how you store both the document and the history.
- Store the document as a mutable list of characters, not an immutable string: appending/truncating at the end is amortized O(change), whereas self.doc + chars copies the whole document O(n) per edit.
- Push inverses, not snapshots. The inverse of an insert(s) is delete(len(s)). The inverse of a delete is insert(removed) — but you must capture the actually-removed substring AT APPLY TIME, because the count removed can be smaller than requested when the document is short.
- Elegant symmetry: make 'apply an op' return that op's inverse. Then undo and redo are the same two lines — apply something, push its inverse onto the opposite stack — with no insert-vs-delete branching.