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,仅包含 '(', ')', '[', ']', '{', '}',判断括号是否正确闭合。
解题思路
-
使用一个映射表记录左括号对应的右括号。
-
遍历字符串:
- 遇到左括号,将其对应的右括号压入栈。
- 遇到右括号,检查是否与栈顶元素匹配。
-
遍历结束后,栈应为空。
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、私有字段和访问器,我们不仅能写出功能正确的栈,还能实现良好的封装与可维护性。掌握其原理与实现差异,有助于在实际项目中做出更合理的技术选型。