JavaScript 中的栈(Stack)学习笔记

30 阅读4分钟

JavaScript 中的栈(Stack)学习笔记

什么是栈?

栈(Stack)是一种先进后出(First In Last Out, FILO)的线性数据结构。你可以把它想象成一摞盘子:你只能从顶部放入或取出盘子,最先放进去的盘子最后才能被取出来。

在编程中,栈常用于函数调用、表达式求值、括号匹配、浏览器历史记录等场景。


栈的抽象数据类型(ADT)

一个标准的栈应具备以下基本操作:

  • push(item) :将元素压入栈顶。
  • pop() :弹出并返回栈顶元素。
  • peek() / top() :查看栈顶元素但不弹出。
  • isEmpty() :判断栈是否为空。
  • size() :返回栈中元素个数。

这些操作构成了栈的核心行为,无论底层是用数组还是链表实现,对外接口应保持一致。


ES6 Class 与封装

ES6 引入了 class 语法,使面向对象编程更清晰。结合私有字段(# 前缀)、构造函数(constructor)、访问器(get/set),我们可以优雅地实现栈的封装。

私有属性(Private Fields)

使用 # 声明的属性只能在类内部访问,外部无法直接修改,增强了数据安全性。例如:

class LinkedListStack {
    #stackPeek; // 私有栈顶指针
    #size = 0;  // 私有大小
}

访问器(get/set)

通过 get 可以暴露只读属性,如栈的大小:

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

这样既保护了内部状态,又提供了安全的读取方式。


两种实现方式:数组 vs 链表

1. 基于数组的栈(ArrayStack)

JavaScript 的数组天然支持栈操作:push()pop() 分别对应入栈和出栈。

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("栈为空");
    return this.#stack[this.size - 1];
  }
  toArray() {
    return this.#stack;
  }
}
优点:
  • 内存连续,缓存友好。
  • push/pop 平均时间复杂度为 O(1)
  • 代码简洁,利用原生方法高效。
缺点:
  • 当数组容量不足时会触发扩容(如 V8 引擎中可能翻倍),此时 push 操作需复制所有元素,最坏时间复杂度为 O(n)
  • 可能存在空间浪费(预分配内存未完全使用)。

尽管如此,由于扩容是低频事件,摊还时间复杂度仍为 O(1)


2. 基于链表的栈(LinkedListStack)

使用单向链表实现栈,每次在头部插入/删除节点。

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++;
    }
    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() {
        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;
    }
}
优点:
  • 动态分配内存,无扩容开销,每次操作稳定 O(1)
  • 空间按需分配,不会浪费。
缺点:
  • 每个节点需额外存储 next 指针,空间开销更大
  • 内存不连续,缓存局部性差,性能略低于数组。

实战应用:括号匹配问题

栈的经典应用场景之一是验证括号是否有效匹配

问题描述

给定字符串 s,仅包含 '(', ')', '[', ']', '{', '}',判断括号是否正确闭合。

解题思路

  1. 使用一个映射表记录左括号对应的右括号。

  2. 遍历字符串:

    • 遇到左括号,将其对应的右括号压入栈。
    • 遇到右括号,检查是否与栈顶元素匹配。
  3. 遍历结束后,栈应为空。

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; // 栈空则有效
};

示例

console.log(isValid("()"));       // true
console.log(isValid("([{}])"));   // true
console.log(isValid("(]"));       // false
console.log(isValid("([)]"));     // false

此解法时间复杂度 O(n) ,空间复杂度 O(n) (最坏情况全为左括号)。


总结对比

特性数组实现栈链表实现栈
时间效率平均 O(1),扩容 O(n)稳定 O(1)
空间效率可能浪费节点指针额外开销
内存布局连续离散
实现复杂度简单稍复杂(需管理节点)
适用场景数据量可控、性能敏感动态性强、避免扩容

在 JavaScript 中,推荐优先使用数组实现栈,因为:

  • 原生方法高度优化;
  • 代码简洁;
  • 日常开发中数据规模通常不会频繁触发扩容。

但在需要严格保证 O(1) 操作或内存极度受限的场景下,链表实现更具优势。


结语

栈虽简单,却是理解程序运行机制(如调用栈、递归)和解决算法问题(如表达式解析、DFS)的基础。通过 ES6 的 class、私有字段和访问器,我们不仅能写出功能正确的栈,还能实现良好的封装与可维护性。掌握其原理与实现差异,有助于在实际项目中做出更合理的技术选型。