🧠栈(Stack)是一种基础但极其重要的线性数据结构,在计算机科学中广泛应用于函数调用、表达式求值、括号匹配、浏览器历史记录、撤销操作等场景。本文将深入剖析栈的定义、特性、实现方式(基于数组和链表)、时间与空间复杂度分析,并结合 LeetCode 第 20 题“有效的括号”进行实战演练。
🔁 什么是栈?
栈是一种 先进后出(First In Last Out, FILO)或 后进先出(Last In First Out, LIFO) 的抽象数据类型(Abstract Data Type, ADT)。你可以把它想象成一摞盘子:你只能在最上面放盘子(入栈),也只能从最上面拿走盘子(出栈)。不能直接从中间或底部操作。
✅ 栈的核心操作(方法)
一个标准的栈通常支持以下基本操作:
push(item):将元素压入栈顶。pop():移除并返回栈顶元素。peek()/top():仅查看栈顶元素,不移除。isEmpty():判断栈是否为空。size():返回栈中元素的数量。- (可选)
toArray():将栈内容转换为数组(便于调试或展示)。
💻 ES6 Class 与封装:现代 JavaScript 中的栈实现
ES6 引入了 class 语法,使得面向对象编程更加直观。同时,通过私有属性(以 # 开头)可以很好地封装内部实现细节,提升代码的安全性和可维护性。
📦 私有属性与访问器(get/set)
- 私有属性(如
#stack或#stackPeek):只能在类内部访问,外部无法直接读写,防止误操作。 get访问器:允许像读取属性一样调用方法(如stack.size而非stack.size())。set访问器:可用于拦截赋值操作(本文未使用,但常用于数据校验)。
📊 基于数组实现栈(ArrayStack)
数组是 JavaScript 中最自然的栈实现方式,因为其原生就支持 push() 和 pop() 操作,且它们的时间复杂度在大多数情况下为 O(1)。
📄 代码实现(来自 4.js)
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('栈为空'); // 注意:原文此处有笔误,应调用 isEmpty()
return this.#stack[this.size - 1];
}
toArray() {
return this.#stack; // 注意:这里返回的是内部数组的引用,若需完全隔离应返回副本 [...this.#stack]
}
}
⚡ 时间与空间效率分析(数组栈)
-
时间复杂度:
push/pop/peek/isEmpty/size:平均 O(1) 。- 注意:当数组容量不足时,JavaScript 引擎会自动扩容(通常是倍增),此时
push操作需要复制所有元素到新数组,最坏情况为 O(n) 。但由于扩容是低频事件,摊还(Amortized)。
-
空间复杂度:
- 可能造成空间浪费:数组预分配的内存可能大于实际元素数量。
- 内存连续,缓存友好,访问速度快。
🔗 基于链表实现栈(LinkedListStack)
链表通过动态分配节点来存储数据,天然支持 O(1) 的插入和删除,非常适合实现栈。
📄 代码实现(来自 3.js)
// 链表节点
class ListNode {
constructor(val) {
this.val = val;
this.next = null;
}
}
// 链表栈
class LinkedListStack {
#stackPeek; // 栈顶指针(指向头节点)
#size = 0;
constructor() {
this.#stackPeek = null;
this.#size = 0;
}
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() { // 注意:命名风格不一致,建议统一为 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;
}
}
⚡ 时间与空间效率分析(链表栈)
-
时间复杂度:
- 所有核心操作(
push,pop,peek,isEmpty,size)均为 严格的 O(1) ,无摊还成本。
- 所有核心操作(
-
空间复杂度:
- 每个节点需要额外空间存储指针(
next),因此 内存开销比数组大。 - 无空间浪费:按需分配,用多少占多少。
- 每个节点需要额外空间存储指针(
🆚 数组栈 vs 链表栈 总结
| 特性 | 数组栈 | 链表栈 |
|---|---|---|
| 时间效率 | 平均 O(1),最坏 O(n)(扩容) | 严格 O(1) |
| 空间效率 | 可能有浪费,但内存连续 | 无浪费,但每个节点有额外开销 |
| 适用场景 | 元素数量可预估,追求速度 | 元素数量变化剧烈,追求稳定 |
🧪 栈的经典应用:LeetCode 20. 有效的括号
🎯 题目描述
给定一个只包括 '(',')','{','}','[',']' 的字符串 s,判断字符串是否有效。
有效字符串需满足:
- 左括号必须用相同类型的右括号闭合。
- 左括号必须以正确的顺序闭合。
- 每个右括号都有一个对应的相同类型的左括号。
💡 解题思路
这正是栈的典型应用场景!
-
遇到左括号(
(,[,{)→ 入栈。 -
遇到右括号 → 出栈,并与当前右括号匹配。
- 若栈空(无左括号可匹配)→ 无效。
- 若弹出的左括号与当前右括号不匹配 → 无效。
-
遍历结束后,若栈非空(有多余左括号)→ 无效;否则有效。
📄 实现一:使用 Map 映射(来自 readme.md)
var isValid = function(s) {
const stack = [];
const map = new Map([
['(', ')'],
['[', ']'],
['{', '}'],
]);
for (let i = 0; i < s.length; i++) {
const char = s[i];
if (map.has(char)) {
stack.push(char); // 入栈左括号
} else {
if (stack.length === 0) return false; // 栈空却遇右括号
const top = stack.pop(); // 出栈
if (map.get(top) !== char) return false; // 不匹配
}
}
return stack.length === 0; // 最终栈必须为空
};
📄 实现二:直接存储期望的右括号(来自 5.js)
这是一种巧妙的优化:入栈时不存左括号,而是存它对应的右括号。这样在遇到右括号时,只需直接比较即可,无需查表。
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;
};
⏱️ 复杂度分析
- 时间复杂度:O(n),每个字符最多入栈和出栈一次。
- 空间复杂度:O(n),最坏情况下(全是左括号),栈大小为 n。
🧱 补充:原始数组操作(来自 1.js 和 2.js)
即使不封装成类,也可以直接用数组模拟栈:
// 来自 2.js
const stack = [];
stack.push(1); // 入栈
console.log(stack[stack.length - 1]); // peek: 1
const pop = stack.pop(); // 出栈
const size = stack.length;
const isEmpty = stack.length === 0;
注意:虽然
arr.unshift()和arr.shift()也能模拟栈(在头部操作),但它们的时间复杂度是 O(n),强烈不推荐用于栈实现。栈应始终在尾部(push/pop)操作以保证 O(1) 效率。
🏁 总结
栈作为一种简洁而强大的数据结构,其核心在于 LIFO 的操作原则。无论是使用 数组(简单高效,适合大多数场景)还是 链表(稳定 O(1),适合动态性强的场景)来实现,都能很好地服务于各种算法问题。
通过 LeetCode “有效的括号”这一经典例题,我们不仅巩固了栈的操作,还学习了两种不同的编码思路(存左括号 vs 存期望右括号),体现了算法思维的灵活性。
掌握栈,是通往更复杂数据结构(如队列、树、图)和算法(DFS、表达式解析)的重要基石。🧱✨