栈(Stack):从“弹夹”到算法面试题的进阶之路

前言

今天我们要聊的是数据结构界的"老实人"------栈(Stack) 。为什么说它老实?因为它只有一条规矩,而且死守到底:先进后出(FILO - First In, Last Out)

想象一下你洗盘子,洗好的盘子一个接一个往上摞(入栈),取盘子时只能从最上面拿(出栈)。如果你非要抽中间的盘子?抱歉,那叫"塌方",不叫数据结构。

本文将带你从 JS 数组的原生操作开始,利用 ES6 Class 封装高逼格的栈,对比数组与链表的底层优劣,最后用一道经典面试题来实战演练。


一、JS 数组------天生的"栈"

在 JavaScript 的世界里,数组(Array)就是一个开箱即用的超级工具。它不仅能当列表用,配合几个 API,它立刻就能变身成一个栈。

核心操作

JS 数组提供了完美适配栈语义的方法:

  • push(element) :入栈(压栈)。往数组尾部塞东西。
  • pop() :出栈(弹栈)。把数组尾部的东西扔出来。
  • peek (手动实现) :偷看一眼栈顶是谁(array[length - 1])。
codeJavaScript 复制代码
const stack = [];

// 入栈:像是往弹夹里压子弹
stack.push(1);
stack.push(2);
stack.push(3);

console.log(stack); // [1, 2, 3]

// 访问栈顶(不删除):看看下一发子弹是什么
const peek = stack[stack.length - 1];
console.log(`栈顶元素: ${peek}`); // 3

// 出栈:发射!
const popped = stack.pop(); 
console.log(`弹出的元素: ${popped}`); // 3
console.log(stack); // [1, 2]

// 警告:不要用 shift/unshift 模拟栈!
// 虽然 stack.unshift(val) 和 stack.shift() 也能实现先进后出(在头部操作)
// 但会导致数组内所有元素下标移动,时间复杂度是 O(n),效率极低。
// 请认准 push/pop 组合,它们通常是 O(1) 的。

二、面向对象封装------做个有"边界感"的栈

虽然数组很好用,但在大型项目中,直接暴露数组是很危险的。万一哪个实习生手抖写了个 stack[2] = 'hack',破坏了栈的结构怎么办?

我们需要用 ES6 的 Class 把实现细节藏起来,只暴露标准接口。这叫封装,也是 ADT(抽象数据类型)的精神所在。

数组实现版 (ArrayStack)

我们要用到 ES6 的黑科技:私有属性(Private Fields)

  • #stack:加了 # 号,外部就无法通过 instance.#stack 访问。这是真正的"隐私保护"。
  • get size() :访问器属性,让你像访问变量一样访问方法。
codeJavaScript 复制代码
class ArrayStack {
  // 私有属性,底层是一个数组
  #stack;

  constructor() {
    this.#stack = [];
  }

  // 入栈
  push(val) {
    this.#stack.push(val);
  }

  // 出栈
  pop() {
    if (this.isEmpty()) throw new Error('栈为空,没东西给你弹了');
    return this.#stack.pop();
  }

  // 查看栈顶
  peek() {
    if (this.isEmpty()) throw new Error('栈为空');
    return this.#stack[this.size - 1];
  }

  // 这里的 get 关键字,让我们能用 stack.size 而不是 stack.size()
  get size() {
    return this.#stack.length;
  }

  isEmpty() {
    return this.size === 0;
  }

  // 为了调试方便,允许转换成普通数组查看
  // 使用扩展运算符复制一份,防止外部修改内部引用
  toArray() {
    return [...this.#stack];
  }
}

const s = new ArrayStack();
s.push(100);
s.push(200);
console.log(s.size); // 2
// console.log(s.#stack); // 报错!SyntaxError,保护成功

三、链表实现版------底层的"浪漫"

如果我们不准用数组呢?或者我们需要极致的扩容性能呢?这时 **链表(Linked List)**就登场了。

我们通过节点(Node)的指针连接来实现栈。

  • 栈顶指针 (stackPeek) :永远指向链表的头部(Head)。
  • 入栈:新建节点 -> 指向原栈顶 -> 更新栈顶指针。
  • 出栈:保存原栈顶值 -> 栈顶指针后移。
codeJavaScript 复制代码
// 节点类:链表的最小单元
class ListNode {
  constructor(val) {
    this.val = val;
    this.next = null;
  }
}

class LinkedListStack {
  #stackPeek; // 这是一个指针,指向栈顶节点
  #size = 0;

  constructor() {
    this.#stackPeek = null;
  }

  push(num) {
    const node = new ListNode(num);
    // 关键步骤:把新节点"骑"在旧栈顶头上
    node.next = this.#stackPeek;
    this.#stackPeek = node;
    this.#size++;
  }

  pop() {
    if (!this.#stackPeek) throw new Error('栈为空');
    const num = this.#stackPeek.val;
    // 关键步骤:丢弃头节点,让下一个节点上位
    this.#stackPeek = this.#stackPeek.next;
    this.#size--;
    return num;
  }

  peek() {
    if (!this.#stackPeek) throw new Error('栈为空');
    return this.#stackPeek.val;
  }

  get size() {
    return this.#size;
  }

  toArray() {
    const res = [];
    let node = this.#stackPeek;
    while (node) {
      res.push(node.val);
      node = node.next;
    }
    return res; // 注意:这里生成的数组,索引0是栈顶元素
  }
}

四、巅峰对决------数组 vs 链表

这两种实现各有千秋。这是一个非常经典的面试考点。

1. 时间效率(Time Complexity)

  • 数组实现

    • 平均情况: O(1)

      :入栈出栈极快,因为内存是连续的。

    • 最坏情况:O(n)

      扩容(Resizing) 当数组满了,JS 引擎需要在内存中找一块更大的地盘,把旧数据全部复制过去,这一瞬间,性能会"卡顿"一下。

  • 链表实现

    • 稳定: O(1),不管有多少数据,入栈永远只是改个指针指向,没有"扩容"的烦恼。

    • 缺点:虽然理论上是 O(1) ,但每次 new ListNode 都有内存分配的开销,且不像数组那样对 CPU 缓存友好。

2. 空间效率(Space Complexity)

  • 数组 :可能有空间浪费。为了防止频繁扩容,数组通常会预分配比实际需要更多的内存(比如你存了5个,它实际占了10个坑)。
  • 链表额外开销。每个数据不仅要存值,还要存一个 next 指针(引用)。如果存的是简单的整数,指针占用的内存可能比数据本身还大。

结论 :在前端开发中,数据量通常不会大到让数组扩容成为瓶颈,且 JS 引擎对数组做了极致优化,首选数组实现。但在系统级编程或对时延极其敏感的场景下,链表更稳定。


五、实战应用------有效的括号

光说不练假把式,我们来看一个经典的算法题:LeetCode 20. 有效的括号

题目 :判断字符串中的括号 (), [], {} 是否合法闭合。
思路

  1. 遇到左括号:压入栈。

  2. 遇到右括号:弹出栈顶,看看是不是配套的左括号,如果不配套,或者栈已经空了,说明不合法。

  3. 遍历结束:栈必须为空(所有左括号都找到了归宿)。

codeJavaScript 复制代码
const isValid = function (str) {
  if (!str) return true;
  
  // 映射表:右括号 -> 对应的左括号
  // 这样我们可以通过右括号快速查找它期待的"另一半"
  const rightToLeft = {
    ')': '(',
    '}': '{',
    ']': '['
  };
  
  const stack = [];
  
  for (let i = 0; i < str.length; i++) { // 原代码 bug: str.len -> str.length
    const ch = str[i];
    
    // 如果是左括号,入栈
    if (ch === '(' || ch === '[' || ch === '{') {
      stack.push(ch);
    } 
    // 如果是右括号
    else {
      // 1. 栈里是空的(说明前面没有左括号,居然先来了个右括号?) -> 也就是 false
      // 2. 弹出的左括号和当前的右括号不匹配 -> false
      // 原代码 bug: leftToright.ch -> rightToLeft[ch] (属性访问要用[])
      if (!stack.length || stack.pop() !== rightToLeft[ch]) {
        return false;
      }
    }
  }
  
  // 遍历完了,栈里必须干净才算赢
  return stack.length === 0;
};

// 测试用例
console.log(isValid("()[]{}")); // true
console.log(isValid("(]"));     // false
console.log(isValid("([)]"));   // false
console.log(isValid("{[]}"));   // true

六、 写在最后:如何在面试中优雅地聊栈?

面试官:"用 JavaScript 实现一个栈呗?"

你(面不改色):

"看需求哈~ 日常开发我直接用数组,三行搞定,性能拉满。 如果需要严格私有属性和防篡改,我就写个 class 封装。 真要聊底层实现,我还能甩个链表版,时间复杂度永远 O(1),不过实际项目里基本不用,因为缓存不友好。 哦对了,顺手还能写个括号匹配玩玩~"

说完淡定喝一口水,面试官已经在狂点头了。

总结

  1. 栈(Stack)先进后出的线性结构,就像堆盘子。
  2. JS 数组通过 push/pop 原生支持栈操作,简单高效。
  3. ES6 Class 可以让我们封装出更安全(私有属性)、更规范的栈数据结构。
  4. 链表实现避免了数组扩容的性能抖动,但牺牲了空间去存指针。
  5. 算法应用:遇到"配对"、"回溯"、"撤销操作"类的问题,第一时间想到栈。

栈是数据结构里最"单纯"的一个家伙:只有一头开口,像个死心眼的管子。但就是这么个简单的东西,撑起了递归、表达式解析、DFS......整个计算机世界的半壁江山。

记住一句话:"当你不知道用啥数据结构的时候,先试试栈,八成能行。"

希望这篇整理能让你对栈的理解更上一层楼!Happy Coding!

相关推荐
烟袅1 小时前
作用域链 × 闭包:三段代码,看懂 JavaScript 的套娃人生
前端·javascript
San30.1 小时前
深入理解 JavaScript 异步编程:从 Ajax 到 Promise
开发语言·javascript·ajax·promise
抱琴_1 小时前
大屏性能优化终极方案:请求合并+智能缓存双剑合璧
前端·javascript
2301_764441332 小时前
Python构建输入法应用
开发语言·python·算法
颜酱2 小时前
开发工具链-构建、测试、代码质量校验常用包的比较
前端·javascript·node.js
AI科技星2 小时前
为什么变化的电磁场才产生引力场?—— 统一场论揭示的时空动力学本质
数据结构·人工智能·经验分享·算法·计算机视觉
mCell2 小时前
[NOTE] JavaScript 中的稀疏数组、空槽和访问
javascript·面试·v8
柒儿吖2 小时前
Electron for 鸿蒙PC - Native模块Mock与降级策略
javascript·electron·harmonyos
豆奶特浓62 小时前
Java面试生死局:谢飞机遭遇在线教育场景,从JVM、Spring Security到AI Agent,他能飞吗?
java·jvm·微服务·ai·面试·spring security·分布式事务