在计算机科学中,栈(Stack) 是一种非常基础且重要的数据结构。它是遵循后进先出(LIFO, Last In First Out)原则的线性数据结构。
本文将深入 JavaScript 语言特性,从最简单的数组实现出发,进阶到 ES6 类的封装,再到底层链表的实现,最后通过一道经典的算法题来巩固理解。
1. 原生数组:开箱即用的“栈”
在 JavaScript 中,数组(Array)本身就是一个非常强大的线性数据结构,它天然支持栈的操作。
基础操作
我们可以利用数组的 push 和 pop 方法完全模拟栈的行为:
- 入栈(Push) :使用
push()在数组尾部插入元素。 - 出栈(Pop) :使用
pop()在数组尾部删除元素。 - 查看栈顶(Peek) :访问
length - 1的索引。
// 简单的数组栈实现
const stack = [];
// 入栈
stack.push(1);
// 访问栈顶
const peek = stack[stack.length - 1];
// 出栈
const pop = stack.pop();
// 判空
const isEmpty = stack.length === 0;
虽然数组还提供了 unshift(头部插入)和 shift(头部删除),但为了保持栈的 LIFO 特性并保证性能(头部操作通常需要移动所有元素),我们通常只操作数组的尾部。
2. 进阶封装:ES6 Class 与私有属性
虽然直接使用数组很方便,但在复杂的工程中,我们通常需要封装(Encapsulation) 。ES6 的 Class 特性为我们提供了更好的模板。
为什么需要封装?
- 安全性:使用 ES6 的
#私有属性语法,可以保护内部数据不被外部随意修改。 - 语义化:通过定义 ADT(抽象数据类型),明确了
push、pop、peek等标准操作。
基于数组的类实现 (ArrayStack)
class ArrayStack {
#stack; // 私有属性,保护内部数组
constructor() {
this.#stack = [];
}
// 使用 getter 语法,调用时无需括号:stack.size
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];
}
}
注意:在类中使用 get 关键字定义的属性,访问时像普通属性一样(如 this.size),不需要加括号。
3. 深入底层:基于链表的实现
虽然数组实现简单且高效,但在某些场景下,链表(Linked List) 是更优秀的选择。
链表 vs 数组:性能大比拼
根据分析:
-
数组:
- 优点:入栈出栈在预分配内存中进行,通常是 O(1)。
- 缺点:当超出容量触发扩容时,需要复制所有元素,时间复杂度瞬间变为 O(n)。
-
链表:
- 优点:扩容非常灵活,永远是 O(1),没有整体复制的开销,性能表现更稳定。
- 缺点:节点实例化(
new ListNode)有一定开销,且每个节点需要额外存储指针,空间占用相对较大。
链表栈的代码实现
我们需要先定义节点 ListNode,利用 next 指针实现离散存储。
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++;
}
pop() {
if (this.isEmpty()) throw new Error('栈为空');
const num = this.#stackPeek.val;
this.#stackPeek = this.#stackPeek.next; // 指针后移
this.#size--;
return num;
}
peek() {
if (!this.#stackPeek) throw new Error('栈为空');
return this.#stackPeek.val;
}
get size() {
return this.#size;
}
isEmpty() {
return this.#size === 0;
}
}
4. 实战应用:有效括号问题
掌握了栈的原理,我们来看一道经典的算法题:判断括号字符串是否有效(如 ()[]{} 为真,(] 为假)。
这道题是栈的典型应用场景:利用栈维护左括号,遇到右括号时进行匹配。
解题思路
-
建立一个 Map 维护左括号到右括号的映射。
-
遍历字符串:
- 如果是左括号,压入对应的期望右括号进栈。
- 如果是右括号,弹出栈顶元素对比。如果不匹配或栈为空,则无效。
-
最后检查栈是否为空(防止残留左括号)。
const isValid = function(s) {
if (s.length === 0) return true;
// 映射关系
const leftToRight = {
"(": ")",
"[": "]",
"{": "}"
}; //
const stack = [];
for(let i = 0; i < s.length; i++) {
const ch = s[i];
// 如果是左括号,将对应的右括号入栈
if(ch === "(" || ch === "[" || ch === "{") {
stack.push(leftToRight[ch]);
} else {
// 如果是右括号,检查栈顶是否匹配
if(stack.length === 0 || stack.pop() !== ch) {
return false;
}
}
}
return stack.length === 0;
}
总结
-
概念:栈是 LIFO(后进先出)的数据结构。
-
实现:
- 数组:适合快速开发,平均效率高,但有扩容风险。
- 链表:适合对性能稳定性要求高、扩容频繁的场景,但空间消耗稍大。
-
语法:ES6 的
class、#private和get访问器让我们的代码更规范、更安全。