“栈虽小,却承载着程序世界中无数精妙逻辑。”
在计算机科学中,栈(Stack) 是一种基础却极其重要的线性数据结构。它遵循 先进后出(FILO, First In Last Out) 的原则——就像你往一个桶里放书,最后放进去的那本,总是最先被拿出来。
本文将带你从 抽象数据类型(ADT)定义 出发,深入探讨栈的两种实现方式(数组 vs 链表),并通过 ES6 的现代语法展示其封装之美,最后用一道经典算法题验证栈的实战价值。
一、栈的 ADT:接口即契约
一个标准的栈应具备以下核心操作:
| 方法 | 描述 |
|---|---|
push(item) | 入栈:将元素压入栈顶 |
pop() | 出栈:移除并返回栈顶元素 |
peek() / top() | 查看栈顶元素,但不移除 |
isEmpty() | 判断栈是否为空 |
size | 获取栈中元素数量(通常作为只读属性) |
这些方法构成了栈的行为契约,无论底层用数组还是链表实现,对外暴露的接口应保持一致。
二、ES6 Class:让栈的实现更优雅
借助 ES6 的 class、私有字段 #、get 访问器等特性,我们可以写出高内聚、低耦合的栈类。
✅ 基于数组的栈(ArrayStack)
class ArrayStack {
#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];
}
}
优点:内存连续,缓存友好,push/pop 平均时间复杂度为 O(1)。
缺点:扩容时需复制整个数组,最坏情况 O(n),且可能浪费预分配空间。
✅ 基于链表的栈(LinkedListStack)
class ListNode {
constructor(val) {
this.val = val;
this.next = null;
}
}
class LinkedListStack {
#stackPeek = null;
#size = 0;
get size() { return this.#size; }
push(num) {
const node = new ListNode(num);
node.next = this.#stackPeek;
this.#stackPeek = node;
this.#size++;
}
pop() {
const num = this.peek();
this.#stackPeek = this.#stackPeek.next;
this.#size--;
return num;
}
peek() {
if (!this.#stackPeek) throw new Error('栈为空');
return this.#stackPeek.val;
}
isEmpty() { return this.#size === 0; }
}
优点:动态分配,无扩容开销,每次操作稳定 O(1)。
缺点:每个节点需额外存储指针,内存碎片化,缓存局部性差。
📌 思考:在实际工程中,数组栈更常用——因为现代 JS 引擎对数组高度优化,且大多数场景下栈深度有限,扩容极少发生。
三、图解对比:数组栈 vs 链表栈
数组栈(初始容量4):
[1, 2, 3, _] → push(4) → [1,2,3,4]
push(5) → 扩容 → [1,2,3,4,5,_,_,_] (复制+新分配)
链表栈:
5 → 4 → 3 → 2 → 1 → null
每次 push 只需新建节点并指向原栈顶
(示意图:左为数组栈,右为链表栈)
四、实战:用栈判断括号有效性
这是 LeetCode 第 20 题,也是栈的经典应用场景。
思路:
- 遇到左括号
({[,将其对应的右括号压入栈; - 遇到右括号,检查是否与栈顶匹配;
- 最终栈应为空。
const leftToRight = { '(': ')', '{': '}', '[': ']' };
function isValid(s) {
const stack = [];
for (let ch of s) {
if (ch in leftToRight) {
stack.push(leftToRight[ch]); // 压入期望的右括号
} else {
if (stack.pop() !== ch) return false; // 不匹配或栈空
}
}
return stack.length === 0;
}
✅ 这段代码简洁、高效,完美体现了“栈用于处理具有嵌套或对称结构的问题”这一思想。
五、延伸思考:栈在现实中的影子
- 函数调用栈:每一次函数调用都是一次
push,返回则是pop。 - 浏览器历史记录:后退按钮本质是栈的
pop操作。 - 撤销(Undo)功能:操作记录压栈,撤销即弹出。
栈,看似简单,却是程序运行时不可或缺的“幕后英雄”。
结语
无论是用数组还是链表实现,栈的核心在于 限制访问方式——只允许操作一端。这种“约束”反而带来了清晰的逻辑和高效的性能。
掌握栈,不仅是掌握一种数据结构,更是理解程序如何管理状态与上下文的关键一步。
代码即哲学,约束即自由。