很多人学数据结构的时候,一听“栈、队列、链表”就头大。其实用 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 中的
get和set用于定义对象属性的访问器,实现对属性读取和赋值的拦截与控制。
面试要点🔍:
- 会用
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. 把栈用到实战里:有效括号问题
一个非常经典的面试题:
给定一个只包含
()[]{}的字符串,判断括号是否有效。
规则:左括号必须用相同类型的右括号闭合,顺序也要正确。
思路很适合用栈:
- 准备一个映射,记录左括号对应的右括号
- 从左到右扫描字符串
- 遇到左括号:把“期望的右括号”压入栈
- 遇到右括号:从栈顶弹出一个期望值,看看是否相等
- 扫描结束时,栈必须是空的才是有效
代码大致是这样:
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是封装内部实现,不对外暴露