栈:不是“入栈”就是“出栈”,但你真的懂它吗?
面试官:“说说栈的实现方式?”
你:“数组和链表都能实现。”
面试官(微微一笑):“那……扩容时时间复杂度是多少?空间浪费怎么评估?为什么大厂偏爱链表实现?”
——别慌,今天带你把栈从“会用”讲到“精通”。
一、栈是什么?不只是“先进后出”那么简单
栈(Stack)是一种线性数据结构,遵循 FILO(First In Last Out,先进后出) 原则。你可以把它想象成一摞盘子:你只能从顶部拿盘子(出栈),也只能往顶部放盘子(入栈)。
栈的核心操作(ADT 抽象数据类型)
push(x):入栈pop():出栈,并返回栈顶元素peek()/top():查看栈顶元素(不弹出)isEmpty():判断是否为空size():获取当前栈的大小
这些操作看似简单,但底层实现方式不同,性能差异巨大——这正是大厂面试考察的重点。
二、JavaScript 中的“开箱即用”栈:Array 的陷阱与便利
const arr = [1, 2, 3];
arr.push(4); // 尾部添加 → O(1) 平均
arr.unshift(0); // 头部添加 → O(n)!⚠️
很多人误以为 JavaScript 数组天然适合做栈,其实只有 push/pop 是高效的。
而 unshift/shift 涉及整个数组元素的位移,时间复杂度为 O(n) ,绝对不能用于栈操作!
✅ 正确姿势:
// 用数组模拟栈:只用 push 和 pop
const stack = [];
stack.push(1);
stack.pop(); // 安全、高效
但即便如此,数组实现的栈仍有隐患——扩容问题。
三、数组 vs 链表:两种栈实现的深度对比
1. 数组实现栈(ArrayStack)
class ArrayStack {
#stack = [];
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 size() { return this.#stack.length; }
isEmpty() { return this.size === 0; }
}
✅ 优点:
- 缓存友好:连续内存,CPU 缓存命中率高。
- 平均性能极佳:
push/pop平均 O(1)。
❌ 缺点:
- 扩容成本高:当容量不足时,JS 引擎会分配新数组并复制所有元素 → O(n) 。
- 空间浪费:为避免频繁扩容,引擎通常按 1.5x~2x 扩容,导致内存预留(例如只用了 60%,却占了 100% 空间)。
📌 面试加分点:V8 引擎中,数组底层可能是 Fast Elements(连续内存) 或 Dictionary Elements(哈希表) 。一旦发生“稀疏化”(如
arr[1000] = 1),数组会退化为字典模式,push/pop性能骤降!
2. 链表实现栈(LinkListStack)
class ListNode {
constructor(val) {
this.val = val;
this.next = null;
}
}
class LinkListStack {
#stackPeek = null;
#size = 0;
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;
}
get size() { return this.#size; }
isEmpty() { return this.size === 0; }
}
✅ 优点:
- 无扩容压力:每次只分配一个节点,严格 O(1) 。
- 内存按需分配:不会预占空间,适合内存敏感场景(如嵌入式、高频创建小栈)。
❌ 缺点:
- 内存碎片:每个节点需额外存储
next指针(64 位系统下指针占 8 字节)。 - 缓存不友好:节点在堆上离散分布,CPU 缓存命中率低。
💡 深度洞察:在现代 JS 引擎中,对象创建(
new ListNode)经过高度优化,实际性能差距远小于理论。但对于超大规模栈(>10^6 元素),数组仍占优;而对于大量短生命周期栈(如递归模拟、表达式解析),链表更稳。
🧱 新手必看:建栈过程详解(附指针/内存变化图)
很多初学者知道“数组连续、链表离散”,但具体怎么变?指针指向哪?扩容时数据去哪了? 下面我们一步步拆解。
▶ 场景1:用数组建栈(ArrayStack)——“搬家式扩容”
假设我们初始化一个空栈:
const stack = new ArrayStack(); // 内部 this.#stack = []
第1步:push(1)
- 内存分配:V8 分配一个小数组(比如容量为4)
- 存储:
[1, empty, empty, empty] length = 1
第2步:push(2), push(3), push(4)
- 数组变为:
[1, 2, 3, 4] length = 4,刚好填满
第3步:push(5) → 触发扩容!
- V8 发现容量不足,申请新数组(容量 ≈ 6~8)
- 逐个复制旧元素:1→新[0], 2→新[1], ..., 4→新[3]
- 插入5:新[4] = 5
- 旧数组被标记为垃圾,等待 GC 回收
🧠 关键理解:
- 扩容不是“在原地加空间”,而是“整体搬家”
- 搬家期间,程序可能卡顿(虽然极短)
- 你看到的
stack.push(5)是原子操作,但底层经历了:分配 + 复制 + 赋值 + 释放
💡 小白误区:以为数组像“伸缩水管”,其实是“换新房+搬家具”。
▶ 场景2:用链表建栈(LinkListStack)——“搭积木式增长”
初始化:
const stack = new LinkListStack(); // #stackPeek = null, #size = 0
第1步:push(1)
-
创建新节点
node1 = { val: 1, next: null } -
#stackPeek = node1(栈顶指向 node1) -
结构:
stackPeek ↓ [1] → null
第2步:push(3)
-
创建
node2 = { val: 3, next: node1 } -
#stackPeek = node2 -
结构:
stackPeek ↓ [3] → [1] → null
第3步:push(2)
-
创建
node3 = { val: 2, next: node2 } -
#stackPeek = node3 -
结构:
stackPeek ↓ [2] → [3] → [1] → null
✅ 指针变化规律:
- 新节点的
next永远指向当前栈顶 - 然后
stackPeek指向新节点 - 整个过程不移动任何已有数据,只改指针!
出栈 pop() 时呢?
-
pop()返回 2 -
#stackPeek = node3.next→ 即node2 -
结构变为:
stackPeek ↓ [3] → [1] → null -
node3成为孤儿,等待 GC
🧠 关键理解:
- 链表栈的“增长”和“收缩”只操作头节点
- 没有数据复制,没有内存搬家
- 代价是:每个节点都要额外存一个
next指针
💡 小白误区:以为链表“连在一起”,其实每个节点是独立对象,靠
next指针“串起来”,像火车车厢。
四、ES6 Class 的工程价值:封装与安全
代码中使用了 私有字段 # ,这是 ES2022 的重要特性:
#stackPeek; // 外部无法访问,防止误操作
为什么大厂强调封装?
- 防止外部篡改内部状态(如直接
stack.#stackPeek = null会导致内存泄漏)。 - 隐藏实现细节:用户只需关心
push/pop,无需知道底层是数组还是链表。 - 便于未来重构:今天用数组,明天换链表,API 不变。
🔥 面试高频题:“如何设计一个不可变栈?” → 答案:结合私有字段 + 返回新实例(类似 Immutable.js)。
五、实战场景:什么时候该用哪种栈?
| 场景 | 推荐实现 | 理由 |
|---|---|---|
| 表达式求值、括号匹配 | ArrayStack | 数据量小,性能极致 |
| 深度优先搜索(DFS) | ArrayStack | 节点数可控,缓存友好 |
| 高频创建/销毁的小栈(如 VM 栈帧) | LinkListStack | 避免数组扩容抖动 |
| 内存受限环境(如 IoT) | LinkListStack | 无预留空间,按需分配 |
六、延伸思考:栈还能怎么玩?
- 双栈实现队列:一个入栈,一个出栈,
pop时若出栈空,则倒入入栈。 - 最小栈:维护辅助栈记录历史最小值,
getMin()O(1)。 - 浏览器 history API:本质就是一个栈!
back()=pop(),forward()=push()。
结语:别再只会 arr.push() 了!
栈虽小,五脏俱全。
会用 ≠ 精通。真正的工程师,要能在“简单”中看到“复杂”,在“默认”中质疑“最优”。
下次面试官问:“你怎么实现栈?”
你可以微笑着说:
“看场景。如果追求极致性能且数据量可控,我用数组;如果需要稳定 O(1) 且内存敏感,我选链表。顺便,我还会用私有字段保证封装性。”
——这,才是大厂想要的答案。