第 4 篇|栈:最后放上去的最先拿

5 阅读5分钟

「最后压上去的那张纸,永远最先被抽走。」——除非你从底下硬撕,那叫 undefined behavior,别学。


本篇大纲

1. 栈是啥(定义)

  • 抽象栈(stack)是一种只能在一端进出的线性结构;这一端常叫栈顶
  • 规矩后进先出,英文 LIFO(Last In, First Out)——像叠盘子:最后放上去的最先拿
  • 基本操作入栈 push出栈 pop;常再配合 peek / top(只看栈顶不出栈)、判空
  • 复杂度直觉:在链表或动态数组实现的栈上,push / pop / peek 通常都是 O(1)——只动栈顶,不搬全队。

2. 和数组啥关系(在 JS 里怎么玩)

  • 教材图:栈是抽象数据类型数组是实现方式之一。
  • JavaScript:用 [] + push + pop 就能模拟栈——尾部当栈顶(别用 shift 当「出栈」,那是队列味,还 O(n) 肉疼)。
  • 手写最小接口:用 class + 实例字段(如 this._items)包一层也行,核心是约定哪一端是顶,全队别乱摸。

3. 栈在题里怎么出现(套路)

  • 括号匹配HTML 标签配对(思想同源):遇到左符号入栈,右符号跟栈顶对账。
  • 路径简化.../):目录进栈出栈,像文件管理器里「返回上一级」。
  • 单调栈DFS 非递归版:后面章节会再见面;本篇先把 LIFO 刻进肌肉记忆。

4. 在前端脑子里栈长啥样(用在哪)

  • 浏览器历史路由回退:新页面压栈,点「后退」弹栈——本篇用纯数组模拟,不写任何框架路由 API。
  • 撤销(Undo):每次操作记一条,撤销时从栈顶撤回——和「后退」是同一套时间倒流叙事。
  • 函数调用栈:报错时控制台里那一串 at xxx,就是栈;懂栈就懂「为啥递归太深会爆」。

形象化:叠盘子、后退键、调用栈

  • 叠盘子:你只能从最上面拿;中间硬抽会塌——栈也是:只认栈顶
  • 后退:你依次进了 A → B → C,点后退是 C → B → A,不是「随机跳到某年某月」——最近的路最先撤销,正是 LIFO。
  • 函数调用abbcc 跑完先返回 b,再返回 a——后进入的先结束,还是栈。

人话:「最后发生的事,最先被抹掉。」O(1) 的 push/pop,比背定义好使。


栈操作速查(数组模拟)

脱离场景死背没意义,但写业务、刷题时,这几个名字要对得上号:

操作数组模拟(栈顶在尾部)复杂度直觉注意
入栈stack.push(x)O(1) 均摊约定好哪一端是顶,别两头混用
出栈stack.pop()O(1)空栈再 popundefined,要判空
看栈顶stack[stack.length - 1]O(1)只看不弹
判空stack.length === 0O(1)

坏味道:用 shift / unshift 当栈——相当于从队头当栈顶,数组要整体挪,O(n),列表一长就哭。

坏 vs 好(同一语义:连续弹三次「栈顶」)

// 坏:把「栈顶」放在数组头部 —— 每次 shift 全表前移,O(n)
let bad = [1, 2, 3];
bad.shift();
bad.shift();
bad.shift();

// 好:栈顶在尾部 —— pop 只动末尾,O(1)
let good = [1, 2, 3];
good.pop();
good.pop();
good.pop();

复杂度直觉才会拒绝「图省事把栈顶放前面」;面试里这也是常问的实现细节

下面给一个小封装,后面例题直接复用(实例字段 _items 存栈底到栈顶,栈顶在数组尾部;下划线表示「按约定别从外面直接改」):

class Stack {
  constructor() {
    this._items = [];
  }
  push(x) {
    this._items.push(x);
  }
  pop() {
    return this._items.pop();
  }
  peek() {
    const a = this._items;
    return a.length ? a[a.length - 1] : undefined;
  }
  get size() {
    return this._items.length;
  }
  isEmpty() {
    return this._items.length === 0;
  }
}

// 使用示例(可整段贴进控制台跑)
const s = new Stack();
console.log(s.isEmpty()); // true

s.push('A');
s.push('B');
console.log(s.peek(), s.size); // 'B', 2

console.log(s.pop()); // 'B'
console.log(s.peek()); // 'A'
console.log(s.pop()); // 'A'
console.log(s.isEmpty()); // true
console.log(s.pop()); // undefined(空栈再 pop)

小算法 1:括号是否匹配

题意:字符串里只有 '(' ')' '[' ']' '{' '}',判断是否合法配对且顺序正确

思路:左括号一律入栈;遇到右括号,若栈空或栈顶类型不对,当场 false;否则弹栈一对消掉。扫完栈必须

function isValidParentheses(s) {
  const stack = [];
  const pair = { ')': '(', ']': '[', '}': '{' };

  for (const ch of s) {
    if ('([{'.includes(ch)) {
      stack.push(ch);
    } else {
      if (stack.length === 0 || stack[stack.length - 1] !== pair[ch]) {
        return false;
      }
      stack.pop();
    }
  }
  return stack.length === 0;
}

console.log(isValidParentheses('()[]{}')); // true
console.log(isValidParentheses('([)]')); // false

时间 O(n)额外空间 O(n)(栈最深不超过字符串长度)。这类题练的是:最近打开的,必须最先关上——和你在 IDE 里删括号是一个道理。


小算法 2:Unix 风格路径简化

给定形如 "/a/./b/../c" 的路径,输出规范路径. 忽略,.. 弹掉上一级,多余斜杠合并。

function simplifyPath(path) {
  const parts = path.split('/').filter(Boolean);
  const stack = [];

  for (const p of parts) {
    if (p === '.' || p === '') continue;
    if (p === '..') {
      if (stack.length > 0) stack.pop(); // 已在根目录则不能再往「上」退
    } else stack.push(p);
  }

  return '/' + stack.join('/');
}

console.log(simplifyPath('/a/./b/../c')); // "/a/c"
console.log(simplifyPath('/../')); // "/"

时间 O(n)n 为分段数),栈里最多 O(n) 段。直觉:走进目录就 push,遇到 .. 就 pop——又是「最后走进去的,最先被退回」。


单页里的「后退」:用栈记住你来时的路

真实框架路由会做更多事(参数、滚动恢复、内存上限等),这里只抽算法骨架每导航到一个新「屏」就把标识压栈,点后退就弹栈

下面用纯 JavaScript 模拟「当前路径」与历史栈(无任何框架 API):

function createHistoryNavigator() {
  const stack = [];
  let current = '/';

  return {
    get path() {
      return current;
    },
    /** 进入新路径:压入「从哪来」,再更新当前 */
    push(next) {
      stack.push(current);
      current = next;
    },
    /** 后退:有历史则回到上一段 */
    back() {
      if (stack.length === 0) return current;
      current = stack.pop();
      return current;
    },
    canGoBack() {
      return stack.length > 0;
    },
  };
}

const nav = createHistoryNavigator();
nav.push('/list');
nav.push('/detail/42');
console.log(nav.path); // '/detail/42'
console.log(nav.back()); // '/list'
console.log(nav.back()); // '/'
console.log(nav.canGoBack()); // false

要点前进是 push 旧状态后退是 pop——和浏览器「历史记录栈」的直觉一致。撤销编辑器操作同理:每次改动的逆操作(或快照指针)入栈,撤销时弹顶执行逆操作。你若把「栈顶」搞反了,用户点后退却前进,那就是产品事故了——LIFO 不是摆设


小练习

  1. 说说为什么用 push/pop(尾部) 模拟栈,通常比 unshift/shift(头部) 更划算?
  2. isValidParentheses 改成只含小括号 '(' ')'** 的版本**,手写一遍循环,体会「只关心栈顶」。
  3. (思考题)若历史栈无限增长,内存会炸——真实产品里常见 限制栈深度换双端队列;你能想到一种「超过 N 条就丢掉最老的」的规则吗?

面试一句

「栈是 LIFO:数组用 push/pop 模拟栈顶 O(1);括号匹配、路径简化、路由后退和撤销都是『最近的操作最先撤回』。」


下篇预告

第 5 篇|队列:先排队的先办事——FIFO 与栈正好相反;任务排队、打印队列、并发限流都会用到。仍坚持 纯 JS