栈:从原理到实战,深入理解 FILO 数据结构的优雅与力量

79 阅读3分钟

“栈虽小,却承载着程序世界中无数精妙逻辑。”

在计算机科学中,栈(Stack) 是一种基础却极其重要的线性数据结构。它遵循 先进后出(FILO, First In Last Out) 的原则——就像你往一个桶里放书,最后放进去的那本,总是最先被拿出来。

本文将带你从 抽象数据类型(ADT)定义 出发,深入探讨栈的两种实现方式(数组 vs 链表),并通过 ES6 的现代语法展示其封装之美,最后用一道经典算法题验证栈的实战价值。


一、栈的 ADT:接口即契约

一个标准的栈应具备以下核心操作:

方法描述
push(item)入栈:将元素压入栈顶
pop()出栈:移除并返回栈顶元素
peek() / top()查看栈顶元素,但不移除
isEmpty()判断栈是否为空
size获取栈中元素数量(通常作为只读属性)

这些方法构成了栈的行为契约,无论底层用数组还是链表实现,对外暴露的接口应保持一致。


二、ES6 Class:让栈的实现更优雅

借助 ES6 的 class、私有字段 #get 访问器等特性,我们可以写出高内聚、低耦合的栈类。

✅ 基于数组的栈(ArrayStack)

class ArrayStack {
  #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("栈为空");
    return this.#stack[this.size - 1];
  }
}

优点:内存连续,缓存友好,push/pop 平均时间复杂度为 O(1)。
缺点:扩容时需复制整个数组,最坏情况 O(n),且可能浪费预分配空间。


✅ 基于链表的栈(LinkedListStack)

class ListNode {
  constructor(val) {
    this.val = val;
    this.next = null;
  }
}

class LinkedListStack {
  #stackPeek = null;
  #size = 0;

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

  push(num) {
    const node = new ListNode(num);
    node.next = this.#stackPeek;
    this.#stackPeek = node;
    this.#size++;
  }

  pop() {
    const num = this.peek();
    this.#stackPeek = this.#stackPeek.next;
    this.#size--;
    return num;
  }

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

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

优点:动态分配,无扩容开销,每次操作稳定 O(1)。
缺点:每个节点需额外存储指针,内存碎片化,缓存局部性差。

📌 思考:在实际工程中,数组栈更常用——因为现代 JS 引擎对数组高度优化,且大多数场景下栈深度有限,扩容极少发生。


三、图解对比:数组栈 vs 链表栈

数组栈(初始容量4):
[1, 2, 3, _]push(4) → [1,2,3,4]
push(5) → 扩容 → [1,2,3,4,5,_,_,_] (复制+新分配)

链表栈:
54321 → null
每次 push 只需新建节点并指向原栈顶

image.png

示意图:左为数组栈,右为链表栈


四、实战:用栈判断括号有效性

这是 LeetCode 第 20 题,也是栈的经典应用场景。

思路

  • 遇到左括号 ( { [,将其对应的右括号压入栈;
  • 遇到右括号,检查是否与栈顶匹配;
  • 最终栈应为空。
const leftToRight = { '(': ')', '{': '}', '[': ']' };

function isValid(s) {
  const stack = [];
  for (let ch of s) {
    if (ch in leftToRight) {
      stack.push(leftToRight[ch]); // 压入期望的右括号
    } else {
      if (stack.pop() !== ch) return false; // 不匹配或栈空
    }
  }
  return stack.length === 0;
}

✅ 这段代码简洁、高效,完美体现了“栈用于处理具有嵌套或对称结构的问题”这一思想。


五、延伸思考:栈在现实中的影子

  • 函数调用栈:每一次函数调用都是一次 push,返回则是 pop
  • 浏览器历史记录:后退按钮本质是栈的 pop 操作。
  • 撤销(Undo)功能:操作记录压栈,撤销即弹出。

栈,看似简单,却是程序运行时不可或缺的“幕后英雄”。


结语

无论是用数组还是链表实现,栈的核心在于 限制访问方式——只允许操作一端。这种“约束”反而带来了清晰的逻辑和高效的性能。

掌握栈,不仅是掌握一种数据结构,更是理解程序如何管理状态与上下文的关键一步。

代码即哲学,约束即自由。