前言
今天我们要聊的是数据结构界的"老实人"------栈(Stack) 。为什么说它老实?因为它只有一条规矩,而且死守到底:先进后出(FILO - First In, Last Out) 。
想象一下你洗盘子,洗好的盘子一个接一个往上摞(入栈),取盘子时只能从最上面拿(出栈)。如果你非要抽中间的盘子?抱歉,那叫"塌方",不叫数据结构。
本文将带你从 JS 数组的原生操作开始,利用 ES6 Class 封装高逼格的栈,对比数组与链表的底层优劣,最后用一道经典面试题来实战演练。
一、JS 数组------天生的"栈"
在 JavaScript 的世界里,数组(Array)就是一个开箱即用的超级工具。它不仅能当列表用,配合几个 API,它立刻就能变身成一个栈。
核心操作
JS 数组提供了完美适配栈语义的方法:
- push(element) :入栈(压栈)。往数组尾部塞东西。
- pop() :出栈(弹栈)。把数组尾部的东西扔出来。
- peek (手动实现) :偷看一眼栈顶是谁(array[length - 1])。
codeJavaScript
const stack = [];
// 入栈:像是往弹夹里压子弹
stack.push(1);
stack.push(2);
stack.push(3);
console.log(stack); // [1, 2, 3]
// 访问栈顶(不删除):看看下一发子弹是什么
const peek = stack[stack.length - 1];
console.log(`栈顶元素: ${peek}`); // 3
// 出栈:发射!
const popped = stack.pop();
console.log(`弹出的元素: ${popped}`); // 3
console.log(stack); // [1, 2]
// 警告:不要用 shift/unshift 模拟栈!
// 虽然 stack.unshift(val) 和 stack.shift() 也能实现先进后出(在头部操作)
// 但会导致数组内所有元素下标移动,时间复杂度是 O(n),效率极低。
// 请认准 push/pop 组合,它们通常是 O(1) 的。
二、面向对象封装------做个有"边界感"的栈
虽然数组很好用,但在大型项目中,直接暴露数组是很危险的。万一哪个实习生手抖写了个 stack[2] = 'hack',破坏了栈的结构怎么办?
我们需要用 ES6 的 Class 把实现细节藏起来,只暴露标准接口。这叫封装,也是 ADT(抽象数据类型)的精神所在。
数组实现版 (ArrayStack)
我们要用到 ES6 的黑科技:私有属性(Private Fields) 。
- #stack:加了 # 号,外部就无法通过 instance.#stack 访问。这是真正的"隐私保护"。
- get size() :访问器属性,让你像访问变量一样访问方法。
codeJavaScript
class ArrayStack {
// 私有属性,底层是一个数组
#stack;
constructor() {
this.#stack = [];
}
// 入栈
push(val) {
this.#stack.push(val);
}
// 出栈
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 关键字,让我们能用 stack.size 而不是 stack.size()
get size() {
return this.#stack.length;
}
isEmpty() {
return this.size === 0;
}
// 为了调试方便,允许转换成普通数组查看
// 使用扩展运算符复制一份,防止外部修改内部引用
toArray() {
return [...this.#stack];
}
}
const s = new ArrayStack();
s.push(100);
s.push(200);
console.log(s.size); // 2
// console.log(s.#stack); // 报错!SyntaxError,保护成功
三、链表实现版------底层的"浪漫"
如果我们不准用数组呢?或者我们需要极致的扩容性能呢?这时 **链表(Linked List)**就登场了。
我们通过节点(Node)的指针连接来实现栈。
- 栈顶指针 (stackPeek) :永远指向链表的头部(Head)。
- 入栈:新建节点 -> 指向原栈顶 -> 更新栈顶指针。
- 出栈:保存原栈顶值 -> 栈顶指针后移。
codeJavaScript
// 节点类:链表的最小单元
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.#stackPeek) 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;
}
toArray() {
const res = [];
let node = this.#stackPeek;
while (node) {
res.push(node.val);
node = node.next;
}
return res; // 注意:这里生成的数组,索引0是栈顶元素
}
}
四、巅峰对决------数组 vs 链表
这两种实现各有千秋。这是一个非常经典的面试考点。
1. 时间效率(Time Complexity)
-
数组实现:
-
平均情况: O(1)
:入栈出栈极快,因为内存是连续的。
-
最坏情况:O(n)
:扩容(Resizing) 当数组满了,JS 引擎需要在内存中找一块更大的地盘,把旧数据全部复制过去,这一瞬间,性能会"卡顿"一下。
-
-
链表实现:
-
稳定: O(1),不管有多少数据,入栈永远只是改个指针指向,没有"扩容"的烦恼。
-
缺点:虽然理论上是 O(1) ,但每次 new ListNode 都有内存分配的开销,且不像数组那样对 CPU 缓存友好。
-
2. 空间效率(Space Complexity)
- 数组 :可能有空间浪费。为了防止频繁扩容,数组通常会预分配比实际需要更多的内存(比如你存了5个,它实际占了10个坑)。
- 链表 :额外开销。每个数据不仅要存值,还要存一个 next 指针(引用)。如果存的是简单的整数,指针占用的内存可能比数据本身还大。
结论 :在前端开发中,数据量通常不会大到让数组扩容成为瓶颈,且 JS 引擎对数组做了极致优化,首选数组实现。但在系统级编程或对时延极其敏感的场景下,链表更稳定。
五、实战应用------有效的括号
光说不练假把式,我们来看一个经典的算法题:LeetCode 20. 有效的括号
题目 :判断字符串中的括号 (), [], {} 是否合法闭合。
思路:
-
遇到左括号:压入栈。
-
遇到右括号:弹出栈顶,看看是不是配套的左括号,如果不配套,或者栈已经空了,说明不合法。
-
遍历结束:栈必须为空(所有左括号都找到了归宿)。
codeJavaScript
const isValid = function (str) {
if (!str) return true;
// 映射表:右括号 -> 对应的左括号
// 这样我们可以通过右括号快速查找它期待的"另一半"
const rightToLeft = {
')': '(',
'}': '{',
']': '['
};
const stack = [];
for (let i = 0; i < str.length; i++) { // 原代码 bug: str.len -> str.length
const ch = str[i];
// 如果是左括号,入栈
if (ch === '(' || ch === '[' || ch === '{') {
stack.push(ch);
}
// 如果是右括号
else {
// 1. 栈里是空的(说明前面没有左括号,居然先来了个右括号?) -> 也就是 false
// 2. 弹出的左括号和当前的右括号不匹配 -> false
// 原代码 bug: leftToright.ch -> rightToLeft[ch] (属性访问要用[])
if (!stack.length || stack.pop() !== rightToLeft[ch]) {
return false;
}
}
}
// 遍历完了,栈里必须干净才算赢
return stack.length === 0;
};
// 测试用例
console.log(isValid("()[]{}")); // true
console.log(isValid("(]")); // false
console.log(isValid("([)]")); // false
console.log(isValid("{[]}")); // true
六、 写在最后:如何在面试中优雅地聊栈?
面试官:"用 JavaScript 实现一个栈呗?"
你(面不改色):
"看需求哈~ 日常开发我直接用数组,三行搞定,性能拉满。 如果需要严格私有属性和防篡改,我就写个 class 封装。 真要聊底层实现,我还能甩个链表版,时间复杂度永远 O(1),不过实际项目里基本不用,因为缓存不友好。 哦对了,顺手还能写个括号匹配玩玩~"
说完淡定喝一口水,面试官已经在狂点头了。
总结
- 栈(Stack)是先进后出的线性结构,就像堆盘子。
- JS 数组通过 push/pop 原生支持栈操作,简单高效。
- ES6 Class 可以让我们封装出更安全(私有属性)、更规范的栈数据结构。
- 链表实现避免了数组扩容的性能抖动,但牺牲了空间去存指针。
- 算法应用:遇到"配对"、"回溯"、"撤销操作"类的问题,第一时间想到栈。
栈是数据结构里最"单纯"的一个家伙:只有一头开口,像个死心眼的管子。但就是这么个简单的东西,撑起了递归、表达式解析、DFS......整个计算机世界的半壁江山。
记住一句话:"当你不知道用啥数据结构的时候,先试试栈,八成能行。"
希望这篇整理能让你对栈的理解更上一层楼!Happy Coding!