栈:不是“入栈”就是“出栈”,但你真的懂它吗?

95 阅读6分钟

栈:不是“入栈”就是“出栈”,但你真的懂它吗?

面试官:“说说栈的实现方式?”
:“数组和链表都能实现。”
面试官(微微一笑):“那……扩容时时间复杂度是多少?空间浪费怎么评估?为什么大厂偏爱链表实现?”
——别慌,今天带你把栈从“会用”讲到“精通”。


一、栈是什么?不只是“先进后出”那么简单

栈(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
    

指针变化规律

  1. 新节点的 next 永远指向当前栈顶
  2. 然后 stackPeek 指向新节点
  3. 整个过程不移动任何已有数据,只改指针!
出栈 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无预留空间,按需分配

六、延伸思考:栈还能怎么玩?

  1. 双栈实现队列:一个入栈,一个出栈,pop 时若出栈空,则倒入入栈。
  2. 最小栈:维护辅助栈记录历史最小值,getMin() O(1)。
  3. 浏览器 history API:本质就是一个栈!back() = pop()forward() = push()

结语:别再只会 arr.push() 了!

栈虽小,五脏俱全。
会用 ≠ 精通。真正的工程师,要能在“简单”中看到“复杂”,在“默认”中质疑“最优”。

下次面试官问:“你怎么实现栈?”
你可以微笑着说:

“看场景。如果追求极致性能且数据量可控,我用数组;如果需要稳定 O(1) 且内存敏感,我选链表。顺便,我还会用私有字段保证封装性。”

——这,才是大厂想要的答案。