栈的两种实现:用 JavaScript 探索先进后出的数据结构

42 阅读6分钟

在计算机科学的世界里,(Stack)是一种基础却极其重要的线性数据结构。它的行为规则非常直观:先进后出(FILO, First In Last Out),就像一摞盘子——你只能从顶部拿走或放上盘子,无法直接访问中间或底部的元素。这种看似简单的结构,却支撑着无数关键系统功能:函数调用栈、浏览器历史记录、表达式求值、括号匹配、撤销操作等,都离不开栈的身影。

在 JavaScript 中,我们拥有多种方式来实现栈。既可以利用语言内置的数组快速搭建,也可以通过链表构建更灵活、性能更稳定的版本。更重要的是,借助 ES6 引入的类语法和私有字段特性,我们可以将这些实现封装得既安全又专业。本文将深入探讨栈的本质、两种主流实现方式的差异,并展示如何用现代 JavaScript 编写出工业级质量的栈结构。

什么是栈?核心操作与抽象接口

栈作为一种抽象数据类型(ADT),其价值不在于底层如何存储数据,而在于它对外暴露的一组清晰、有限的操作接口。一个标准的栈应支持以下基本方法:

  • push(item) :将元素压入栈顶;
  • pop() :移除并返回栈顶元素;
  • peek() (或 top()):查看栈顶元素但不移除;
  • isEmpty() :判断栈是否为空;
  • size:获取当前栈中元素的数量。

这些操作共同定义了栈的行为契约。无论内部使用数组还是链表,只要满足上述接口,就可被视为一个合法的栈实现。这种“接口与实现分离”的思想,正是软件工程中高内聚、低耦合原则的体现。

基于数组的栈:简洁高效的首选方案

JavaScript 的数组原生支持在尾部高效地添加和删除元素(pushpop),这恰好符合栈的操作要求。因此,用数组实现栈是最自然、最简洁的方式。

通过 ES6 的 class 语法,我们可以轻松封装一个安全的栈类:

class ArrayStack {
  #items = []; // 私有字段,外部不可见

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

  push(val) { this.#items.push(val); }
  pop() { 
    if (this.isEmpty()) throw new Error("栈为空");
    return this.#items.pop(); 
  }
  peek() { 
    if (this.isEmpty()) throw new Error("栈为空");
    return this.#items[this.size - 1]; 
  }
}

这里的关键在于:

  • 使用 #items 定义私有属性,防止外部直接修改内部数组;
  • 通过 get size() 提供只读访问器,隐藏具体实现;
  • 对空栈操作进行错误处理,提升健壮性。

数组实现的优势与局限

优势显著

  • 高性能:在绝大多数情况下,pushpop 的时间复杂度为 O(1);
  • 内存连续:数据在内存中紧凑排列,缓存命中率高,访问速度快;
  • 代码简洁:依托语言内置能力,实现逻辑极简。

但也存在局限

  • 扩容开销:当数组容量不足时,JavaScript 引擎会分配更大的新数组,并将所有元素复制过去,此时单次 push 操作的时间复杂度退化为 O(n);
  • 空间预分配:引擎可能预留额外空间以减少扩容频率,但这可能导致内存利用率不高。

不过,由于扩容是低频事件,其摊还(amortized)时间复杂度仍为 O(1),因此在实际开发中,数组实现通常是首选方案

基于链表的栈:稳定性能的替代选择

为了彻底规避数组扩容带来的性能波动,我们可以采用链表来实现栈。每个节点包含数据值和指向下一个节点的指针,栈顶即为链表的头节点。

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

class LinkedListStack {
  #head = null;
  #count = 0;

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

  push(val) {
    const node = new ListNode(val);
    node.next = this.#head;
    this.#head = node;
    this.#count++;
  }

  pop() {
    if (this.isEmpty()) return null;
    const val = this.#head.val;
    this.#head = this.#head.next;
    this.#count--;
    return val;
  }

  peek() { return this.isEmpty() ? null : this.#head.val; }
}

每次入栈只需创建新节点并更新头指针,无需移动任何已有数据。

链表实现的权衡

优点突出

  • 稳定 O(1) :所有核心操作的时间复杂度恒为 O(1),无性能抖动;
  • 动态内存:按需分配,空间利用率高,适合元素数量剧烈变化的场景。

代价也不容忽视

  • 额外开销:每个节点需存储指针,内存占用比纯数据更大;
  • 非连续内存:节点分散在堆中,缓存局部性差,访问速度略慢于数组。

因此,链表实现更适合对最坏情况性能有严格要求的系统,如实时处理或嵌入式环境。

现代 JavaScript 的封装艺术

无论是数组还是链表实现,良好的封装都是专业代码的标志。ES6 的 # 私有字段语法让我们能真正隐藏内部状态,避免外部误操作破坏数据结构的一致性。同时,get 访问器允许我们以属性形式提供只读信息,使 API 更加直观。

例如,用户只需调用 stack.size 而非 stack.getSize(),既简洁又符合直觉。这种设计不仅提升了可用性,也增强了代码的可维护性——未来若更换底层实现(如从数组切换到链表),只要接口不变,调用方完全无需修改。

如何选择?场景驱动的决策

在实际项目中,选择哪种实现应基于具体需求:

  • 通用 Web 应用:优先选择数组实现。其简单性、高性能和低内存开销足以应对绝大多数场景,如管理 UI 状态、实现简易历史记录等。
  • 高性能或资源受限环境:考虑链表实现。当栈大小高度动态、且不能容忍任何性能尖峰时,链表的稳定性更具优势。
  • 教学或原型开发:数组实现因其直观性,是入门学习的最佳选择。

结语:小结构,大智慧

栈虽是一个简单的数据结构,却蕴含着深刻的工程哲学:通过限制操作来换取更高的可靠性与可预测性。在 JavaScript 中,我们不仅能轻松实现它,还能借助现代语言特性将其封装得既强大又安全。

理解栈的两种实现方式,不仅是掌握一种数据结构,更是培养“根据场景权衡设计”的工程思维。毕竟,优秀的开发者不在于知道多少炫技的语法,而在于能否为每一个问题选择最合适、最稳健的解决方案。而栈,正是通往这一境界的第一块基石。