深入理解栈(Stack):从基础到实现,一文讲透!

215 阅读6分钟

引言

(Stack)是一种“后进先出”(Last In First Out, LIFO)的线性数据结构。它就像你往一个窄口瓶里放书——最后放进去的那本,总是最先被拿出来。

在本文中,我们将通过生动的比喻、清晰的代码示例和详尽的注解,带你彻底掌握栈的核心概念、常见操作、不同实现方式及其优缺点。所有代码均来自你提供的文件,并逐行添加了详细注释,确保你不仅能看懂,还能真正理解背后的原理!


一、什么是栈?生活中的类比

想象你在吃薯片:

  • 你每次只能从最上面拿一片;
  • 如果你想吃到最底下的那一片,就得先把上面的所有都拿走;
  • 你也不能直接伸手去中间抽一片。

这就是栈的行为:只允许在一端进行插入和删除操作,这一端称为“栈顶”(top),另一端是“栈底”(bottom)。

栈的两个核心操作:

  • push(入栈) :把元素放到栈顶;
  • pop(出栈) :从栈顶移除元素。

此外还有:

  • peek / top:查看栈顶元素但不移除;
  • isEmpty:判断栈是否为空;
  • size:获取栈中元素个数。

二、用数组实现栈:简单高效

JavaScript 中的数组天然支持 pushpop,因此用数组实现栈非常直观。来看 1.js2.js 的例子:

文件 1.js:数组的基本操作

// 数组是开箱即用的线性数据结构
const arr = [1, 2, 3];
arr.push(4);      // 在尾部添加 → 相当于入栈
arr.unshift(0);   // 在头部添加 → 不符合栈行为!
console.log(arr); // [0, 1, 2, 3, 4]

arr.pop();        // 在尾部删除 → 相当于出栈
arr.shift();      // 在头部删除 → 不符合栈行为!
console.log(arr); // [1, 2, 3]

⚠️ 注意:虽然数组可以模拟栈,但 unshiftshift 操作会移动整个数组,效率低(O(n)),真正的栈只操作尾部(即栈顶)

文件 2.js:用数组模拟栈的标准操作

const stack = [];
// 入栈
stack.push(1);
stack.push(3);
stack.push(2);
stack.push(5);
stack.push(4);
console.log(stack); // [1, 3, 2, 5, 4]

// 访问栈顶元素(注意:这里用了 pop,其实是出栈了!)
const peek = stack.pop();
console.log(peek); // 4 ← 栈顶

// 出栈
const pop = stack.pop();
console.log(pop); // 5

// 栈的长度
const size = stack.length;
console.log(size); // 3

// 栈是否为空
const isEmpty = stack.length === 0;
console.log(isEmpty); // false

小问题:这里用 pop() 来“访问”栈顶其实是错误的,因为 pop 会移除元素。正确做法应是读取 stack[stack.length - 1] 而不修改栈。


三、封装一个专业的数组栈:ArrayStack

为了更规范地使用栈,我们用 ES6 的 class 封装一个 ArrayStack,如 4.js 所示:

文件 4.js:基于数组的栈类(带私有属性)

// 数组来实现 stack
class ArrayStack {
  #stack; // 私有属性,外部无法直接访问,保证封装性

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

  // 获取栈大小(只读)
  get size() {
    return this.#stack.length;
  }

  // 判断栈是否为空
  isEmpty() {
    return this.size === 0;
  }

  // 入栈方法(注意:原文件此处有 bug,缺少参数 num)
  push(num) { // ← 修正:添加参数 num
    this.#stack.push(num);
  }

  // 出栈方法
  pop() {
    if (this.isEmpty()) throw new Error("栈为空");
    return this.#stack.pop();
  }

  // 查看栈顶元素(不移除)
  peek() {
    if (this.isEmpty()) throw new Error("栈为空");
    return this.#stack[this.size - 1]; // 只读,不 pop
  }

  // 转换为数组(用于调试或展示)
  toArray() {
    return this.#stack;
  }
}

✅ 优点:代码清晰、操作 O(1),支持随机访问。

❌ 缺点:底层数组扩容时(如从 4→8)需复制元素,偶发 O(n) 开销。


四、用链表实现栈:灵活但稍复杂

当不确定栈的最大容量时,链表是更好的选择。它无需预分配内存,动态增长。来看 3.js 的实现:

文件 3.js:基于链表的栈

// 链表来实现栈
// ES5 没有 class 关键字
// ES6 有 class 关键字

class ListNode {
  constructor(val, next = null) {
    this.val = val;
    this.next = null; // 每个节点指向下一个,形成离散结构
  }
}

class LinkedListStack {
  // 为什么要使用 # ?表示私有的,只能在内部使用,
  // 封装实现的细节,保护类不被外部访问和修改
  #stackPeek; // 栈顶指针(实际是链表头节点)
  #size = 0;  // 栈的大小

  // 直接在本文件内实现
  // console.log(stack.size); // 结果为 undefined(因为 size 是私有属性)

  constructor() {
    this.#stackPeek = null; // 初始化栈顶为空
  }

  // 入栈:在链表头部插入新节点
  push(num) {
    const node = new ListNode(num);
    node.next = this.#stackPeek; // 新节点指向当前栈顶
    this.#stackPeek = node;      // 更新栈顶为新节点
    this.#size++;
  }

  // 访问栈顶元素(注意:原文件逻辑写反了!)
  peek() {
    // 原代码:if(this.#stackPeek) throw ... → 错误!
    // 正确应为:if (!this.#stackPeek)
    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() {
    return this.#size;
  }

  // 判断栈是否为空
  isEmpty() {
    return this.#size === 0;
  }

  // 将栈转换为数组(用于打印)
  toArray() {
    let node = this.#stackPeek;
    const res = new Array(this.size);
    // 从栈顶到底部遍历,但数组要按栈顺序存储(底部在前)
    for (let i = this.size - 1; i >= 0; i--) {
      res[i] = node.val;
      node = node.next;
    }
    return res;
  }
}

const stack = new LinkedListStack();
console.log(stack.size); // 0

✅ 优点:无扩容开销,内存按需分配,稳定 O(1)。 ❌ 缺点:每个节点需额外指针空间,不支持随机访问。


五、实战应用:用栈解决括号匹配问题

栈的经典应用之一是验证括号是否合法配对。看 5.js 的精彩实现:

文件 5.js:有效的括号(LeetCode 第20题)

// https://leetcode.cn/problems/valid-parentheses/description/
// - 用一个 map | json 来维护左括号和右括号的对应关系
// - 用一个栈来维护左括号,遇到右括号时,弹出栈顶元素,查看是否匹配
// - 若不匹配或栈不为空,valid 为 false,遍历完毕后,栈为空,有效

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 {
      // 如果是右括号:
      // 1. 栈为空 → 多余右括号,无效
      // 2. 栈顶不等于当前右括号 → 不匹配,无效
      if (!stack.length || stack.pop() !== ch) {
        return false;
      }
    }
  }

  // 最终栈必须为空,否则有多余左括号
  return stack.length === 0;
}

🌟 这段代码精妙之处在于:栈中存储的是“期望的右括号” ,而不是左括号本身。这样在遇到右括号时,直接比较即可,无需再查映射表。

例如:

  • 输入 "([{}])"

    • ( → 压入 )
    • [ → 压入 ]
    • { → 压入 }
    • } → 弹出 },匹配 ✅
    • ] → 弹出 ],匹配 ✅
    • ) → 弹出 ),匹配 ✅
    • 栈空 → 有效!

六、数组栈 vs 链表栈:全面对比

特性数组栈链表栈
时间复杂度(push/pop)平均 O(1),扩容时 O(n)稳定 O(1)
空间复杂度O(n),可能有闲置空间O(n),每个节点多一个指针
随机访问支持(O(1))不支持(需遍历,O(n))
内存连续性连续,缓存友好离散,缓存不友好
实现难度简单稍复杂
适用场景已知大致容量、追求速度容量未知、要求稳定性能

建议:日常开发优先用数组栈(如 JavaScript 内置数组),除非有特殊需求(如超大栈、内存敏感)。


七、总结

栈虽简单,却是计算机科学中最基础、最重要的数据结构之一。从函数调用栈、表达式求值、括号匹配,到浏览器的“后退”按钮,处处都有它的身影。

通过本文,你已经:

  • 理解了栈的 LIFO 特性;
  • 掌握了数组和链表两种实现方式;
  • 学会了如何封装专业栈类;
  • 看到了栈在算法中的经典应用;
  • 能够根据场景选择合适实现。

记住:数据结构不是死记硬背,而是理解其思想,并能在合适的场景灵活运用。

现在,打开你的编辑器,亲手实现一个栈吧!你会发现,编程的乐趣,就藏在这些基础而优雅的结构之中。