Skip to content

6. 撤销魔法(栈)

知识点:栈(stack)—— 后进先出

项目:魔法记事本(撤销功能)+ 括号配对检查


故事开场:手滑写错了,怎么办?

你正在做一个"魔法记事本"程序:可以往里面写笔记,也可以撤销上一步操作。

第一版很容易:用一个变量记住"上一次的内容":

ts
let current = ""
let last = ""          // 记住上一次的笔记

function input(text: string): void {
  last = current       // 写之前,先存个备份
  current = text
}

function undo(): void {
  current = last       // 撤销:把备份拿回来
  last = ""            // 备份用完就没了
}

运行一下试试:写"早上好",再写"中午好",然后撤销——变回"早上好",成功了!

可是老师过来一看,说:"只能撤销一次?我在写长作文,要能撤销十次! 写错了三次才反应过来,结果只能退回一步,前面的错字怎么办?"

你想了想:用一个变量不够,那就用一个数组,把每一次的备份都存下来? 可是——该从数组的哪一头拿"最近的一次"呢?


笨办法先行:一摞纸

想清楚这个问题,先做一个生活实验。

拿一叠白纸,一张一张往上放:第一张放"早上好",第二张放"中午好", 第三张放"晚上好"。

现在,你要撤销——回到"最近一次"的状态。哪一张是最新的? 当然是最上面那一张(最后放上去的)。

如果继续撤销呢?拿走最上面那张,露出来的就是"中午好";再拿一张,是"早上好"。

发现规律了吗?

最后放上去的,最先被拿走。

我们想想用数组怎么写。如果你用 push 把每一次的备份依次放进数组:

[第一版, 第二版, 第三版]   ← 第三版在最后,也就是"最上面"

那么"拿最近的一份",就是拿走数组的最后一个——用 poppush(放上去)和 pop(拿下来),天生就是一对。


引出知识点:栈——"后进先出"的一摞盘子

在计算机里,这种"只能从同一头放、从同一头拿"的结构,叫做栈(stack)

栈有个响亮的规则:后进先出(LIFO,Last In First Out)—— 最后进来的,最先出去。

生活中的栈到处都是:

  • 叠盘子:最后洗好的盘子放在最上面,用的时候先拿最上面的
  • 饼干罐:你把饼干一块块放进去,拿的时候先从最上面拿
  • 浏览器后退按钮:你逛了 A → B → C 三个网页,点"后退"回到哪个? 当然是最后去的 C 回 B,再回 A——这正是栈!
  • 撤销功能:编辑器、画图软件里的"撤销",都是一摞操作记录

栈只有两个基本动作:

动作名字干什么
push压栈把东西放到最上面
pop出栈把最上面的东西拿走

在 TypeScript 里,数组自带 pushpop——数组就是我们现成的栈!


动手实现:可以撤销十次的魔法记事本

现在重写记事本:不用一个变量,用一摞"备份":

ts
const history: string[] = []   // 一摞备份,新的备份放在最上面
let current = ""               // 现在的笔记

// 写笔记:先把当前内容存进备份摞,再写新的
function input(text: string): void {
  history.push(current)
  current = text
  console.log("笔记:" + current)
}

// 撤销:把最上面的备份拿下来
function undo(): void {
  if (history.length === 0) {
    console.log("没有可以撤销的了")
    return
  }
  const last = history.pop()        // 拿走最上面的备份
  if (last !== undefined) {
    current = last
  }
  console.log("撤销后:" + current)
}

input("早上好")
input("中午好")
input("晚上好")
undo()
undo()
undo()
undo()

输出:

笔记:早上好
笔记:中午好
笔记:晚上好
撤销后:中午好
撤销后:早上好
撤销后:
没有可以撤销的了

撤销到底,一路退回到空白("撤销后:"后面什么都没写), 再撤就提示"没有可以撤销的了"。想撤销一百次?只要备份摞里还有, 就能一直撤——写一百次笔记,就有一百份备份。一个 push 一个 pop, 全部搞定。


跑起来:括号配对检查员

栈最经典的用法,是检查括号配不配对

写数学式子时,我们规定:每个 ( 都要有一个 ) 和它配对, 而且后写的括号要先闭合——就像套娃,先打开的最后关上:

((1+2)×(3+4))   ✅ 配对
(1+2            ❌ 少了一个右括号
)(1+2(          ❌ 反了:右括号跑到了前面

怎么让程序来检查?用栈:

  • 看到 (压栈(打开一个盒子)
  • 看到 ):先检查栈里有没有 (——有,就出栈(关上一个盒子); 没有,说明这个右括号是"孤儿",配对失败
  • 全部看完:栈里还有没关上的 (?配对失败;栈空空的?配对成功!
ts
// 检查括号是否配对
function checkBrackets(text: string): boolean {
  const stack: string[] = []     // 打开的盒子摞

  for (const ch of text) {       // 把每个字符轮流拿出来
    if (ch === "(") {
      stack.push(ch)             // 看到左括号:打开一个盒子
    } else if (ch === ")") {
      if (stack.length === 0) {
        return false             // 右括号来了,盒子摞却是空的!
      }
      stack.pop()                // 关上一个盒子
    }
  }

  return stack.length === 0      // 全部关上了才算配对成功
}

console.log(checkBrackets("((1+2)×(3+4))"))  // true
console.log(checkBrackets("(1+2"))           // false
console.log(checkBrackets(")(1+2("))         // false

for (const ch of text) 是"把字符串里的每个字符轮流拿出来"的新写法—— 比用下标省事,以后会经常用到。)

输出:

true
false
false

看,短短几行代码,配对检查就完成了。这就是栈:打开关上的顺序,天然由 "后进先出"管着——后打开的盒子必须先关上,栈顶永远是最新打开的那个。


小挑战

  1. 三种括号:把检查员升级成能处理 ()[]{} 三种括号混用的版本 (提示:) 必须和栈顶的 ( 配对,] 必须和栈顶的 [ 配对—— 遇到右括号时,检查栈顶是不是对应的左括号,不是就失败)。
  2. 后退按钮:用数组模拟浏览器的后退功能:依次"访问"5 个网页, 后退 3 次,打印当前所在的网页。
  3. 反过来的密信:还记得密信章吗?用一个栈,把"放学后一起玩游戏" 一个字一个字压栈,再一个个弹出来——你会发现弹出来的顺序正好倒过来了! 用代码验证一下这个"栈版加密器"。

课后练习

第 1 题(动手题):魔法记事本实战

给记事本加一个"清空"按钮逻辑:把备份摞全部清空,笔记设为空字符串。 然后按下面的顺序测试:写"1"、写"2"、清空、撤销——会发生什么?为什么?

参考答案(点开查看)
ts
function clearAll(): void {
  history.length = 0   // 清空备份摞
  current = ""
  console.log("已清空")
}

测试结果:写"1"、写"2"、清空、撤销 → 提示"没有可以撤销的了"。

因为清空把备份摞清掉了,history.length === 0,撤销自然没有东西可用。 这很合理:清空 = 放弃所有历史,想撤销也撤销不回来了。

第 2 题(思考题):只能看见最上面的

栈有一个特点:你只能看见最上面的一层,看不见下面的。 想一想,为什么栈不让你"直接拿走最底下的盘子"?如果允许随便拿, 它还是栈吗?

参考答案(点开查看)

因为栈的规则就是"后进先出"——只能从最上面放、从最上面拿。

如果允许随便拿中间的、最底下的,那结构就不是栈了(可以拿中间的 是"数组",可以两头拿的是"双端队列")。

正是这个"只能从上面拿"的规矩,保证了撤销永远拿的是最近一次的备份。 规矩越严格,用起来越不容易出错——这是数据结构的通用道理: 每个结构把一两条规矩管死,程序就不容易乱。

第 3 题(思考题):数组就是栈?

我们说"数组自带 pushpop,就是现成的栈"。那么问题来了: 数组还允许 arr[3] = "任意位置" 这样直接改中间的格子——这是栈不允许的。

那么:程序用数组模拟栈,会不会有人偷偷"从中间拿盘子"? 这算不算破坏栈的规矩?

参考答案(点开查看)

算!用数组模拟栈,只是"借用"了数组的 push/pop,规矩靠写程序的人自觉

如果程序里别的地方不小心用了 arr[3] = ...,栈的"后进先出"就被破坏了, 撤销功能就会出错。

所以真正的程序里,会把栈封装成一个"只提供 push/pop"的小盒子, 把数组藏起来不让别人碰——以后学到"类"和"封装"时,你会明白怎么做。 (还记得魔法函数章吗?函数盒子也是这个思路:把内部藏起来,只留入口。)


下一课预告:打印店的叫号机"滴——请 3 号到柜台"。 栈是"后进先出",可是排队明明应该是先来的先办事啊?那是什么结构?