前两天帮一个准备跳槽的后端开发者复盘笔试,题面很短:设计一个文本编辑器,支持插入、删除、撤销(Undo)和恢复(Redo),最后按给定命令序列输出结果。这个题在外人眼里属于“人人都会”的送分题,但那位开发者写完代码后越看越心虚——连续撤销两次以后文本就错位,撤销之后再做新操作,Redo居然还能回到一个已经被覆盖的旧状态。其实这道题来自某安全厂商的在线笔试,考得非常经典,要的就是你用最少代码把状态回滚这件事表达清楚。
我不打算直接甩一份AC代码,而是把拆题、选型、编码、边界处理、真实落地整条线都串一遍。准备笔试的可以直接抄后面那段实现,做业务系统的也可以借鉴最后面命令对象的设计。全篇没有炫技,每一步都尽量说清楚为什么这么做,尤其是“为什么是双栈”,以及“为什么不能存全量快照”。
1. 拆题:一道普通撤销/恢复题真正想考的能力
先说结论:这类题只要抓住一个核心模型就行——整个编辑器就是一台状态机,所有命令都是状态迁移。Undo不是把变量改回旧值,而是对当前状态执行一次逆向迁移;Redo则是把刚才那条逆向迁移再翻回去。这个理解是解题的前提,也是面试官嘴里“思路清晰”和“代码能跑”之间的分界线。
1.1 从编辑器操作看本质:每次改动都是状态迁移
如果题目只给一个文本框,你很容易把“撤销”想成“把文本变量恢复到上一个值”。但笔试不是这么简单,它会给你一串混合命令,例如:
I 0 A I 1 B U U R R操作I表示插入,U表示Undo,R表示Redo。你不能靠保存“上一次字符串”来回答,因为问题关心的是一整条操作历史,而不仅仅是一个变量。每一次I、D都会把文本状态迁移到另一个状态,U和R反向迁移一次。
我把这条逻辑作为口头解释的第一步:命令是状态迁移的载体,不是文本的临时操作。你可以在代码注释里写清楚,也可以直接跟面试官说“我会用双栈分别保存正向历史和可重放的历史”,这样一开始就把得分点占住了。
1.2 三个常见变体,解法全部收敛到双栈
我在整理这类题时发现,外面的变体非常多,但内核基本一致。列一张表看得很清楚:
| 变体类型 | 典型题面 | 多出来的难度点 | 解法本质 |
|---|---|---|---|
| 文本编辑器 | 插入、删除、Undo、Redo、打印文本 | 索引边界、字符串拼接 | 双栈 + 指令反转 |
| 数组/集合操作 | 对列表追加、移除、改值,支持回退 | 随机索引、撤销后分支 | 双栈 + 记录变化 |
| 业务对象回滚 | 审批、配置发布、草稿状态回退 | 持久化、并发、字段幂等 | 命令对象 + 逆操作 |
安全厂商的这道题属于第一种,但它的解题思想完全可以平移。你要做的不是背一道题,而是建立“指令回放”的思维模型:任何可逆操作,都可以被压缩成一个正向命令和一个反向命令。Undo执行反向,Redo执行正向,历史由两个栈维护。
1.3 为什么“每步存全量快照”不是好方案
不少基础不错的开发者第一反应是:每次操作后把整个文本拷贝一份存起来,撤销就从快照列表里取上一个,恢复就取下一个。这个方案看起来最简单,但面试官只要追问一句“真项目里也这么存吗”,立刻就会露怯。
算一笔账很容易懂:假设一次编辑会话有5000次操作,每次平均文本长度是1000个字符。全量快照方案要保存大约500万字符,其中绝大部分是重复内容;而增量方案只记录每次实际变化,假设每次平均变化10个字符,只要存5万字符,差距是一百倍。文本越长、操作越多,快照方案越不可控。更关键的是,全量快照丢失了“操作语义”。一旦编辑器加入光标、选区、格式这些状态,快照可能存得下,但恢复出来的状态未必能还原成用户眼中的“前一步”。所以只要想通过面试,就必须往增量方向走。
2. 数据结构与双栈设计:为什么我选了字符数组加元组
代码不是直接拍脑袋写的。动手之前,我先确定两件事:文本用什么结构存,命令历史用什么格式记。这两件事决定代码的清晰程度和扩展余地。
2.1 文本结构选型:笔试阶段最适合的是字符列表
真实编辑器会考虑复杂的数据结构,但笔试不能这么干。我这里把选项摆出来:
| 结构方案 | 优点 | 缺点 | 推荐场景 |
|---|---|---|---|
| Python字符串 | 直观、可用不可变语义 | 每次插入/删除都生成新字符串,成本高 | 只讲原理、不写长文本时 |
| 字符数组/列表 | 支持切片插入删除,代码好写 | 任意位置插入仍要搬移元素 | 笔试、中小型文本处理 |
| Gap Buffer | 光标附近操作快,真实文本编辑器常用 | 实现复杂,需要考虑gap维护 | 有足够时间设计的大型编辑器 |
| Piece Table/Rope | 内存效率高,适合超大文档 | 概念重,笔试容易绕晕 | 专题探讨、专业编辑器场景 |
我最后选了list[str],原因很简单:text[pos:pos] = list(chars)就是插入,del text[pos:pos+length]就是删除,两个操作写出来短、看着清楚,还不会在边界条件上给自己挖坑。字符列表占内存比纯字符串多一些,但在笔试数据量下单字符元素的内存开销可以忽略。
2.2 双栈的分工:一个存历史,一个存可恢复的未来
这是整道题的核心,我的理解如下:
undo_stack:保存用户已经执行过的正向操作。redo_stack:保存被撤销后、将来可能重新执行的正向操作。
执行规则非常固定:
执行新操作: 文本应用正向效果 操作压入 undo_stack 清空 redo_stack Undo: 从 undo_stack 弹出命令 根据命令类型执行反向效果 把同一条命令压入 redo_stack Redo: 从 redo_stack 弹出命令 根据命令类型执行正向效果 把同一条命令压入 undo_stack第一次看到“同一条命令压入另一个栈”的人可能会懵。其实关键点是:栈里保存的不是“过去的值”,而是“逻辑操作本身”。一条insert命令,在被执行时产生插入效果;被撤销时就应该删除对应字符。一条delete命令,被执行时删除字符;被撤销时就把字符插回来。命令本身不变,变的是栈和调用方向。这样做的好处是Redo不需要额外保存“撤销前的文本”,因为Redo做的事情只是把同一条命令重新正向执行一遍。
2.3 用元组当轻量命令对象:笔试够用,扩展有迹可循
正式的项目里,每一条命令都应该是一个独立对象,有do()和undo()两个方法,这就是经典命令模式。但笔试时间紧张,我不会为了一个只有两种操作的小题写一整套类继承,所以用(类型, 索引, 数据)三元组当轻量命令。
("insert", 5, "hello") # 在索引5插入 hello ("delete", 5, "hello") # 删除从索引5开始的 hello元组最实在的好处是打印、调试都很直观。更重要的是,如果面试官继续追问“要是加一个替换操作怎么办”,你可以顺着话头说:现阶段分支只有两个,元组足够;如果操作类型增多,就把元组升级成命令对象,把分支逻辑收进各自的do和undo里,避免undo函数变成一个不断膨胀的if堆。这个回答既解释了当前实现,又展示了对设计模式的理解。
3. 可运行的Python实现:双栈加Delta记录
下面这份代码是按笔试场景写的。它没有多余的抽象,注释直接放在关键分支上,如果你要现场解释,顺着注释讲就行。
3.1 接口约定
我设计的编辑器对外只暴露四个方法:
insert(pos, chars):在指定位置插入字符串。delete(pos, length):从指定位置删除一段字符。undo():撤销最近一次操作,成功返回True,无可撤销内容返回False。redo():重做最近一次被撤销的操作,成功返回True,无内容返回False。
文本内部用字符列表保存,外部读写用字符串,这样接口使用方不需要关心内部结构。
3.2 完整代码
class TextEditor: def __init__(self): self.text = [] self.undo_stack = [] self.redo_stack = [] def insert(self, pos: int, chars: str) -> None: if not 0 <= pos <= len(self.text): raise ValueError("insert position out of range") self.text[pos:pos] = list(chars) self.undo_stack.append(("insert", pos, chars)) # 新增操作会让旧的 redo 分支失效 self.redo_stack.clear() def delete(self, pos: int, length: int) -> None: if pos < 0 or pos + length > len(self.text): raise ValueError("delete range out of range") removed = "".join(self.text[pos:pos + length]) del self.text[pos:pos + length] self.undo_stack.append(("delete", pos, removed)) self.redo_stack.clear() def undo(self) -> bool: if not self.undo_stack: return False cmd_type, pos, data = self.undo_stack.pop() if cmd_type == "insert": # 撤销插入:删除同样位置的同样字符 del self.text[pos:pos + len(data)] else: # 撤销删除:把被删文本插回原位置 self.text[pos:pos] = list(data) self.redo_stack.append((cmd_type, pos, data)) return True def redo(self) -> bool: if not self.redo_stack: return False cmd_type, pos, data = self.redo_stack.pop() if cmd_type == "insert": # 重做插入:再次把字符插到该位置 self.text[pos:pos] = list(data) else: # 重做删除:再次删除这段字符 del self.text[pos:pos + len(data)] self.undo_stack.append((cmd_type, pos, data)) return True def get_text(self) -> str: return "".join(self.text)这段代码在每个方法里都成对思考了正向和反向效果。插一条关键经验:写删除时,一定要先取出被删字符,再执行删除,并且把被删字符存进命令元组。如果只记录length而不记录被删内容,撤销删除时就没有东西可以插回。
3.3 用两个示例验证正确性
先测最基本的插入和撤销:
e = TextEditor() e.insert(0, "A") e.insert(1, "B") print(e.get_text()) # AB e.undo() print(e.get_text()) # A e.redo() print(e.get_text()) # AB再测删除、撤销、重做循环:
e = TextEditor() e.insert(0, "hello") e.delete(2, 3) # 删除 "llo" print(e.get_text()) # he e.undo() print(e.get_text()) # hello e.redo() print(e.get_text()) # he你观察这个流程会发现:Undo之后文本恢复为删除前的状态,Redo之后又回到删除后的状态。整个过程没有保存任何快照,只靠方向相反的指令执行完成状态迁移。这就是双栈方案最让人舒服的地方。
4. 最容易翻车的边界条件和笔试陷阱
代码能跑通基本用例只是第一步,这些题目的测试用例很爱藏边界情况,不处理就会挂得不明不白。
4.1 空栈撤销与空栈恢复
如果用户连续执行了一次Undo之后还想再Undo,此时undo_stack是空的。标准做法是让方法返回False,而不是抛IndexError,也不要让程序崩溃。同理,Redo栈在刚启动时一定是空的,redo()同样要安全返回。
这属于“接口健壮性”的一部分。笔试判题只看输出结果,可能不会特别罚空栈场景,但在后续面试讲解里,如果你主动提到“我让undo()和redo()在空栈时返回False,而不是破坏调用方逻辑”,这是一个稳定的加分点。
4.2 撤销之后做新操作:Redo栈必须清空
这是整道题最容易被忽略、也最容易在真实产品里被吐槽的坑。假设当前文本是AB,你执行了一次删除,把B删掉了,紧接着Undo,文本回到AB。此时用户没有按Redo,而是直接输入字母C,文本变成ACB。那么现在Redo应该做什么?
几乎所有主流编辑器的答案都是:什么都不做,Redo分支作废。因为用户通过新的编辑动作表示“我选择了一条新的未来”,之前那条“重新删除B”的历史分支不再有意义。如果不清空redo_stack,用户按一下Redo,字符B又会莫名消失,整个编辑器的行为就变成不可预测的灾难。
这就是我代码里insert和delete方法都调用redo_stack.clear()的原因。这一段可以放在解释代码时重点强调:任何新增正菜改动的操作,都必须终结当前的Redo历史。
4.3 索引校验和中文文本
文本索引必须做范围检查,否则删除时很容易出现IndexError,或者删错字符。我在代码里用了两层保护:插入时限制0 <= pos <= len(text),删除时限制pos >= 0且pos + length <= len(text)。笔试的输入通常保证合法,但写上校验会显得代码更成熟。
中文文本方面,Python的str天然按Unicode代码点存储,len("你好")等于2,普通中文按字符索引没问题。但如果你在答题时错误地使用了bytearray或按字节切分,中文场景就会立刻出错。所以代码里所有字符串处理都应该基于str,不要自己做字节转换。万一题面特意提到中英文混合输入,你可以明说:Python的字符串切片天然按字符处理,不需要额外适配。
4.4 历史栈无限增长怎么办
如果操作序列有10万条,undo_stack就会存10万条命令。对于笔试,这通常不是问题,但如果你在表达“可扩展性”,可以提两个优化方向。
第一个是用collections.deque限制最大历史长度:
from collections import deque undo_stack = deque(maxlen=1000) redo_stack = deque(maxlen=1000)当历史超过1000条时,最老的历史会被自动丢弃,从而控制内存占用。第二个优化方向是把连续输入合成一个批次命令。例如用户一次输入一整段英文,系统底层可能收到20次单字符插入,正常情况下谁也不想按20次Ctrl+Z才能撤销一句话。你可以设计一个BatchCommand,内部保存一个命令列表,undo时从后往前依次撤销组内所有子命令。这在真实编辑器里几乎是刚需。
5. 从笔试题到真实系统:撤销/恢复还能怎么用
这道题不止是面试题。我在实际项目里处理审批流回滚、配置发布和任务编排时,多次用到了同一个模型。学会了这个思想,你再见到“撤销”两个字就不会只在文本框里打转。
5.1 命令对象才是生产环境的正解
笔试用元组是为了短平快,但生产代码里命令类型会越来越多,继续在undo()里写一大串if cmd_type == ...会让函数越来越难维护。这时候要把元组升级成真正的命令对象:
class Command: def do(self, editor): raise NotImplementedError def undo(self, editor): raise NotImplementedError class InsertCommand(Command): def __init__(self, pos, text): self.pos = pos self.text = text def do(self, editor): editor.text[self.pos:self.pos] = list(self.text) def undo(self, editor): del editor.text[self.pos:self.pos + len(self.text)]新增一种操作时,只需要新建一个命令类,实现do和undo两个方法,双栈的流转逻辑完全不用改。这是命令模式最大的价值:把变化封装起来,让管理历史的框架保持稳定。
5.2 审批、任务编排和配置发布里的“撤回”
业务系统里最常见的撤销场景是一次审批动作。比如审批人通过了某个申请,后来发现信息有误,想把这次“通过”撤回。如果整个系统没有操作历史,就只能写一堆和业务强耦合的回滚代码。用命令模式之后,每次审批动作就是一个命令对象:
do():更新状态为“已通过”,写入审批意见。undo():把状态改回“待审批”,清空审批意见并写入撤销记录。
任务编排更是如此。并行任务里执行了步骤1、步骤2,如果步骤4失败,往往需要把步骤2和步骤1的效果逐层撤销。这本质上就是双栈里的连续Undo,只是每个命令的undo()方法变成了真实的接口回滚调用。
这套机制落地的关键点是持久化。编辑器可以只在内存里保存历史,但业务系统一旦重启,内存历史就没了。实际工程里通常会把命令事件写进一张操作日志表,重新恢复时重放或者逆向补偿。笔试题不要求这一步,但你懂这个延伸方向,会给面试官留下“有工程全局观”的印象。
5.3 如果再做一次笔试,我会按照这个顺序答题
复盘完这道题,我给自己总结了一套答题顺序,可以当作参考。
第一,先用一句话给出方案:用双栈维护操作历史,每条操作记录正向和反向效果。第二,主动划清执行规则:新操作清空Redo栈,Undo执行反向动作,Redo执行正向动作。第三,先实现核心的文本变更,再补空栈判断和索引边界。第四,如果时间充裕,补充内存优化思路,比如限制历史栈长度或者合并批量输入。
按这个顺序写,即使最后代码因为个别字符处理没完全跑通,你的整体思路也已经清楚展示给阅卷方了。我在实际帮人复盘的过程中见过太多“会写但不会组织答案”的代码,其实技术差距不大,差的只是有没有把最关键的设计先说出口。
最后再补一句个人体会:撤销/恢复这类题目,表面考栈,实际考的是“可逆操作建模”。你把这个模型刻在脑子里,再去写编辑器、审批流、配置中心,看到“回滚”两个字都会很自然地想到同一套解法。