【算法-2 前端三剑客-26/Lesson44(2025-11-26)】栈(Stack)详解:从原理到实现,从数组到链表,再到 LeetCode 实战🧠

66 阅读6分钟

🧠栈(Stack)是一种基础但极其重要的线性数据结构,在计算机科学中广泛应用于函数调用、表达式求值、括号匹配、浏览器历史记录、撤销操作等场景。本文将深入剖析栈的定义、特性、实现方式(基于数组和链表)、时间与空间复杂度分析,并结合 LeetCode 第 20 题“有效的括号”进行实战演练。


🔁 什么是栈?

栈是一种 先进后出(First In Last Out, FILO)或 后进先出(Last In First Out, LIFO) 的抽象数据类型(Abstract Data Type, ADT)。你可以把它想象成一摞盘子:你只能在最上面放盘子(入栈),也只能从最上面拿走盘子(出栈)。不能直接从中间或底部操作。

✅ 栈的核心操作(方法)

一个标准的栈通常支持以下基本操作:

  • push(item):将元素压入栈顶。
  • pop():移除并返回栈顶元素。
  • peek() / top():仅查看栈顶元素,不移除。
  • isEmpty():判断栈是否为空。
  • size():返回栈中元素的数量。
  • (可选)toArray():将栈内容转换为数组(便于调试或展示)。

💻 ES6 Class 与封装:现代 JavaScript 中的栈实现

ES6 引入了 class 语法,使得面向对象编程更加直观。同时,通过私有属性(以 # 开头)可以很好地封装内部实现细节,提升代码的安全性和可维护性。

📦 私有属性与访问器(get/set)

  • 私有属性(如 #stack#stackPeek):只能在类内部访问,外部无法直接读写,防止误操作。
  • get 访问器:允许像读取属性一样调用方法(如 stack.size 而非 stack.size())。
  • set 访问器:可用于拦截赋值操作(本文未使用,但常用于数据校验)。

📊 基于数组实现栈(ArrayStack)

数组是 JavaScript 中最自然的栈实现方式,因为其原生就支持 push()pop() 操作,且它们的时间复杂度在大多数情况下为 O(1)。

📄 代码实现(来自 4.js

class ArrayStack {
  #stack; // 私有属性,封装内部数组

  constructor() {
    this.#stack = []; // 初始化为空数组
  }

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

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

  push(num) {
    this.#stack.push(num);
  }

  pop() {
    if (this.isEmpty()) throw new Error('栈为空');
    return this.#stack.pop();
  }

  peek() {
    if (this.isEmpty()) throw new Error('栈为空'); // 注意:原文此处有笔误,应调用 isEmpty()
    return this.#stack[this.size - 1];
  }

  toArray() {
    return this.#stack; // 注意:这里返回的是内部数组的引用,若需完全隔离应返回副本 [...this.#stack]
  }
}

⚡ 时间与空间效率分析(数组栈)

  • 时间复杂度

    • push / pop / peek / isEmpty / size平均 O(1)
    • 注意:当数组容量不足时,JavaScript 引擎会自动扩容(通常是倍增),此时 push 操作需要复制所有元素到新数组,最坏情况为 O(n) 。但由于扩容是低频事件,摊还(Amortized)。
  • 空间复杂度

    • 可能造成空间浪费:数组预分配的内存可能大于实际元素数量。
    • 内存连续,缓存友好,访问速度快。

🔗 基于链表实现栈(LinkedListStack)

链表通过动态分配节点来存储数据,天然支持 O(1) 的插入和删除,非常适合实现栈。

📄 代码实现(来自 3.js

// 链表节点
class ListNode {
  constructor(val) {
    this.val = val;
    this.next = null;
  }
}

// 链表栈
class LinkedListStack {
  #stackPeek; // 栈顶指针(指向头节点)
  #size = 0;

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

  push(num) {
    const node = new ListNode(num);
    node.next = this.#stackPeek; // 新节点指向原栈顶
    this.#stackPeek = node;      // 栈顶更新为新节点
    this.#size++;
  }

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

  pop() {
    const num = this.peek();
    this.#stackPeek = this.#stackPeek.next; // 栈顶下移
    this.#size--;
    return num;
  }

  get Size() { // 注意:命名风格不一致,建议统一为 size
    return this.#size;
  }

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

  toArray() {
    let node = this.#stackPeek;
    const res = new Array(this.Size);
    // 从栈顶到栈底遍历,但数组索引从后往前填,保证顺序一致
    for (let i = res.length - 1; i >= 0; i--) {
      res[i] = node.val;
      node = node.next;
    }
    return res;
  }
}

⚡ 时间与空间效率分析(链表栈)

  • 时间复杂度

    • 所有核心操作(push, pop, peek, isEmpty, size)均为 严格的 O(1) ,无摊还成本。
  • 空间复杂度

    • 每个节点需要额外空间存储指针next),因此 内存开销比数组大
    • 无空间浪费:按需分配,用多少占多少。

🆚 数组栈 vs 链表栈 总结

特性数组栈链表栈
时间效率平均 O(1),最坏 O(n)(扩容)严格 O(1)
空间效率可能有浪费,但内存连续无浪费,但每个节点有额外开销
适用场景元素数量可预估,追求速度元素数量变化剧烈,追求稳定

🧪 栈的经典应用:LeetCode 20. 有效的括号

题目链接leetcode.cn/problems/va…

🎯 题目描述

给定一个只包括 '('')''{''}''['']' 的字符串 s,判断字符串是否有效。

有效字符串需满足:

  1. 左括号必须用相同类型的右括号闭合。
  2. 左括号必须以正确的顺序闭合。
  3. 每个右括号都有一个对应的相同类型的左括号。

💡 解题思路

这正是栈的典型应用场景!

  1. 遇到左括号(, [, {)→ 入栈

  2. 遇到右括号出栈,并与当前右括号匹配。

    • 若栈空(无左括号可匹配)→ 无效。
    • 若弹出的左括号与当前右括号不匹配 → 无效。
  3. 遍历结束后,若栈非空(有多余左括号)→ 无效;否则有效。

📄 实现一:使用 Map 映射(来自 readme.md

var isValid = function(s) {
  const stack = [];
  const map = new Map([
    ['(', ')'],
    ['[', ']'],
    ['{', '}'],
  ]);

  for (let i = 0; i < s.length; i++) {
    const char = s[i];
    if (map.has(char)) {
      stack.push(char); // 入栈左括号
    } else {
      if (stack.length === 0) return false; // 栈空却遇右括号
      const top = stack.pop(); // 出栈
      if (map.get(top) !== char) return false; // 不匹配
    }
  }
  return stack.length === 0; // 最终栈必须为空
};

📄 实现二:直接存储期望的右括号(来自 5.js

这是一种巧妙的优化:入栈时不存左括号,而是存它对应的右括号。这样在遇到右括号时,只需直接比较即可,无需查表。

const leftToRight = { '(': ')', '[': ']', '{': '}' };

const isValid = function(s) {
  if (!s) return true;
  const stack = [];
  const len = s.length;

  for (let i = 0; i < len; i++) {
    const ch = s[i];
    if (ch === "(" || ch === "[" || ch === "{") {
      stack.push(leftToRight[ch]); // 入栈期望的右括号
    } else {
      if (!stack.length || stack.pop() !== ch) {
        return false; // 栈空或不匹配
      }
    }
  }
  return !stack.length;
};

⏱️ 复杂度分析

  • 时间复杂度:O(n),每个字符最多入栈和出栈一次。
  • 空间复杂度:O(n),最坏情况下(全是左括号),栈大小为 n。

🧱 补充:原始数组操作(来自 1.js2.js

即使不封装成类,也可以直接用数组模拟栈:

// 来自 2.js
const stack = [];
stack.push(1); // 入栈
console.log(stack[stack.length - 1]); // peek: 1
const pop = stack.pop(); // 出栈
const size = stack.length;
const isEmpty = stack.length === 0;

注意:虽然 arr.unshift()arr.shift() 也能模拟栈(在头部操作),但它们的时间复杂度是 O(n),强烈不推荐用于栈实现。栈应始终在尾部push/pop)操作以保证 O(1) 效率。


🏁 总结

栈作为一种简洁而强大的数据结构,其核心在于 LIFO 的操作原则。无论是使用 数组(简单高效,适合大多数场景)还是 链表(稳定 O(1),适合动态性强的场景)来实现,都能很好地服务于各种算法问题。

通过 LeetCode “有效的括号”这一经典例题,我们不仅巩固了栈的操作,还学习了两种不同的编码思路(存左括号 vs 存期望右括号),体现了算法思维的灵活性。

掌握栈,是通往更复杂数据结构(如队列、树、图)和算法(DFS、表达式解析)的重要基石。🧱✨