你的撤销按钮只是一堆煎饼

发布: (2026年3月10日 GMT+8 06:06)
5 分钟阅读
原文: Dev.to

Source: Dev.to

TL;DR: 我使用栈(Stack)数据结构实现撤销功能,因为它遵循后进先出(LIFO)原则。每一次状态变化都会压入栈中。当我触发撤销时,弹出最近的操作,以 O(1) 的时间恢复应用状态,而无需重新索引整个数组的开销。

为什么撤销功能总是使用栈?

我使用栈是因为它强制保持严格的线性历史,唯一可访问的元素是最近的那个。这确保每一次撤销调用都恰好逆转最后一次执行的操作,防止状态损坏并将指针开销保持在最低。

在构建需要跟踪状态的应用时,遍历巨大的数组或为每个输入的字符移动索引是低效的。栈让我可以把状态快照放在顶部,并同样快速地取回。对栈顶进行压入和弹出是常数时间操作,符合我们在事件序列中回溯的自然方式。

煎饼类比是如何解释 LIFO 的?

LIFO(后进先出)意味着我放在盘子最上面的那块煎饼是我最先吃的。在软件中,我压入历史栈的最后一个操作是用户点击 撤销 时最先弹出的。

如果我尝试把新煎饼塞到堆底部,就会出现 O(n) 的操作并导致早餐“碎掉”——就像会产生索引移动的糟糕架构。相反,我把新操作压在顶部。需要撤销时,我取走顶部的煎饼;从底部取出会导致索引移动的噩梦和状态崩溃。

栈 vs. 数组:为何历史记录更适合栈的效率

功能栈(LIFO)标准数组
插入复杂度O(1) – 常数时间 pushO(n) – 若需移到索引 0 时
删除复杂度O(1) – 常数时间 popO(n) – 若需重新索引历史
索引管理无需(只需顶指针)高(需要跟踪移动的索引)
实现工作量最小(原生 push/pop)较大(手动 slice/splice)

如何在真实的状态管理循环中实现?

// Simple undo stack implementation
const history = [];

const trackChange = (state) => {
  history.push(state);
};

const handleUndo = () => {
  if (history.length < 2) return; // Need at least one previous state
  history.pop(); // Remove current state
  const prevState = history[history.length - 1];
  applyToUI(prevState);
};

如何管理庞大栈的内存占用?

我通过对栈深度设定硬限制(例如 100 条操作)来防止撤销栈无限增长。当超过限制时,我会移除最旧的条目(底部元素)或将栈视为循环缓冲区。这种权衡会丢弃非常久远的历史,但能防止应用耗尽内存。

FAQ

为什么不使用队列来实现撤销?

队列遵循 FIFO(先进先出)逻辑。使用队列进行撤销会把应用打开时的 第一个 操作撤回,而不是最近的错误——这会导致糟糕的用户体验。

“重做”按钮如何与栈配合工作?

我维护第二个栈用于 Redo。当我从撤销栈弹出一个状态时,会把它压入重做栈。如果用户进行新的更改,重做栈会被清空。这相当于在两盘煎饼之间来回移动。

能否存储差异(diff)而不是完整的状态快照?

可以。对于大型应用,仅存储 diff(具体的命令或变化)而不是完整快照可以降低 RAM 使用,同时仍保持 O(1) 的栈操作。这使得每块“煎饼”的内存占用保持在较小水平。

0 浏览
Back to Blog

相关文章

阅读更多 »

使用最大熵求解 Mastermind

概述 该思路是选择能够提供最多信息的猜测,并尽可能快速地减少可能的代码数量。使用此方法,代码…

Java 中的 Two Sum 问题

Two‑Sum问题 – Java中的不同实现 方法 Two‑Sum问题是开发者面试中最常见的问题之一。给定一个整数数组…