用 JavaScript 把“栈”吃透:从数组到链表,再到面试题💡

95 阅读6分钟

很多人学数据结构的时候,一听“栈、队列、链表”就头大。其实用 JavaScript 写一写,你会发现这些东西并不玄乎。

下面按这样的顺序来走一遍:

  • 先用数组感受一下“先进后出”
  • 再抽象出栈这个数据结构:有哪些操作
  • 用数组封装一个栈类
  • 用链表再实现一版栈,对比优缺点
  • 最后用栈解决一个经典面试题:有效括号

中间会顺带把 class、constructor、this、私有属性 #get 这些 ES6 语法串起来。

1. 从数组基本操作说起

最基础的一段代码,大概是这样的:

const arr = [1, 2, 3];
arr.push(4);    // 尾部插入
arr.unshift(0); // 头部插入
console.log(arr);

arr.pop();      // 尾部删除
arr.shift();    // 头部删除
console.log(arr);

这里有四个方法:

  • push:在尾部加一个元素
  • pop:从尾部删一个元素
  • unshift:在头部加
  • shift:从头部删

如果你只用 

push + pop,其实已经在用一个“尾巴作为栈顶”的了:
先 push 进去的,后 pop 出来,典型的 FILO(先进后出)

面试要点🔍:

  • 熟悉数组的基本方法:push/pop/unshift/shift
  • 能说出:只用 push + pop,数组就能当栈来用

2. 抽象一下:什么是“栈”?

简单说,栈就是一种 先进后出(FILO) 的线性结构,通常有这些操作:

  • push(x) :元素 x 入栈(放到栈顶)
  • pop() :弹出栈顶元素,并返回它
  • peek() :只看一眼栈顶元素,不弹出
  • size:当前有多少个元素
  • isEmpty() :栈是否为空

不管底层是数组还是链表,这几个操作的“语义”是一样的,这就是所谓的 ADT(抽象数据类型)

面试要点🔍:

  • 能清楚说出栈的定义:FILO
  • 能列出常见操作及含义,尤其区分好 peek vs pop

3. 直接用数组模拟一个栈

最粗暴的写法就是直接用一个数组:

const stack = [];

// 入栈
stack.push(1);
stack.push(3);
stack.push(2);

// 访问栈顶元素(peek)
const peek = stack[stack.length - 1];

// 出栈
const top = stack.pop();

// 栈的长度
const size = stack.length;

// 是否为空
const isEmpty = stack.length === 0;

约定:数组末尾元素就是栈顶

用数组做栈的好处:

  • push / pop 操作时间复杂度大致是 O(1)
  • JS 原生就支持这些方法,实现简单

面试要点🔍:

  • 能写出用数组实现的 push/pop/peek/isEmpty/size
  • 能说出为什么用“末尾”作为栈顶(方便 O(1) 插删)

4. 用 ES6 class 封装一个“数组栈”

为了更规整一点,可以把“栈”的行为封装到一个类里:

class ArrayStack {
  #stack;
  constructor() {
    this.#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];
  }
  toArray() {
    return this.#stack;
  }
}

这里涉及几个 ES6 的语法点:

  • class:类,抽象出一类对象的属性和方法
  • constructor() :构造函数,在 new 的时候自动调用一次,用来做初始化
  • #stack:私有属性,只能在类内部访问,外部 instance.#stack 会报错
  • get size() :getter,用属性方式访问方法的结果:stack.size(而不是 stack.size())
  • ES6 中的getset 用于定义对象属性的访问器,实现对属性读取和赋值的拦截与控制。

面试要点🔍:

  • 会用 class 和 constructor 封装一个数据结构
  • 知道 get 的作用:对外表现为属性,对内可以计算
  • 清楚私有属性 # 的含义:封装内部实现

5. 再进阶一层:用链表实现栈

数组做栈已经够用了,那为啥还要链表版?

先看链表节点:

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++;
  }

  peek() {
    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 = res.length - 1; i >= 0; i--) {
      res[i] = node.val;
      node = node.next;
    }
    return res;
  }
}

大致思路:

  • 链表头当成栈顶
  • 入栈:在链表头插入新节点(头插法)
  • 出栈:把链表头删掉,指针指向下一个节点

⚙️数组栈 vs 链表栈:优缺点

时间效率

  • 两者的 push/pop/peek/isEmpty/size 单次操作基本都是 O(1)
  • 数组在内部扩容时(容量不够),会有一次 O(n)  的拷贝 (入栈时超出数组容量),但不是每次
  • 链表每次 push 都要 new ListNode 实例化,有一点额外开销,但不需要扩容

空间效率

  • 数组可能会有冗余空间(多预留一些容量)
  • 链表每个节点有额外的 next 指针,占更多空间,但不会有“大块空着”的浪费

面试要点🔍:

  • 能说出两种实现方式:基于数组 / 基于链表
  • 知道各自的时间复杂度和空间特点
  • 能解释链表版为什么“更稳定”(不需要扩容)

加分点🔍:

  • 链表版的 push 和 pop 都是对“头结点”操作,时间复杂度 O(1)

6. 把栈用到实战里:有效括号问题

一个非常经典的面试题:

给定一个只包含 ()[]{} 的字符串,判断括号是否有效。
规则:左括号必须用相同类型的右括号闭合,顺序也要正确。

思路很适合用栈:

  1. 准备一个映射,记录左括号对应的右括号
  2. 从左到右扫描字符串
  3. 遇到左括号:把“期望的右括号”压入栈
  4. 遇到右括号:从栈顶弹出一个期望值,看看是否相等
  5. 扫描结束时,栈必须是空的才是有效

代码大致是这样:

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 {
      // 栈空 或 栈顶不等于当前右括号,直接无效
      if (!stack.length || stack.pop() !== ch) return false;
    }
  }

  // 栈必须为空才有效
  return !stack.length;
};

这里有几个小技巧:

  • 栈里存的是期望的右括号(而不是左括号),这样匹配时简单很多
  • 一旦遇到不匹配,可以立刻返回 false,不必继续扫描
  • 最后用 !stack.length 判断栈是否为空

面试要点🔍:

  • 能用“扫描 + 栈”解决括号匹配问题
  • 能自己写出上面的逻辑,而不是只背答案
  • 清楚栈在这里承载的是“还没匹配完的左括号信息”

7. 总结:栈相关的面试 checklist

知识点层面:

  • 明确栈的特性:FILO,操作只发生在栈顶
  • 会说出:push/pop/peek/isEmpty/size 的语义和实现
  • 知道用数组实现栈的方式,能写出来
  • 知道链表实现栈的大致思路,以及和数组版的优劣对比

代码层面(JS 特有):

  • 能用 class 封装一个栈,写出 constructor、方法、get size()
  • 理解 this 在类方法里的含义:谁调用,this 就是谁的实例
  • 知道私有字段 #xxx 是封装内部实现,不对外暴露