← 返回题目列表

撤销和重做功能为什么常用两个栈设计?

中等 第 21 / 30 题 更新于 2026/07/30
撤销重做命令模式

简化版

撤销/重做可以用两个栈:undo 栈保存已执行操作,redo 栈保存刚撤销的操作。执行新操作时压入 undo 并清空 redo;撤销时从 undo 弹出并反向执行,再压入 redo;重做时从 redo 弹出重新执行,再压回 undo。

详细版

撤销恢复的是最近一次操作,所以天然适合栈。重做恢复的是最近被撤销的操作,也适合栈。

关键规则:

  • 每个操作最好封装成命令,包含 executeundo
  • 新操作执行成功后进入 undo 栈。
  • 撤销时调用 undo,操作进入 redo 栈。
  • 重做时调用 execute,操作回到 undo 栈。
  • 一旦执行新操作,redo 栈清空。

难点在于操作必须可逆,或者至少能保存足够快照恢复旧状态。

完整版教学

一、撤销为什么是后进先出

用户连续输入 A、B、C,第一次撤销应该撤销 C,第二次撤销 B,第三次撤销 A。最近发生的操作最先被撤销,这就是 LIFO。

undoStack: [A, B, C]
撤销顺序:C -> B -> A

所以撤销栈保存的是“已经执行且可以反向恢复”的操作。栈顶永远是下一次撤销的目标。

二、redo 栈保存什么

撤销 C 后,C 不应该丢失,因为用户可能点击重做。redo 栈保存的是“刚被撤销、可以重新执行”的操作。

撤销 C 后:
undoStack: [A, B]
redoStack: [C]

如果再撤销 B,redo 栈变成 [C, B],栈顶 B 应该先被重做。因为用户撤销顺序是 C、B,重做时应该先把 B 恢复,再恢复 C,线性历史才一致。

三、为什么新操作会清空 redo

假设执行 A、B、C 后撤销 C,此时可以重做 C。如果用户不重做,而是执行新操作 D,那么历史变成 A、B、D。原来的 C 不再是当前历史的未来。

旧历史:A -> B -> C
撤销到:A -> B
新操作:A -> B -> D

这和浏览器历史类似:从过去某点走出新路径,原来的前进路径失效。因此执行新操作后必须清空 redo 栈。

四、命令对象应该保存哪些信息

一个可撤销命令通常要保存两类信息:如何执行,以及如何恢复。

例如文本插入命令:

command = {
  execute() { insert(position, text); },
  undo() { deleteRange(position, text.length); }
}

如果是替换文本,则需要保存旧内容:

replace(pos, oldText, newText)
undo 时用 oldText 恢复

这说明撤销系统不是只有两个栈,栈里存的命令本身也要设计好状态。

五、快照和反向操作怎么选择

实现撤销有两种常见思路:保存反向操作,或保存操作前后的快照。

方式优点缺点
反向操作空间小,适合细粒度编辑操作必须可逆
状态快照恢复简单空间大
增量快照折中实现复杂

文本编辑器常保存增量操作;图像编辑或复杂文档有时会用快照或分层快照。选型取决于状态大小和操作可逆性。

六、异常和合并操作要考虑

如果 execute 失败,不应该压入 undo 栈。否则撤销一个没有成功执行的操作会造成状态错乱。

另外,连续输入字符可能不希望每个字符都成为一次撤销。工程上会把短时间连续输入合并成一个命令,例如 1 秒内输入的 hello 合成一次撤销单位。

记忆钩子:undo 栈管过去,redo 栈管刚撤销的未来;新操作会开新历史线,所以 redo 必清空。

七、常见误区与追问

  • 误区:撤销栈只需要保存操作名字。 必须保存足够恢复状态的信息,比如位置、旧值、新值。
  • 误区:执行新操作后 redo 还能保留。 新操作改变历史分支,redo 路径已经失效。
  • 误区:所有操作都天然可撤销。 外部副作用如发邮件、扣款、网络请求需要补偿机制,不能简单 undo。
  • 追问:撤销失败怎么办? 要有事务边界或错误恢复策略,避免 undo 栈和真实状态不一致。
  • 追问:空间复杂度如何控制? 限制历史步数、合并小操作、使用增量记录或周期性快照。

八、加强记忆

撤销重做的两个栈模型很干净:执行进 undo,撤销进 redo,重做回 undo,新操作清 redo。真正难点在栈元素,不是栈本身;命令必须知道怎么执行、怎么反向恢复,以及失败时怎么保持状态一致。