引言
栈(Stack)是一种“后进先出”(Last In First Out, LIFO)的线性数据结构。它就像你往一个窄口瓶里放书——最后放进去的那本,总是最先被拿出来。
在本文中,我们将通过生动的比喻、清晰的代码示例和详尽的注解,带你彻底掌握栈的核心概念、常见操作、不同实现方式及其优缺点。所有代码均来自你提供的文件,并逐行添加了详细注释,确保你不仅能看懂,还能真正理解背后的原理!
一、什么是栈?生活中的类比
想象你在吃薯片:
- 你每次只能从最上面拿一片;
- 如果你想吃到最底下的那一片,就得先把上面的所有都拿走;
- 你也不能直接伸手去中间抽一片。
这就是栈的行为:只允许在一端进行插入和删除操作,这一端称为“栈顶”(top),另一端是“栈底”(bottom)。
栈的两个核心操作:
- push(入栈) :把元素放到栈顶;
- pop(出栈) :从栈顶移除元素。
此外还有:
- peek / top:查看栈顶元素但不移除;
- isEmpty:判断栈是否为空;
- size:获取栈中元素个数。
二、用数组实现栈:简单高效
JavaScript 中的数组天然支持 push 和 pop,因此用数组实现栈非常直观。来看 1.js 和 2.js 的例子:
文件 1.js:数组的基本操作
// 数组是开箱即用的线性数据结构
const arr = [1, 2, 3];
arr.push(4); // 在尾部添加 → 相当于入栈
arr.unshift(0); // 在头部添加 → 不符合栈行为!
console.log(arr); // [0, 1, 2, 3, 4]
arr.pop(); // 在尾部删除 → 相当于出栈
arr.shift(); // 在头部删除 → 不符合栈行为!
console.log(arr); // [1, 2, 3]
⚠️ 注意:虽然数组可以模拟栈,但
unshift和shift操作会移动整个数组,效率低(O(n)),真正的栈只操作尾部(即栈顶) 。
文件 2.js:用数组模拟栈的标准操作
const stack = [];
// 入栈
stack.push(1);
stack.push(3);
stack.push(2);
stack.push(5);
stack.push(4);
console.log(stack); // [1, 3, 2, 5, 4]
// 访问栈顶元素(注意:这里用了 pop,其实是出栈了!)
const peek = stack.pop();
console.log(peek); // 4 ← 栈顶
// 出栈
const pop = stack.pop();
console.log(pop); // 5
// 栈的长度
const size = stack.length;
console.log(size); // 3
// 栈是否为空
const isEmpty = stack.length === 0;
console.log(isEmpty); // false
小问题:这里用
pop()来“访问”栈顶其实是错误的,因为pop会移除元素。正确做法应是读取stack[stack.length - 1]而不修改栈。
三、封装一个专业的数组栈:ArrayStack
为了更规范地使用栈,我们用 ES6 的 class 封装一个 ArrayStack,如 4.js 所示:
文件 4.js:基于数组的栈类(带私有属性)
// 数组来实现 stack
class ArrayStack {
#stack; // 私有属性,外部无法直接访问,保证封装性
constructor() {
this.#stack = []; // 初始化内部数组
}
// 获取栈大小(只读)
get size() {
return this.#stack.length;
}
// 判断栈是否为空
isEmpty() {
return this.size === 0;
}
// 入栈方法(注意:原文件此处有 bug,缺少参数 num)
push(num) { // ← 修正:添加参数 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]; // 只读,不 pop
}
// 转换为数组(用于调试或展示)
toArray() {
return this.#stack;
}
}
✅ 优点:代码清晰、操作 O(1),支持随机访问。
❌ 缺点:底层数组扩容时(如从 4→8)需复制元素,偶发 O(n) 开销。
四、用链表实现栈:灵活但稍复杂
当不确定栈的最大容量时,链表是更好的选择。它无需预分配内存,动态增长。来看 3.js 的实现:
文件 3.js:基于链表的栈
// 链表来实现栈
// ES5 没有 class 关键字
// ES6 有 class 关键字
class ListNode {
constructor(val, next = null) {
this.val = val;
this.next = null; // 每个节点指向下一个,形成离散结构
}
}
class LinkedListStack {
// 为什么要使用 # ?表示私有的,只能在内部使用,
// 封装实现的细节,保护类不被外部访问和修改
#stackPeek; // 栈顶指针(实际是链表头节点)
#size = 0; // 栈的大小
// 直接在本文件内实现
// console.log(stack.size); // 结果为 undefined(因为 size 是私有属性)
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 ... → 错误!
// 正确应为:if (!this.#stackPeek)
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 = this.size - 1; i >= 0; i--) {
res[i] = node.val;
node = node.next;
}
return res;
}
}
const stack = new LinkedListStack();
console.log(stack.size); // 0
✅ 优点:无扩容开销,内存按需分配,稳定 O(1)。 ❌ 缺点:每个节点需额外指针空间,不支持随机访问。
五、实战应用:用栈解决括号匹配问题
栈的经典应用之一是验证括号是否合法配对。看 5.js 的精彩实现:
文件 5.js:有效的括号(LeetCode 第20题)
// https://leetcode.cn/problems/valid-parentheses/description/
// - 用一个 map | json 来维护左括号和右括号的对应关系
// - 用一个栈来维护左括号,遇到右括号时,弹出栈顶元素,查看是否匹配
// - 若不匹配或栈不为空,valid 为 false,遍历完毕后,栈为空,有效
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 {
// 如果是右括号:
// 1. 栈为空 → 多余右括号,无效
// 2. 栈顶不等于当前右括号 → 不匹配,无效
if (!stack.length || stack.pop() !== ch) {
return false;
}
}
}
// 最终栈必须为空,否则有多余左括号
return stack.length === 0;
}
🌟 这段代码精妙之处在于:栈中存储的是“期望的右括号” ,而不是左括号本身。这样在遇到右括号时,直接比较即可,无需再查映射表。
例如:
-
输入
"([{}])":(→ 压入)[→ 压入]{→ 压入}}→ 弹出},匹配 ✅]→ 弹出],匹配 ✅)→ 弹出),匹配 ✅- 栈空 → 有效!
六、数组栈 vs 链表栈:全面对比
| 特性 | 数组栈 | 链表栈 |
|---|---|---|
| 时间复杂度(push/pop) | 平均 O(1),扩容时 O(n) | 稳定 O(1) |
| 空间复杂度 | O(n),可能有闲置空间 | O(n),每个节点多一个指针 |
| 随机访问 | 支持(O(1)) | 不支持(需遍历,O(n)) |
| 内存连续性 | 连续,缓存友好 | 离散,缓存不友好 |
| 实现难度 | 简单 | 稍复杂 |
| 适用场景 | 已知大致容量、追求速度 | 容量未知、要求稳定性能 |
建议:日常开发优先用数组栈(如 JavaScript 内置数组),除非有特殊需求(如超大栈、内存敏感)。
七、总结
栈虽简单,却是计算机科学中最基础、最重要的数据结构之一。从函数调用栈、表达式求值、括号匹配,到浏览器的“后退”按钮,处处都有它的身影。
通过本文,你已经:
- 理解了栈的 LIFO 特性;
- 掌握了数组和链表两种实现方式;
- 学会了如何封装专业栈类;
- 看到了栈在算法中的经典应用;
- 能够根据场景选择合适实现。
记住:数据结构不是死记硬背,而是理解其思想,并能在合适的场景灵活运用。
现在,打开你的编辑器,亲手实现一个栈吧!你会发现,编程的乐趣,就藏在这些基础而优雅的结构之中。