📖 一文彻底搞懂 JavaScript 栈和队列(建议收藏)
从基础概念到面试实战,看完这篇就够了!
前言
大家好,我是ReBound 。今天来聊聊前端面试中出场率极高 的两个数据结构------栈(Stack) 和 队列(Queue)。
很多同学觉得数据结构很难,其实不然。栈和队列本质上就是对数组操作的封装和限制,理解了它们的特性,刷题时就能事半功倍。
本文将按照 由浅入深 的顺序,带你彻底搞懂栈和队列:
基础铺垫 → 栈入门 → 队列进阶 → 链表对比 → 面试实战
一、基础铺垫:先搞懂 JS 数组
📌 为什么要先学数组? 栈和队列都是基于数组实现的,理解数组的特性是学习它们的前提。
1.1 JS 数组是"假数组"
和其他语言不同,JS 数组不要求元素类型一致,也不要求内存连续:
javascript
const arr = [1, 2, 3, 4];
// 每个元素的类型不一样,不连续,连续也没有意义
const arr2 = ['haha', 1, { a: 1 }]; // 不那么数组了
// arr2[2] 仍然可以通过下标访问
console.log(arr2[2]); // { a: 1 }
console.log(typeof arr2[2], typeof arr[1]); // object number
💡 面试考点 :V8 引擎会根据数组内容选择存储方式------当元素类型一致且连续时,使用快速模式 (连续内存);否则降级为字典模式(哈希表)。所以 JS 数组才能存储不同类型的数据。
1.2 sort() 的经典陷阱
这是新手最容易踩的坑之一:
javascript
let arr = [10, 2, 5];
arr.sort();
console.log(arr); // [10, 2, 5] ← 出错!
// 因为 sort 默认按 Unicode 码点排序(字符串比较),"10" < "2" < "5"
正确做法:传入比较函数
javascript
let arr1 = [10, 2, 5];
arr1.sort((a, b) => a - b);
console.log(arr1); // [2, 5, 10] ✓
🔥 记住 :
sort()不传参 = 按 Unicode 码点(字符串)排序 = 数字排序必翻车!
1.3 splice() 详解
splice() 是数组操作中最强大的方法之一,但也最容易搞混:
javascript
const arr = [1, 2];
// 在索引 1 的位置,删除 0 个元素,插入 3
console.log(arr.splice(1, 0, 3)); // [] (返回被删除的元素)
console.log(arr); // [1, 3, 2]
// 删除索引 1 的 1 个元素
arr.splice(1, 1);
console.log(arr); // [1, 2]
💡 参数速记 :
splice(起始索引, 删除个数, ...要插入的元素)
二、栈(Stack):后进先出
2.1 什么是栈?
📌 一句话理解:栈就像羽毛球筒------只能从一端放入和取出。
┌─────────────┐
│ 巧乐兹 │ ← 栈顶(最后放入,最先取出)
├─────────────┤
│ 冰工厂 │
├─────────────┤
│ 可爱多 │
├─────────────┤
│ 东北大板 │ ← 栈底(最先放入,最后取出)
└─────────────┘
🔥 核心原则 :LIFO(Last In, First Out)后进先出
2.2 用 JS 实现栈
用数组模拟栈非常简单,只需要两个操作:
javascript
const stack = []; // 空栈
// 入栈(push)
stack.push("东北大板");
stack.push("可爱多");
stack.push("冰工厂");
stack.push("巧乐兹");
// 出栈(pop)
while (stack.length) {
const top = stack[stack.length - 1]; // 查看栈顶元素(peek)
console.log(`取出来的是:`, top);
stack.pop(); // 移除栈顶元素
}
// 输出顺序:巧乐兹 → 冰工厂 → 可爱多 → 东北大板
2.3 栈的常用操作
| 操作 | 方法 | 时间复杂度 | 说明 |
|---|---|---|---|
| 入栈 | push() |
O(1) | 添加到栈顶 |
| 出栈 | pop() |
O(1) | 移除栈顶 |
| 查看栈顶 | stack[stack.length - 1] |
O(1) | 不移除,只查看 |
| 判空 | stack.length === 0 |
O(1) | 检查是否为空栈 |
💡 面试考点 :栈的所有操作都是 O(1),这是它最大的优势!
2.4 栈的实战应用
栈在前端中无处不在:
javascript
// 1. 函数调用栈 ------ JavaScript 执行机制的基础
function a() { b(); }
function b() { c(); }
function c() { console.log('done'); }
a(); // 调用栈:a → b → c → 输出 → c出栈 → b出栈 → a出栈
// 2. 撤销/重做(Undo/Redo)------ 编辑器必备功能
const undoStack = [];
const redoStack = [];
function doAction(action) {
undoStack.push(action);
redoStack.length = 0; // 清空重做栈
}
function undo() {
if (undoStack.length) {
const action = undoStack.pop();
redoStack.push(action);
}
}
// 3. 括号匹配 ------ LeetCode 第 20 题
function isValid(s) {
const stack = [];
const map = { ')': '(', ']': '[', '}': '{' };
for (const char of s) {
if ('({['.includes(char)) {
stack.push(char); // 左括号入栈
} else {
if (stack.pop() !== map[char]) return false; // 右括号匹配栈顶
}
}
return stack.length === 0; // 栈空则匹配成功
}
🔥 重点理解 :括号匹配是栈最经典的应用------遇到左括号入栈,遇到右括号出栈匹配!
三、队列(Queue):先进先出
3.1 什么是队列?
📌 一句话理解:队列就像排队买奶茶------先来的人先买到。
scss
入队 → [许, 王, 张] → 出队
↑ ↑
队尾 队首
(新来的) (先走的)
🔥 核心原则 :FIFO(First In, First Out)先进先出
3.2 用 JS 实现队列
javascript
const queue = []; // 空队列
// 入队(push)
queue.push('许');
queue.push('王');
queue.push('张');
// 出队(shift)
while (queue.length) {
const front = queue[0]; // 查看队首元素
console.log(front);
queue.shift(); // 移除队首元素
}
// 输出顺序:许 → 王 → 张
3.3 ⚠️ 性能警告!
上面的实现有个致命问题 :shift() 的时间复杂度是 O(n)!
scss
shift() 执行过程:
[许, 王, 张] → [王, 张]
↑ 移除第一个,后面所有元素都要前移一位
🔥 面试高频考点 :为什么
shift()是 O(n)?因为需要移动所有后续元素!
3.4 优化方案:双指针模拟队列
为了解决 shift() 的性能问题,我们用双指针实现 O(1) 的队列:
javascript
class Queue {
constructor() {
this.items = {};
this.head = 0; // 队首指针
this.tail = 0; // 队尾指针
}
// 入队 O(1)
enqueue(item) {
this.items[this.tail] = item;
this.tail++;
}
// 出队 O(1) ------ 关键:只移动指针,不移动元素!
dequeue() {
if (this.isEmpty()) return undefined;
const item = this.items[this.head];
delete this.items[this.head];
this.head++;
return item;
}
// 查看队首
peek() {
return this.items[this.head];
}
// 获取大小
get size() {
return this.tail - this.head;
}
isEmpty() {
return this.size === 0;
}
}
💡 核心思想 :用
head和tail两个指针标记队首和队尾,只移动指针,不移动元素,从而实现 O(1) 的入队出队!
四、栈 vs 队列:一张图搞懂区别
scss
栈 (LIFO) 队列 (FIFO)
┌─────────────┐ ┌─────────────┐
│ 巧乐兹 │ ← 栈顶 │ 许 → 王 → 张 │
│ 冰工厂 │ └─────────────┘
│ 可爱多 │ ↑ ↑
│ 东北大板 │ 入队 出队
└─────────────┘
↑ 入/出 先进先出
后进先出
| 特性 | 栈 | 队列 |
|---|---|---|
| 原则 | LIFO 后进先出 | FIFO 先进先出 |
| 入操作 | push() O(1) |
push() O(1) |
| 出操作 | pop() O(1) |
shift() O(n) / 优化后 O(1) |
| 生活类比 | 羽毛球筒、叠盘子 | 排队、打印机队列 |
| 典型应用 | 函数调用栈、撤销操作、括号匹配 | 消息队列、BFS、任务调度 |
五、链表:另一种线性结构
📌 为什么要学链表? 理解链表有助于深入理解数据结构的本质,也是面试常考内容。
5.1 什么是链表?
链表和数组不同,它的元素不需要连续存储,而是通过指针连接:
css
数组:[1] [2] [3] [4] ← 内存连续,按下标访问
链表:[1] → [2] → [3] → [4] → null ← 通过指针连接,按顺序访问
5.2 用 JS 实现链表节点
javascript
function ListNode(val) {
this.val = val;
this.next = null; // 指向下一个节点的指针
}
// 创建链表 1 → 2 → 3
const node = new ListNode(1);
node.next = new ListNode(2);
node.next.next = new ListNode(3);
console.log(node);
// ListNode {
// val: 1,
// next: ListNode {
// val: 2,
// next: ListNode { val: 3, next: null }
// }
// }
5.3 数组 vs 链表对比
| 特性 | 数组 | 链表 |
|---|---|---|
| 内存 | 连续 | 分散 |
| 访问 | O(1) 随机访问 | O(n) 顺序访问 |
| 插入/删除 | O(n) 需要移动元素 | O(1) 只改指针(前提是已定位到节点) |
| 缓存友好 | 好 | 差 |
| 适用场景 | 频繁读取 | 频繁增删 |
💡 面试考点 :链表插入删除是 O(1),但定位到节点需要 O(n),所以整体仍然是 O(n)!
六、面试真题实战
题目 1:用栈实现队列(LeetCode 232)
🔥 经典思路:两个栈"翻转"一次,就变成了队列!
javascript
class MyQueue {
constructor() {
this.stackIn = []; // 入队栈
this.stackOut = []; // 出队栈
}
push(x) {
this.stackIn.push(x);
}
pop() {
// 如果出队栈为空,把入队栈的元素全部倒入
if (!this.stackOut.length) {
while (this.stackIn.length) {
this.stackOut.push(this.stackIn.pop());
}
}
return this.stackOut.pop();
}
peek() {
const val = this.pop();
this.stackOut.push(val);
return val;
}
empty() {
return !this.stackIn.length && !this.stackOut.length;
}
}
图解过程:
scss
入队:push(1), push(2), push(3)
stackIn: [1, 2, 3]
stackOut: []
出队:pop()
stackIn 为空,倒入 stackOut
stackIn: []
stackOut: [3, 2, 1] ← 栈翻转了!
返回 stackOut.pop() = 1 ← 先进先出!
题目 2:有效的括号(LeetCode 20)
🔥 核心思路:遇到左括号入栈,遇到右括号出栈匹配!
javascript
function isValid(s) {
if (s.length % 2) return false; // 奇数直接 false
const stack = [];
const map = new Map([
[')', '('],
[']', '['],
['}', '{']
]);
for (const c of s) {
if (map.has(c)) {
// 是右括号,检查栈顶是否匹配
if (stack.length === 0 || stack.pop() !== map.get(c)) {
return false;
}
} else {
// 是左括号,入栈
stack.push(c);
}
}
return stack.length === 0; // 栈空则全部匹配
}
示例:
arduino
输入:"(]"
过程:
'(' 是左括号,入栈 → stack: ['(']
']' 是右括号,栈顶是 '(',不匹配 → return false
输出:false
七、总结
核心知识点速查表
| 数据结构 | 核心原则 | 关键操作 | 时间复杂度 |
|---|---|---|---|
| 栈 | LIFO 后进先出 | push / pop | 都是 O(1) |
| 队列 | FIFO 先进先出 | push / shift | shift 是 O(n),优化后 O(1) |
| 链表 | 指针连接 | 定位 O(n),插入删除 O(1) | 整体 O(n) |
必背结论
scss
┌─────────────────────────────────────────────────────────────┐
│ 1. 栈:后进先出 LIFO,push/pop 都是 O(1) │
│ 2. 队列:先进先出 FIFO,注意 shift 的 O(n) 陷阱 │
│ 3. 链表:通过指针连接,插入删除 O(1),访问 O(n) │
│ 4. sort():不传参按 Unicode 排序,数字排序必须传比较函数 │
└─────────────────────────────────────────────────────────────┘
最后送大家一句话 :数据结构不是背出来的,是画出来、写出来、用出来的。建议大家看完文章后,自己动手实现一遍栈和队列,再刷几道 LeetCode,保证印象深刻!
📌 觉得有帮助的话,点个赞👍再走吧~
📧 有问题欢迎评论区讨论,我会一一回复!
🔖 关注我,持续更新前端算法系列文章!
📚 相关资源
LeetCode 必刷题目
| 题目 | 难度 | 链接 |
|---|---|---|
| 20. 有效的括号 | 🟢 简单 | leetcode.cn/problems/va... |
| 155. 最小栈 | 🟡 中等 | leetcode.cn/problems/mi... |
| 232. 用栈实现队列 | 🟢 简单 | leetcode.cn/problems/im... |
| 225. 用队列实现栈 | 🟢 简单 | leetcode.cn/problems/im... |
| 239. 滑动窗口最大值 | 🔴 困难 | leetcode.cn/problems/sl... |
| 1047. 删除字符串中的所有相邻重复项 | 🟢 简单 | leetcode.cn/problems/re... |